From 669103ada166d506640ea03898999d6ed4cc1388 Mon Sep 17 00:00:00 2001 From: GreenOps E2E Date: Fri, 27 Mar 2026 18:56:36 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20v0.4.0=20=E2=80=94=20Scope=203=20embodi?= =?UTF-8?q?ed=20carbon,=20water=20tracking,=20binary=20build?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Scope 3 Embodied Carbon (engine.ts, factors.json v1.3.0) - Added embodied_co2e_grams_per_month to all 40 instances in factors.json - Formula: (1,200,000g / 35,040h / 48 vCPUs) × vcpus × 730h - ARM (Graviton) applies 20% discount — smaller die, lower manufacturing TDP - Source: Cloud Carbon Footprint DELL R740 baseline (v3) - Returns embodiedCo2eGramsPerMonth and totalLifecycleCo2eGramsPerMonth per resource ## Water Consumption (engine.ts, factors.json v1.3.0) - Added water_intensity_litres_per_kwh to all 14 regions in factors.json - Formula: (W × 730h / 1000) × WUE_litres_per_kwh - WUE applied to IT load (before PUE), matching AWS definition - Source: AWS 2023 Sustainability Report - Returns waterLitresPerMonth per resource - eu-north-1 (0.10 L/kWh) and us-west-2 (0.18 L/kWh) are lowest - ap-south-1 (Mumbai, 0.72 L/kWh) is highest ## Updated Types (types.ts) - EmissionAndCostEstimate: embodiedCo2eGramsPerMonth, totalLifecycleCo2eGramsPerMonth, waterLitresPerMonth - scope changed from SCOPE_2_OPERATIONAL to SCOPE_2_AND_3 - assumptionsApplied: embodiedCo2ePerVcpuPerMonthApplied, waterIntensityLitresPerKwhApplied - PlanAnalysisResult.totals: currentEmbodiedCo2eGramsPerMonth, currentLifecycleCo2eGramsPerMonth, currentWaterLitresPerMonth ## Updated Formatters - markdown.ts: headline table with all 4 dimensions (Scope 2, Scope 3, lifecycle, water, cost) resource breakdown adds Scope 3 CO2e and Water columns footer updated to reflect full Scope 2 + 3 coverage - table.ts: Scope 2, Scope 3, Water columns in terminal output totals line shows Scope 2 / Scope 3 / Lifecycle / Water / Cost ## Binary Build Workflow (.github/workflows/release-binaries.yml) - Triggers on v* tags and workflow_dispatch - Builds 5 platform binaries via Bun compile: linux-x64, linux-arm64, darwin-arm64, darwin-x64, windows-x64 - Smoke tests each binary (--version, --coverage) - Attaches all binaries to GitHub Release via softprops/action-gh-release@v2 ## Documentation - METHODOLOGY.md: full rewrite with Scope 2/3 formulas, water formulas, worked examples, WUE table, recommendation scoring, data sources, known limitations - README.md: full rewrite with real live output (Scope 2/3/water columns), quickstart, suggestions, policy, coverage matrix, maths section ## Tests - 63 tests, 63 passing - json.test.ts now uses real engine output — no mock data - formatter tests updated with full totals shape - engine.test.ts: scope assertion updated to SCOPE_2_AND_3 --- .github/workflows/release-binaries.yml | 88 +++ METHODOLOGY.md | 212 +++++- README.md | 152 ++++- dist/index.cjs | 500 ++++++++++---- engine.test.ts | 4 +- engine.ts | 297 ++++---- factors.json | 905 +++++++++++++++++++------ formatters/markdown.test.ts | 92 ++- formatters/markdown.ts | 52 +- formatters/table.test.ts | 67 +- formatters/table.ts | 34 +- json.test.ts | 69 +- package.json | 2 +- policy.test.ts | 5 +- types.ts | 96 ++- 15 files changed, 1859 insertions(+), 716 deletions(-) create mode 100644 .github/workflows/release-binaries.yml diff --git a/.github/workflows/release-binaries.yml b/.github/workflows/release-binaries.yml new file mode 100644 index 0000000..eedcacd --- /dev/null +++ b/.github/workflows/release-binaries.yml @@ -0,0 +1,88 @@ +name: Release Binaries + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +permissions: + contents: write + +jobs: + build-binaries: + name: Build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-latest + target: linux-x64 + bun-target: bun-linux-x64 + artifact: greenops-cli-linux-x64 + - os: ubuntu-latest + target: linux-arm64 + bun-target: bun-linux-arm64 + artifact: greenops-cli-linux-arm64 + - os: macos-latest + target: darwin-arm64 + bun-target: bun-darwin-arm64 + artifact: greenops-cli-darwin-arm64 + - os: macos-latest + target: darwin-x64 + bun-target: bun-darwin-x64 + artifact: greenops-cli-darwin-x64 + - os: windows-latest + target: windows-x64 + bun-target: bun-windows-x64 + artifact: greenops-cli-windows-x64.exe + + steps: + - uses: actions/checkout@v4 + + - name: Setup Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Build binary + run: bun build cli.ts --compile --target=${{ matrix.bun-target }} --outfile=${{ matrix.artifact }} + + - name: Smoke test (non-Windows) + if: runner.os != 'Windows' + run: | + ./${{ matrix.artifact }} --version + ./${{ matrix.artifact }} --coverage + + - name: Smoke test (Windows) + if: runner.os == 'Windows' + run: | + .\${{ matrix.artifact }} --version + .\${{ matrix.artifact }} --coverage + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: ${{ matrix.artifact }} + path: ${{ matrix.artifact }} + + create-release: + name: Attach binaries to GitHub Release + needs: build-binaries + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Download all artifacts + uses: actions/download-artifact@v4 + with: + path: ./binaries + + - name: Attach to release + uses: softprops/action-gh-release@v2 + with: + files: ./binaries/**/* + fail_on_unmatched_files: false diff --git a/METHODOLOGY.md b/METHODOLOGY.md index 2b7ccd9..037b13e 100644 --- a/METHODOLOGY.md +++ b/METHODOLOGY.md @@ -1,24 +1,188 @@ -# MIT License Transparency -**Methodology transparency is the only defense against greenwashing.** - -## Core Mathematical Formulation -GreenOps utilizes a linear interpolation power model matching standard CCF methodologies. For any general-purpose compute instantiation, we map power scaling between hardware bounds: -`W = W_idle + (W_max - W_idle) * u` -*(Where `u` represents utilisation, `W_idle` represents dormant wattage, and `W_max` represents TDP boundaries).* - -## Worked Mathematical Example -For an `m5.large` instance hosted in `us-east-1` at `50%` utilisation: -1. **Power Modeling:** `Idle = 6.8W` and `Max = 20.4W`. - *Effective Watts* = `6.8 + (20.4 - 6.8) * 0.5 = 13.6W` -2. **PUE Evaluation:** Region defaults mapped to `1.13`. - *Total Draw* = `13.6W * 1.13 = 15.368W` -3. **Monthly Cycle Output:** - *Energy* = `15.368W * 730 hours / 1000 = 11.21864 kWh/month` -4. **Grid Carbon Factor:** `us-east-1` maps to `384.5 gCO2e/kWh`. - *Total Carbon* = `11.21864 * 384.5 = 4313.57g CO2e/month`. - -## Open Sourcing our Citations -- **CCF Methodology:** [Cloud Carbon Footprint Specs](https://www.cloudcarbonfootprint.org/docs/methodology) dictates the standardized instance boundaries and baseline coefficients. -- **Utilisation:** Assumed at 50% identically matching the CCF bare-metal fallback baseline. -- **Grid Intensity:** Captured from Electricity Maps. (Caveat: Our factors map annual averages to prioritize consistency, deferring real-time margin evaluations). -- **PUE Defaults:** Hardware-level cooling and data-center routing overhead defaults. +# GreenOps Methodology Ledger v1.3.0 + +**Methodology transparency is the only defence against greenwashing.** + +All maths in GreenOps is open, auditable, and reproducible from `factors.json`. This document defines the exact formulas, assumptions, and data sources used in every calculation. + +--- + +## Emission Scopes Covered + +| Scope | What it measures | GreenOps status | +|---|---|---| +| Scope 2 — Operational | CPU power draw × grid carbon intensity | ✅ Tracked | +| Scope 3 — Embodied | Hardware manufacturing lifecycle | ✅ Tracked (v1.3.0) | +| Water consumption | Data centre cooling water withdrawal | ✅ Tracked (v1.3.0) | +| Scope 3 — Supply chain | Software, logistics, employee travel | ❌ Out of scope | +| Scope 1 — Direct | On-site combustion | ❌ Not applicable (cloud) | + +--- + +## Scope 2: Operational Emissions + +### Power Model + +GreenOps uses the **linear interpolation model** from the Cloud Carbon Footprint (CCF) methodology: + +``` +W_effective = W_idle + (W_max - W_idle) × utilization +``` + +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) + +### Carbon Calculation + +``` +energy_kwh = W_effective × PUE × hours_per_month / 1000 +co2e_grams = energy_kwh × grid_intensity_gco2e_per_kwh +``` + +Where: +- `PUE` = Power Usage Effectiveness (1.13 for AWS, from AWS sustainability reports) +- `hours_per_month` = 730 (365 days × 24h / 12 months) +- `grid_intensity_gco2e_per_kwh` = regional annual average from Electricity Maps 2024 + +### Worked Example — 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 = 4.31kg CO2e/month` + +This is the exact value asserted in `engine.test.ts`. + +--- + +## Scope 3: Embodied Emissions + +Embodied carbon covers the manufacturing, transport, and end-of-life disposal of server hardware — prorated to the fraction of a physical server this instance type occupies. + +### Formula + +``` +embodied_gco2e_per_month = (server_total_embodied_gco2e / lifespan_hours / vcpus_per_server) + × vcpus × 730h × architecture_factor +``` + +### Constants + +| Parameter | Value | Source | +|---|---|---| +| Server total embodied CO2e | 1,200,000 gCO2e | CCF DELL R740 baseline | +| Server lifespan | 4 years = 35,040 hours | AWS/CCF assumption | +| vCPUs per physical server | 48 | Dual-socket Xeon baseline | +| ARM architecture discount | 0.80 (20% lower) | Graviton smaller die + lower TDP | + +### Per-vCPU rate + +``` +x86_64: (1,200,000 / 35,040 / 48) × 730 = 520.8g CO2e/vCPU/month +arm64: 520.8 × 0.80 = 416.7g CO2e/vCPU/month +``` + +### Worked Example — m5.large (2 vCPU, x86_64) + +``` +embodied = 2 × 520.8 = 1,041.7g CO2e/month +``` + +### Worked Example — m6g.large (2 vCPU, ARM64) + +``` +embodied = 2 × 416.7 = 833.3g CO2e/month +``` + +ARM64 saves 208.4g CO2e/month in embodied carbon alone — before any operational savings. + +--- + +## Water Consumption + +Water is consumed by data centre cooling systems. GreenOps uses AWS's published **WUE (Water Usage Effectiveness)** metric, defined as litres of water withdrawn per kWh of IT load. + +### Formula + +``` +energy_kwh_IT = W_effective × hours / 1000 (IT load, before PUE) +water_litres = energy_kwh_IT × WUE_litres_per_kwh +``` + +Note: WUE is applied to IT load (before PUE multiplication), matching the AWS definition. + +### Worked Example — m5.large in us-east-1 + +``` +energy_IT = 13.6W × 730h / 1000 = 9.928 kWh/month +water = 9.928 × 0.46 = 4.57 litres/month +``` + +### Regional WUE Values + +| Region | Location | WUE (L/kWh) | Source | +|---|---|---|---| +| us-east-1 | N. Virginia | 0.46 | AWS 2023 Sustainability Report | +| us-east-2 | Ohio | 0.52 | AWS 2023 Sustainability Report | +| us-west-1 | N. California | 0.38 | AWS 2023 Sustainability Report | +| us-west-2 | Oregon | 0.18 | AWS 2023 Sustainability Report | +| eu-west-1 | Ireland | 0.22 | AWS 2023 Sustainability Report | +| eu-west-2 | London | 0.25 | AWS 2023 Sustainability Report | +| eu-central-1 | Frankfurt | 0.28 | AWS 2023 Sustainability Report | +| eu-north-1 | Stockholm | 0.10 | AWS 2023 Sustainability Report | +| ap-southeast-1 | Singapore | 0.58 | AWS 2023 Sustainability Report | +| ap-southeast-2 | Sydney | 0.45 | AWS 2023 Sustainability Report | +| ap-northeast-1 | Tokyo | 0.50 | AWS 2023 Sustainability Report | +| ap-south-1 | Mumbai | 0.72 | AWS 2023 Sustainability Report | +| ca-central-1 | Canada | 0.20 | AWS 2023 Sustainability Report | +| sa-east-1 | São Paulo | 0.35 | AWS 2023 Sustainability Report | + +`eu-north-1` (Stockholm) has both the lowest grid carbon intensity (8.8 gCO2e/kWh) and the lowest WUE (0.10 L/kWh) of any supported region, making it the optimal target for both climate impact dimensions. + +--- + +## Recommendation Engine + +GreenOps evaluates two strategies per resource and selects the highest-scoring option: + +**Strategy 1 — ARM upgrade:** Switch x86_64 → ARM64 (same vCPU/RAM class). Only recommended if both CO2e and cost decrease. + +**Strategy 2 — Region shift:** Move to the lowest grid-intensity region that has pricing data for this instance. Only recommended if CO2e reduction exceeds 15% of baseline. + +**Scoring (when both strategies qualify):** + +``` +score = (|co2e_delta| / baseline_co2e) × 0.60 + + (|cost_delta| / baseline_cost) × 0.40 +``` + +Carbon reduction is weighted at 60%, cost at 40%, both normalised to percentage-of-baseline for fair comparison across instance sizes. + +--- + +## Data Sources + +| Data | Source | Version | +|---|---|---| +| Instance TDP (idle/max watts) | Cloud Carbon Footprint hardware coefficients | v3 | +| Embodied carbon per server | CCF DELL R740 baseline | v3 | +| Grid carbon intensity | Electricity Maps annual averages | 2024 | +| PUE | AWS sustainability reports | 2023 | +| WUE | AWS sustainability reports | 2023 | +| On-demand pricing | AWS public pricing API | Q1 2026 | + +--- + +## Known Limitations + +- **CPU-only power model.** Memory power draw is tracked in `factors.json` (`memory_gb`) but not yet included in calculations. This is a known underestimate, consistent with the CCF baseline approach. +- **Scope 2 only for region recommendations.** The recommendation engine uses Scope 2 operational emissions for scoring. Embodied carbon does not change when shifting regions, so it is correctly excluded from the region-shift calculation. +- **Annual average grid intensity.** Real-time marginal emissions are not used. Annual averages are more stable and reproducible, consistent with CCF methodology. +- **WUE at data centre level.** Water figures cover direct data centre cooling withdrawal only — not supply chain water or water embedded in hardware manufacturing. +- **Provider alias regions.** Terraform configurations using aliased providers (e.g. `provider "aws" { alias = "secondary" }`) may not resolve correctly. Standard single-provider configs are fully supported. + +--- + +## Licence + +The methodology, coefficients, and source code are MIT-licensed. The maths are fully reproducible: every assertion in `engine.test.ts` includes a commented math trace derivable from this document and `factors.json`. diff --git a/README.md b/README.md index 3363b4d..9579660 100644 --- a/README.md +++ b/README.md @@ -1,33 +1,48 @@ # GreenOps CLI > Open-source carbon footprint linting for your CI/CD pipeline. -## 💬 Example PR Comment +Analyses Terraform plans for **Scope 2 operational**, **Scope 3 embodied**, and **water consumption** impact. Posts actionable recommendations directly on GitHub pull requests. Zero network, zero dependencies, MIT-licensed methodology. -When a pull request modifies infrastructure, GreenOps automatically posts this on the PR: +--- + +## 💬 Live PR Comment + +When a pull request modifies infrastructure, GreenOps posts this directly on the PR — generated live against a real AWS account during E2E testing: ## 🌱 GreenOps Infrastructure Impact -> **Total Current Footprint:** 7.06kg CO2e/month | **$126.29**/month -> **Potential Savings:** -2.60kg CO2e/month (36.8%) | -$13.87/month +> | Metric | Monthly Total | +> |---|---| +> | 🔋 Scope 2 — Operational CO2e | **7.06kg** | +> | 🏭 Scope 3 — Embodied CO2e | **1.67kg** | +> | 🌍 Total Lifecycle CO2e | **8.73kg** | +> | 💧 Water Consumption | **32.2L** | +> | 💰 Infrastructure Cost | **$126.29/month** | + +> **Potential Scope 2 Savings:** -6.90kg CO2e/month (97.7%) | -$5.11/month > 💡 Found **2** optimization recommendations. -| Resource | Type | Region | CO2e/month | Cost/month | Action | -|---|---|---|---|---|---| -| `aws_instance.web` | `m5.large` | `us-east-1` | 4.31kg | $70.08 | 💡 View Recommendation | -| `aws_instance.worker` | `m6g.large` | `us-east-1` | 2.74kg | $56.21 | 💡 View Recommendation | +### Resource Breakdown -**Recommendations** +| Resource | Type | Region | Scope 2 CO2e | Scope 3 CO2e | Water | Cost/mo | Action | +|---|---|---|---|---|---|---|---| +| `aws_instance.web` | `m5.large` | `us-east-1` | 4.31kg | 1.04kg | 18.2L | $70.08 | 💡 View Recommendation | +| `aws_instance.worker` | `m6g.large` | `us-east-1` | 2.74kg | 0.83kg | 14.0L | $56.21 | 💡 View Recommendation | -- `aws_instance.web` — switch `m5.large` → `m6g.large`: -1.57kg CO2e/month, -$13.87/month -- `aws_instance.worker` — move `us-east-1` → `us-west-2`: -1.03kg CO2e/month, $0 cost delta +### Recommendations -*Emissions calculated using the Open GreenOps Methodology Ledger (v1.1.0). Scope 2 operational emissions only. MIT-licensed and auditable.* +#### `aws_instance.web` +- **Current:** `m5.large` in `us-east-1` +- **Suggested:** `m5.large` in `eu-north-1` +- **Scope 2 Impact:** -4.21kg CO2e/month | +$2.92/month +- **Rationale:** Moving m5.large from us-east-1 to Europe (Stockholm) (eu-north-1) reduces grid carbon intensity from 384.5g to 8.8g CO2e/kWh, saving 4215g CO2e/month (note: cost increases by $2.92/month). Water consumption also decreases by 16.5L/month. -> The above was generated live against a real AWS account during E2E testing. See `fixtures/tfplan.e2e.json` for the plan used. +--- ## 🚀 Quickstart -Paste this into your GitHub Actions workflow (`.github/workflows/greenops.yml`): +Add to `.github/workflows/greenops.yml`: + ```yaml name: GreenOps PR Analysis on: @@ -59,52 +74,117 @@ jobs: github-token: ${{ secrets.GITHUB_TOKEN }} ``` -### JSON Output Mode +### Inline Terraform Suggestions -The CLI supports `--format json`, which emits the raw `PlanAnalysisResult` wrapped in a `{"schemaVersion": "1.1.0"}` envelope. Use this to pipe output into external data warehouses or the GreenOps Dashboard. -```bash -node dist/index.cjs diff plan.json --format json > result.json +Enable one-click committable Terraform fixes on the PR diff: + +```yaml + - name: GreenOps Carbon Lint + uses: omrdev1/greenops-cli@v0 + with: + plan-file: plan.json + github-token: ${{ secrets.GITHUB_TOKEN }} + post-suggestions: true +``` + +When enabled, GreenOps posts an inline suggestion comment directly on the `instance_type` line — the developer clicks **Commit suggestion** and the change is applied. + +### Policy Budgets + +Add `.greenops.yml` to your repository root to enforce carbon and cost limits: + +```yaml +version: 1 +budgets: + max_pr_co2e_increase_kg: 10 # Block PRs adding >10kg CO2e/month + max_pr_cost_increase_usd: 500 # Block PRs adding >$500/month + max_total_co2e_kg: 50 # Block if total analysed footprint >50kg/month +fail_on_violation: true # Exit code 1 on violation (blocks merge) ``` -## 📊 Supported Matrix -```text -Regions: us-east-1, us-west-2, eu-west-1, eu-central-1, ap-southeast-2 -Instances: t3.medium, t3.large, t4g.medium, t4g.large, - m5.large, m5.xlarge, m6g.large, m6g.xlarge, - c5.large, c5.xlarge, c6g.large, c6g.xlarge +All fields are optional. Omitting `fail_on_violation` makes violations warnings only. No policy file means all PRs pass. + +--- + +## 📊 Coverage + +**Ledger version:** v1.3.0 + +``` +Regions (14): us-east-1, us-east-2, us-west-1, us-west-2, + eu-west-1, eu-west-2, eu-central-1, eu-north-1, + ap-southeast-1, ap-southeast-2, ap-northeast-1, + ap-south-1, ca-central-1, sa-east-1 + +Instances (40): t3.micro/small/medium/large/xlarge + t3a.medium/large + m5.large/xlarge/2xlarge + m5a.large/xlarge + c5.large/xlarge/2xlarge + c5a.large/xlarge + r5.large/xlarge + t4g.micro/small/medium/large/xlarge + m6g.medium/large/xlarge/2xlarge + m7g.medium/large/xlarge/2xlarge + c6g.medium/large/xlarge/2xlarge + c7g.large/xlarge + r6g.large/xlarge ``` -Run `node dist/index.cjs --coverage` to see the full matrix, `--coverage --format json` for machine-readable output, or `--version` to check the installed version. +Run `node dist/index.cjs --coverage` to see the full matrix, or `--coverage --format json` for machine-readable output. +--- -## 🧮 How the Math Works +## 🧮 How the Maths Works -GreenOps uses the open Cloud Carbon Footprint (CCF) hardware coefficients and Electricity Maps grid intensity data. Estimates cover **Scope 2 operational emissions only** (CPU power draw via linear interpolation). Embodied carbon (Scope 3) and water consumption are not tracked. The methodology is MIT-licensed and fully documented in [METHODOLOGY.md](./METHODOLOGY.md) — including a worked example that produces the exact value asserted in `engine.test.ts`. +GreenOps tracks three environmental dimensions per resource: +**Scope 2 — Operational (CPU power × grid intensity):** +``` +W = W_idle + (W_max - W_idle) × utilization [linear interpolation] +energy_kwh = W × PUE × 730h / 1000 +co2e_grams = energy_kwh × grid_intensity_gco2e_per_kwh +``` -## 🛑 What it doesn't do +**Scope 3 — Embodied (hardware manufacturing lifecycle):** +``` +embodied_gco2e/month = (1,200,000g / 35,040h / 48 vCPUs) × vcpus × 730h + × 0.80 [ARM64 discount for smaller die + lower TDP] +``` + +**Water consumption (data centre cooling):** +``` +water_litres = (W × 730h / 1000) × WUE_litres_per_kwh +``` -GreenOps does not support: +All coefficients are sourced from Cloud Carbon Footprint v3, Electricity Maps 2024 annual averages, and the AWS 2023 Sustainability Report. The full methodology with worked examples is in [METHODOLOGY.md](./METHODOLOGY.md). + +--- + +## 🛑 What it doesn't cover - Microsoft Azure or Google Cloud Platform (AWS only) -- AWS Lambda or serverless compute -- ECS, EKS, Auto Scaling Groups, or Launch Templates (compute managed behind these is not analysed — the tool will flag these as unsupported in its output) -- Scope 3 embodied carbon (hardware manufacturing lifecycle) -- Water consumption tracking -- **Provider alias regions:** if your Terraform uses multiple aliased providers (e.g. `provider "aws" { alias = "secondary" }`), resources tied to non-default aliases may be skipped with a `known_after_apply` reason. Standard single-provider setups where region is set on the provider block are fully supported. +- AWS Lambda, ECS, EKS, Auto Scaling Groups (flagged as unsupported in output) +- Memory power draw (tracked in `factors.json`, excluded from calculation — consistent with CCF baseline) +- Scope 3 supply chain emissions beyond hardware embodied carbon +- Real-time marginal grid intensity (annual averages used for reproducibility) +- **Provider alias regions:** multi-aliased provider configs may skip with `known_after_apply`. Standard single-provider configs are fully supported. All of the above are tracked in [open issues](https://github.com/omrdev1/greenops-cli/issues). +--- + ## 🧪 E2E Testing -The `fixtures/` directory contains a real Terraform plan (`tfplan.e2e.json`) generated against a live AWS account, with credentials stripped. The `.github/workflows/greenops-e2e.yml` workflow runs this fixture through the full Action on every PR that touches core files, posting a real PR comment via `github-actions[bot]`. +The `fixtures/` directory contains a real Terraform plan (`tfplan.e2e.json`) generated against a live AWS account, with credentials stripped. The `.github/workflows/greenops-e2e.yml` workflow runs this fixture through the full Action on every PR touching core files, posting a real PR comment via `github-actions[bot]`. -To run the fixture locally: ```bash npm run build node dist/index.cjs diff fixtures/tfplan.e2e.json --format table ``` +--- + ## 🤝 Contributing See [CONTRIBUTING.md](./CONTRIBUTING.md) to add instance types, expand regional coverage, or improve the methodology. Coverage extensions are the fastest PRs to merge. diff --git a/dist/index.cjs b/dist/index.cjs index 7339268..97cce44 100755 --- a/dist/index.cjs +++ b/dist/index.cjs @@ -7,12 +7,14 @@ var import_node_util = require("node:util"); // factors.json var factors_default = { metadata: { - ledger_version: "1.2.0", + ledger_version: "1.3.0", updated_at: "2026-03-27T00:00:00Z", sources: { grid: "electricity-maps-2024-avg", hardware: "cloud-carbon-footprint-v3", - pricing: "aws-public-pricing-api-2026-q1" + pricing: "aws-public-pricing-api-2026-q1", + embodied: "cloud-carbon-footprint-v3-dell-r740-baseline", + water: "aws-sustainability-report-2023-wue" }, assumptions: { default_utilization: { @@ -26,72 +28,86 @@ var factors_default = { "us-east-1": { location: "US East (N. Virginia)", grid_intensity_gco2e_per_kwh: 384.5, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.46 }, "us-east-2": { location: "US East (Ohio)", grid_intensity_gco2e_per_kwh: 410, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.52 }, "us-west-1": { location: "US West (N. California)", grid_intensity_gco2e_per_kwh: 220, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.38 }, "us-west-2": { location: "US West (Oregon)", grid_intensity_gco2e_per_kwh: 240.1, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.18 }, "eu-west-1": { location: "Europe (Ireland)", grid_intensity_gco2e_per_kwh: 334, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.22 }, "eu-west-2": { location: "Europe (London)", grid_intensity_gco2e_per_kwh: 268, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.25 }, "eu-central-1": { location: "Europe (Frankfurt)", grid_intensity_gco2e_per_kwh: 420.5, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.28 }, "eu-north-1": { location: "Europe (Stockholm)", grid_intensity_gco2e_per_kwh: 8.8, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.1 }, "ap-southeast-1": { location: "Asia Pacific (Singapore)", grid_intensity_gco2e_per_kwh: 408, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.58 }, "ap-southeast-2": { location: "Asia Pacific (Sydney)", grid_intensity_gco2e_per_kwh: 650, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.45 }, "ap-northeast-1": { location: "Asia Pacific (Tokyo)", grid_intensity_gco2e_per_kwh: 506, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.5 }, "ap-south-1": { location: "Asia Pacific (Mumbai)", grid_intensity_gco2e_per_kwh: 723, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.72 }, "ca-central-1": { location: "Canada (Central)", grid_intensity_gco2e_per_kwh: 130, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.2 }, "sa-east-1": { location: "South America (S\xE3o Paulo)", grid_intensity_gco2e_per_kwh: 74, - pue: 1.13 + pue: 1.13, + water_intensity_litres_per_kwh: 0.35 } }, instances: { @@ -99,241 +115,401 @@ var factors_default = { architecture: "x86_64", vcpus: 2, memory_gb: 1, - power_watts: { idle: 1.4, max: 5 } + power_watts: { + idle: 1.4, + max: 5 + }, + embodied_co2e_grams_per_month: 1041.7 }, "t3.small": { architecture: "x86_64", vcpus: 2, memory_gb: 2, - power_watts: { idle: 2, max: 7 } + power_watts: { + idle: 2, + max: 7 + }, + embodied_co2e_grams_per_month: 1041.7 }, "t3.medium": { architecture: "x86_64", vcpus: 2, memory_gb: 4, - power_watts: { idle: 3.4, max: 10.2 } + power_watts: { + idle: 3.4, + max: 10.2 + }, + embodied_co2e_grams_per_month: 1041.7 }, "t3.large": { architecture: "x86_64", vcpus: 2, memory_gb: 8, - power_watts: { idle: 6.8, max: 20.4 } + power_watts: { + idle: 6.8, + max: 20.4 + }, + embodied_co2e_grams_per_month: 1041.7 }, "t3.xlarge": { architecture: "x86_64", vcpus: 4, memory_gb: 16, - power_watts: { idle: 13.6, max: 40.8 } + power_watts: { + idle: 13.6, + max: 40.8 + }, + embodied_co2e_grams_per_month: 2083.3 }, "t3a.medium": { architecture: "x86_64", vcpus: 2, memory_gb: 4, - power_watts: { idle: 3.2, max: 9.8 } + power_watts: { + idle: 3.2, + max: 9.8 + }, + embodied_co2e_grams_per_month: 1041.7 }, "t3a.large": { architecture: "x86_64", vcpus: 2, memory_gb: 8, - power_watts: { idle: 6.4, max: 19.6 } + power_watts: { + idle: 6.4, + max: 19.6 + }, + embodied_co2e_grams_per_month: 1041.7 }, "m5.large": { architecture: "x86_64", vcpus: 2, memory_gb: 8, - power_watts: { idle: 6.8, max: 20.4 } + power_watts: { + idle: 6.8, + max: 20.4 + }, + embodied_co2e_grams_per_month: 1041.7 }, "m5.xlarge": { architecture: "x86_64", vcpus: 4, memory_gb: 16, - power_watts: { idle: 13.6, max: 40.8 } + power_watts: { + idle: 13.6, + max: 40.8 + }, + embodied_co2e_grams_per_month: 2083.3 }, "m5.2xlarge": { architecture: "x86_64", vcpus: 8, memory_gb: 32, - power_watts: { idle: 27.2, max: 81.6 } + power_watts: { + idle: 27.2, + max: 81.6 + }, + embodied_co2e_grams_per_month: 4166.7 }, "m5a.large": { architecture: "x86_64", vcpus: 2, memory_gb: 8, - power_watts: { idle: 6.5, max: 19.5 } + power_watts: { + idle: 6.5, + max: 19.5 + }, + embodied_co2e_grams_per_month: 1041.7 }, "m5a.xlarge": { architecture: "x86_64", vcpus: 4, memory_gb: 16, - power_watts: { idle: 13, max: 39 } + power_watts: { + idle: 13, + max: 39 + }, + embodied_co2e_grams_per_month: 2083.3 }, "c5.large": { architecture: "x86_64", vcpus: 2, memory_gb: 4, - power_watts: { idle: 6.5, max: 22 } + power_watts: { + idle: 6.5, + max: 22 + }, + embodied_co2e_grams_per_month: 1041.7 }, "c5.xlarge": { architecture: "x86_64", vcpus: 4, memory_gb: 8, - power_watts: { idle: 13, max: 44 } + power_watts: { + idle: 13, + max: 44 + }, + embodied_co2e_grams_per_month: 2083.3 }, "c5.2xlarge": { architecture: "x86_64", vcpus: 8, memory_gb: 16, - power_watts: { idle: 26, max: 88 } + power_watts: { + idle: 26, + max: 88 + }, + embodied_co2e_grams_per_month: 4166.7 }, "c5a.large": { architecture: "x86_64", vcpus: 2, memory_gb: 4, - power_watts: { idle: 6.2, max: 21 } + power_watts: { + idle: 6.2, + max: 21 + }, + embodied_co2e_grams_per_month: 1041.7 }, "c5a.xlarge": { architecture: "x86_64", vcpus: 4, memory_gb: 8, - power_watts: { idle: 12.4, max: 42 } + power_watts: { + idle: 12.4, + max: 42 + }, + embodied_co2e_grams_per_month: 2083.3 }, "r5.large": { architecture: "x86_64", vcpus: 2, memory_gb: 16, - power_watts: { idle: 8, max: 24 } + power_watts: { + idle: 8, + max: 24 + }, + embodied_co2e_grams_per_month: 1041.7 }, "r5.xlarge": { architecture: "x86_64", vcpus: 4, memory_gb: 32, - power_watts: { idle: 16, max: 48 } + power_watts: { + idle: 16, + max: 48 + }, + embodied_co2e_grams_per_month: 2083.3 }, "t4g.micro": { architecture: "arm64", vcpus: 2, memory_gb: 1, - power_watts: { idle: 0.9, max: 3.2 } + power_watts: { + idle: 0.9, + max: 3.2 + }, + embodied_co2e_grams_per_month: 833.3 }, "t4g.small": { architecture: "arm64", vcpus: 2, memory_gb: 2, - power_watts: { idle: 1.4, max: 4.5 } + power_watts: { + idle: 1.4, + max: 4.5 + }, + embodied_co2e_grams_per_month: 833.3 }, "t4g.medium": { architecture: "arm64", vcpus: 2, memory_gb: 4, - power_watts: { idle: 2.2, max: 6.8 } + power_watts: { + idle: 2.2, + max: 6.8 + }, + embodied_co2e_grams_per_month: 833.3 }, "t4g.large": { architecture: "arm64", vcpus: 2, memory_gb: 8, - power_watts: { idle: 4.4, max: 13.6 } + power_watts: { + idle: 4.4, + max: 13.6 + }, + embodied_co2e_grams_per_month: 833.3 }, "t4g.xlarge": { architecture: "arm64", vcpus: 4, memory_gb: 16, - power_watts: { idle: 8.8, max: 27.2 } + power_watts: { + idle: 8.8, + max: 27.2 + }, + embodied_co2e_grams_per_month: 1666.7 }, "m6g.medium": { architecture: "arm64", vcpus: 1, memory_gb: 4, - power_watts: { idle: 2.1, max: 6.6 } + power_watts: { + idle: 2.1, + max: 6.6 + }, + embodied_co2e_grams_per_month: 416.7 }, "m6g.large": { architecture: "arm64", vcpus: 2, memory_gb: 8, - power_watts: { idle: 4.1, max: 13.2 } + power_watts: { + idle: 4.1, + max: 13.2 + }, + embodied_co2e_grams_per_month: 833.3 }, "m6g.xlarge": { architecture: "arm64", vcpus: 4, memory_gb: 16, - power_watts: { idle: 8.2, max: 26.4 } + power_watts: { + idle: 8.2, + max: 26.4 + }, + embodied_co2e_grams_per_month: 1666.7 }, "m6g.2xlarge": { architecture: "arm64", vcpus: 8, memory_gb: 32, - power_watts: { idle: 16.4, max: 52.8 } + power_watts: { + idle: 16.4, + max: 52.8 + }, + embodied_co2e_grams_per_month: 3333.3 }, "m7g.medium": { architecture: "arm64", vcpus: 1, memory_gb: 4, - power_watts: { idle: 1.8, max: 5.8 } + power_watts: { + idle: 1.8, + max: 5.8 + }, + embodied_co2e_grams_per_month: 416.7 }, "m7g.large": { architecture: "arm64", vcpus: 2, memory_gb: 8, - power_watts: { idle: 3.6, max: 11.6 } + power_watts: { + idle: 3.6, + max: 11.6 + }, + embodied_co2e_grams_per_month: 833.3 }, "m7g.xlarge": { architecture: "arm64", vcpus: 4, memory_gb: 16, - power_watts: { idle: 7.2, max: 23.2 } + power_watts: { + idle: 7.2, + max: 23.2 + }, + embodied_co2e_grams_per_month: 1666.7 }, "m7g.2xlarge": { architecture: "arm64", vcpus: 8, memory_gb: 32, - power_watts: { idle: 14.4, max: 46.4 } + power_watts: { + idle: 14.4, + max: 46.4 + }, + embodied_co2e_grams_per_month: 3333.3 }, "c6g.medium": { architecture: "arm64", vcpus: 1, memory_gb: 2, - power_watts: { idle: 2, max: 7.3 } + power_watts: { + idle: 2, + max: 7.3 + }, + embodied_co2e_grams_per_month: 416.7 }, "c6g.large": { architecture: "arm64", vcpus: 2, memory_gb: 4, - power_watts: { idle: 3.9, max: 14.5 } + power_watts: { + idle: 3.9, + max: 14.5 + }, + embodied_co2e_grams_per_month: 833.3 }, "c6g.xlarge": { architecture: "arm64", vcpus: 4, memory_gb: 8, - power_watts: { idle: 7.8, max: 29 } + power_watts: { + idle: 7.8, + max: 29 + }, + embodied_co2e_grams_per_month: 1666.7 }, "c6g.2xlarge": { architecture: "arm64", vcpus: 8, memory_gb: 16, - power_watts: { idle: 15.6, max: 58 } + power_watts: { + idle: 15.6, + max: 58 + }, + embodied_co2e_grams_per_month: 3333.3 }, "c7g.large": { architecture: "arm64", vcpus: 2, memory_gb: 4, - power_watts: { idle: 3.5, max: 13 } + power_watts: { + idle: 3.5, + max: 13 + }, + embodied_co2e_grams_per_month: 833.3 }, "c7g.xlarge": { architecture: "arm64", vcpus: 4, memory_gb: 8, - power_watts: { idle: 7, max: 26 } + power_watts: { + idle: 7, + max: 26 + }, + embodied_co2e_grams_per_month: 1666.7 }, "r6g.large": { architecture: "arm64", vcpus: 2, memory_gb: 16, - power_watts: { idle: 4.8, max: 15 } + power_watts: { + idle: 4.8, + max: 15 + }, + embodied_co2e_grams_per_month: 833.3 }, "r6g.xlarge": { architecture: "arm64", vcpus: 4, memory_gb: 32, - power_watts: { idle: 9.6, max: 30 } + power_watts: { + idle: 9.6, + max: 30 + }, + embodied_co2e_grams_per_month: 1666.7 } }, pricing_usd_per_hour: { @@ -832,7 +1008,7 @@ var factors_default = { // package.json var package_default = { name: "greenops-cli", - version: "0.3.0", + version: "0.4.0", description: "Carbon footprint linting for Terraform plans. Analyses infrastructure changes for CO2e impact and cost, posts recommendations directly on GitHub PRs.", main: "dist/index.cjs", bin: { @@ -1034,13 +1210,16 @@ function resolveUtilization(input, ledger) { function linearInterpolationWatts(idle, max, utilization) { return idle + (max - idle) * utilization; } -function wattsToCarbon(watts, hours, pue, gridIntensityGco2ePerKwh) { +function wattsToScope2Carbon(watts, hours, pue, gridIntensityGco2ePerKwh) { const energyKwh = watts * pue * hours / GRAMS_PER_KWH_TO_KWH_FACTOR; return energyKwh * gridIntensityGco2ePerKwh; } +function wattsToWater(watts, hours, waterIntensityLitresPerKwh) { + const energyKwh = watts * hours / GRAMS_PER_KWH_TO_KWH_FACTOR; + return energyKwh * waterIntensityLitresPerKwh; +} var ARM_UPGRADE_MAP = { - // x86 → ARM64 upgrade targets (same vCPU/RAM class, lower power draw) - // Source: AWS EC2 instance family documentation + CCF hardware coefficients + // x86 → ARM64 upgrade targets (same vCPU/RAM class, lower power + embodied) t3: "t4g", t3a: "t4g", m5: "m6g", @@ -1077,50 +1256,44 @@ function getCleanerRegion(currentRegion, instanceType, ledger) { function calculateBaseline(input, ledger = factors_default) { const hours = input.hoursPerMonth ?? HOURS_PER_MONTH; const utilization = resolveUtilization(input, ledger); + const zeroResult = (unsupportedReason, gridIntensity = 0, embodied = 0, waterIntensity = 0) => ({ + totalCo2eGramsPerMonth: 0, + embodiedCo2eGramsPerMonth: 0, + totalLifecycleCo2eGramsPerMonth: 0, + waterLitresPerMonth: 0, + totalCostUsdPerMonth: 0, + confidence: "LOW_ASSUMED_DEFAULT", + scope: "SCOPE_2_AND_3", + unsupportedReason, + assumptionsApplied: { + utilizationApplied: utilization, + gridIntensityApplied: gridIntensity, + powerModelUsed: "LINEAR_INTERPOLATION", + embodiedCo2ePerVcpuPerMonthApplied: embodied, + waterIntensityLitresPerKwhApplied: waterIntensity + } + }); const regionData = ledger.regions[input.region]; if (!regionData) { - return { - totalCo2eGramsPerMonth: 0, - totalCostUsdPerMonth: 0, - confidence: "LOW_ASSUMED_DEFAULT", - scope: "SCOPE_2_OPERATIONAL", - unsupportedReason: `Region "${input.region}" is not present in the open methodology ledger v${ledger.metadata.ledger_version}.`, - assumptionsApplied: { - utilizationApplied: utilization, - gridIntensityApplied: 0, - powerModelUsed: "LINEAR_INTERPOLATION" - } - }; + return zeroResult(`Region "${input.region}" is not present in the Open GreenOps Methodology Ledger v${ledger.metadata.ledger_version}.`); } const instanceData = ledger.instances[input.instanceType]; if (!instanceData) { - return { - totalCo2eGramsPerMonth: 0, - totalCostUsdPerMonth: 0, - confidence: "LOW_ASSUMED_DEFAULT", - scope: "SCOPE_2_OPERATIONAL", - unsupportedReason: `Instance type "${input.instanceType}" is not present in the open methodology ledger v${ledger.metadata.ledger_version}.`, - assumptionsApplied: { - utilizationApplied: utilization, - gridIntensityApplied: regionData.grid_intensity_gco2e_per_kwh, - powerModelUsed: "LINEAR_INTERPOLATION" - } - }; + return zeroResult( + `Instance type "${input.instanceType}" is not present in the Open GreenOps Methodology Ledger v${ledger.metadata.ledger_version}.`, + regionData.grid_intensity_gco2e_per_kwh, + 0, + regionData.water_intensity_litres_per_kwh + ); } const pricePerHour = ledger.pricing_usd_per_hour[input.region]?.[input.instanceType]; if (pricePerHour === void 0) { - return { - totalCo2eGramsPerMonth: 0, - totalCostUsdPerMonth: 0, - confidence: "LOW_ASSUMED_DEFAULT", - scope: "SCOPE_2_OPERATIONAL", - unsupportedReason: `No pricing data for "${input.instanceType}" in "${input.region}" in the open methodology ledger v${ledger.metadata.ledger_version}.`, - assumptionsApplied: { - utilizationApplied: utilization, - gridIntensityApplied: regionData.grid_intensity_gco2e_per_kwh, - powerModelUsed: "LINEAR_INTERPOLATION" - } - }; + return zeroResult( + `No pricing data for "${input.instanceType}" in "${input.region}" in the Open GreenOps Methodology Ledger v${ledger.metadata.ledger_version}.`, + regionData.grid_intensity_gco2e_per_kwh, + instanceData.embodied_co2e_grams_per_month, + regionData.water_intensity_litres_per_kwh + ); } const powerModel = "LINEAR_INTERPOLATION"; const effectiveWatts = linearInterpolationWatts( @@ -1128,23 +1301,35 @@ function calculateBaseline(input, ledger = factors_default) { instanceData.power_watts.max, utilization ); - const totalCo2eGramsPerMonth = wattsToCarbon( + const totalCo2eGramsPerMonth = wattsToScope2Carbon( effectiveWatts, hours, regionData.pue, regionData.grid_intensity_gco2e_per_kwh ); + const embodiedCo2eGramsPerMonth = instanceData.embodied_co2e_grams_per_month * (hours / HOURS_PER_MONTH); + const waterLitresPerMonth = wattsToWater( + effectiveWatts, + hours, + regionData.water_intensity_litres_per_kwh + ); + const totalLifecycleCo2eGramsPerMonth = totalCo2eGramsPerMonth + embodiedCo2eGramsPerMonth; const totalCostUsdPerMonth = pricePerHour * hours; const confidence = input.avgUtilization !== void 0 ? "MEDIUM" : "HIGH"; return { totalCo2eGramsPerMonth, + embodiedCo2eGramsPerMonth, + totalLifecycleCo2eGramsPerMonth, + waterLitresPerMonth, totalCostUsdPerMonth, confidence, - scope: "SCOPE_2_OPERATIONAL", + scope: "SCOPE_2_AND_3", assumptionsApplied: { utilizationApplied: utilization, gridIntensityApplied: regionData.grid_intensity_gco2e_per_kwh, - powerModelUsed: powerModel + powerModelUsed: powerModel, + embodiedCo2ePerVcpuPerMonthApplied: instanceData.embodied_co2e_grams_per_month, + waterIntensityLitresPerKwhApplied: regionData.water_intensity_litres_per_kwh } }; } @@ -1154,29 +1339,25 @@ function generateRecommendation(input, baseline, ledger = factors_default) { const candidates = []; const armAlternative = getArmAlternative(input.instanceType, ledger); if (armAlternative) { - const armEstimate = calculateBaseline( - { ...input, instanceType: armAlternative }, - ledger - ); + const armEstimate = calculateBaseline({ ...input, instanceType: armAlternative }, ledger); if (armEstimate.confidence !== "LOW_ASSUMED_DEFAULT") { const co2Delta = armEstimate.totalCo2eGramsPerMonth - baseline.totalCo2eGramsPerMonth; const costDelta = armEstimate.totalCostUsdPerMonth - baseline.totalCostUsdPerMonth; + const embodiedDelta = armEstimate.embodiedCo2eGramsPerMonth - baseline.embodiedCo2eGramsPerMonth; if (co2Delta < 0 && costDelta < 0) { + const embodiedNote = embodiedDelta < 0 ? ` ARM64 also reduces embodied (Scope 3) carbon by ${Math.abs(Math.round(embodiedDelta))}g CO2e/month.` : ""; candidates.push({ suggestedInstanceType: armAlternative, co2eDeltaGramsPerMonth: co2Delta, costDeltaUsdPerMonth: costDelta, - rationale: `Switching from ${input.instanceType} (x86_64) to ${armAlternative} (ARM64) provides identical vCPU and memory at lower power draw, reducing carbon by ${Math.abs(Math.round(co2Delta))}g CO2e/month and cost by $${Math.abs(costDelta).toFixed(2)}/month.` + rationale: `Switching from ${input.instanceType} (x86_64) to ${armAlternative} (ARM64) provides identical vCPU and memory at lower power draw, reducing Scope 2 carbon by ${Math.abs(Math.round(co2Delta))}g CO2e/month and cost by $${Math.abs(costDelta).toFixed(2)}/month.${embodiedNote}` }); } } } const cleanerRegion = getCleanerRegion(input.region, input.instanceType, ledger); if (cleanerRegion) { - const regionEstimate = calculateBaseline( - { ...input, region: cleanerRegion }, - ledger - ); + const regionEstimate = calculateBaseline({ ...input, region: cleanerRegion }, ledger); if (regionEstimate.confidence !== "LOW_ASSUMED_DEFAULT") { const co2Delta = regionEstimate.totalCo2eGramsPerMonth - baseline.totalCo2eGramsPerMonth; const costDelta = regionEstimate.totalCostUsdPerMonth - baseline.totalCostUsdPerMonth; @@ -1184,11 +1365,13 @@ function generateRecommendation(input, baseline, ledger = factors_default) { if (co2Delta < 0 && co2ReductionPct > 0.15) { const regionName = ledger.regions[cleanerRegion]?.location ?? cleanerRegion; const costNote = costDelta > 0 ? ` (note: cost increases by $${costDelta.toFixed(2)}/month)` : ` saving $${Math.abs(costDelta).toFixed(2)}/month`; + const waterDelta = regionEstimate.waterLitresPerMonth - baseline.waterLitresPerMonth; + const waterNote = waterDelta < -0.1 ? ` Water consumption also decreases by ${Math.abs(waterDelta).toFixed(1)}L/month.` : ""; candidates.push({ suggestedRegion: cleanerRegion, co2eDeltaGramsPerMonth: co2Delta, costDeltaUsdPerMonth: costDelta, - rationale: `Moving ${input.instanceType} from ${input.region} to ${regionName} (${cleanerRegion}) reduces grid carbon intensity from ${ledger.regions[input.region]?.grid_intensity_gco2e_per_kwh}g to ${ledger.regions[cleanerRegion]?.grid_intensity_gco2e_per_kwh}g CO2e/kWh, saving ${Math.abs(Math.round(co2Delta))}g CO2e/month${costNote}.` + rationale: `Moving ${input.instanceType} from ${input.region} to ${regionName} (${cleanerRegion}) reduces Scope 2 grid carbon intensity from ${ledger.regions[input.region]?.grid_intensity_gco2e_per_kwh}g to ${ledger.regions[cleanerRegion]?.grid_intensity_gco2e_per_kwh}g CO2e/kWh, saving ${Math.abs(Math.round(co2Delta))}g CO2e/month${costNote}.${waterNote}` }); } } @@ -1212,19 +1395,21 @@ function analysePlan(resources, skipped, planFile2, ledger = factors_default, un const totals = analysedResources.reduce( (acc, { baseline, recommendation }) => { acc.currentCo2eGramsPerMonth += baseline.totalCo2eGramsPerMonth; + acc.currentEmbodiedCo2eGramsPerMonth += baseline.embodiedCo2eGramsPerMonth; + acc.currentLifecycleCo2eGramsPerMonth += baseline.totalLifecycleCo2eGramsPerMonth; + acc.currentWaterLitresPerMonth += baseline.waterLitresPerMonth; acc.currentCostUsdPerMonth += baseline.totalCostUsdPerMonth; if (recommendation) { - acc.potentialCo2eSavingGramsPerMonth += Math.abs( - recommendation.co2eDeltaGramsPerMonth - ); - acc.potentialCostSavingUsdPerMonth += Math.abs( - recommendation.costDeltaUsdPerMonth - ); + acc.potentialCo2eSavingGramsPerMonth += Math.abs(recommendation.co2eDeltaGramsPerMonth); + acc.potentialCostSavingUsdPerMonth += Math.abs(recommendation.costDeltaUsdPerMonth); } return acc; }, { currentCo2eGramsPerMonth: 0, + currentEmbodiedCo2eGramsPerMonth: 0, + currentLifecycleCo2eGramsPerMonth: 0, + currentWaterLitresPerMonth: 0, currentCostUsdPerMonth: 0, potentialCo2eSavingGramsPerMonth: 0, potentialCostSavingUsdPerMonth: 0 @@ -1588,36 +1773,59 @@ function formatGrams(grams) { } // formatters/markdown.ts +function formatWater(litres) { + if (litres >= 1e3) + return `${(litres / 1e3).toFixed(2)}m\xB3`; + return `${litres.toFixed(1)}L`; +} function formatMarkdown(result2, options = {}) { const METHODOLOGY_URL = options.repositoryUrl || "https://github.com/omrdev1/greenops-cli/blob/main/METHODOLOGY.md"; const recsCount = result2.resources.filter((r) => r.recommendation).length; let out = `## \u{1F331} GreenOps Infrastructure Impact `; - out += `> **Total Current Footprint:** ${formatGrams(result2.totals.currentCo2eGramsPerMonth)} CO2e/month | **$${result2.totals.currentCostUsdPerMonth.toFixed(2)}**/month + const scope2 = formatGrams(result2.totals.currentCo2eGramsPerMonth); + const scope3 = formatGrams(result2.totals.currentEmbodiedCo2eGramsPerMonth); + const lifecycle = formatGrams(result2.totals.currentLifecycleCo2eGramsPerMonth); + const water = formatWater(result2.totals.currentWaterLitresPerMonth); + const cost = result2.totals.currentCostUsdPerMonth.toFixed(2); + out += `> | Metric | Monthly Total | +`; + out += `> |---|---| +`; + out += `> | \u{1F50B} Scope 2 \u2014 Operational CO2e | **${scope2}** | +`; + out += `> | \u{1F3ED} Scope 3 \u2014 Embodied CO2e | **${scope3}** | +`; + out += `> | \u{1F30D} Total Lifecycle CO2e | **${lifecycle}** | +`; + out += `> | \u{1F4A7} Water Consumption | **${water}** | +`; + out += `> | \u{1F4B0} Infrastructure Cost | **$${cost}/month** | + `; if (recsCount > 0) { const pct = result2.totals.currentCo2eGramsPerMonth > 0 ? (result2.totals.potentialCo2eSavingGramsPerMonth / result2.totals.currentCo2eGramsPerMonth * 100).toFixed(1) : "0.0"; - out += `> **Potential Savings:** -${formatGrams(result2.totals.potentialCo2eSavingGramsPerMonth)} CO2e/month (${pct}%) | -$${result2.totals.potentialCostSavingUsdPerMonth.toFixed(2)}/month + out += `> **Potential Scope 2 Savings:** -${formatGrams(result2.totals.potentialCo2eSavingGramsPerMonth)} CO2e/month (${pct}%) | -$${result2.totals.potentialCostSavingUsdPerMonth.toFixed(2)}/month `; out += `> \u{1F4A1} Found **${recsCount}** optimization ${recsCount === 1 ? "recommendation" : "recommendations"}. `; } else { - out += `> \u2705 **Already optimally configured!** No upgrades recommended. + out += `> \u2705 **Already optimally configured.** No upgrades recommended. `; } out += `### Resource Breakdown `; - out += `| Resource | Type | Region | CO2e/month | Cost/month | Action | + out += `| Resource | Type | Region | Scope 2 CO2e | Scope 3 CO2e | Water | Cost/mo | Action | `; - out += `|---|---|---|---|---|---| + out += `|---|---|---|---|---|---|---|---| `; for (const r of result2.resources) { - const action = r.recommendation ? `\u{1F4A1} [View Recommendation](#recommendations)` : `\u2705 No change needed`; - out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} | + const action = r.recommendation ? `\u{1F4A1} [View Recommendation](#recommendations)` : `\u2705 Optimal`; + out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} | `; } out += ` @@ -1626,7 +1834,7 @@ function formatMarkdown(result2, options = {}) { out += `
\u26A0\uFE0F ${result2.skipped.length} Skipped Resources `; - out += `The following resources were skipped from calculation (usually due to runtime abstractions). The actual footprint may be higher. + out += `The following resources were excluded from analysis (typically due to runtime-resolved attributes). The actual footprint may be higher. `; out += `| Resource | Reason | @@ -1655,7 +1863,7 @@ function formatMarkdown(result2, options = {}) { const sugInst = r.recommendation.suggestedInstanceType || r.input.instanceType; out += `- **Suggested:** \`${sugInst}\` in \`${sugRegion}\` `; - out += `- **Impact:** ${formatDelta2(r.recommendation.co2eDeltaGramsPerMonth)} CO2e/month | ${formatCostDelta2(r.recommendation.costDeltaUsdPerMonth)}/month + out += `- **Scope 2 Impact:** ${formatDelta2(r.recommendation.co2eDeltaGramsPerMonth)} CO2e/month | ${formatCostDelta2(r.recommendation.costDeltaUsdPerMonth)}/month `; out += `- **Rationale:** ${r.recommendation.rationale} @@ -1663,19 +1871,22 @@ function formatMarkdown(result2, options = {}) { } } } - out += `--- -`; - out += `*Emissions calculated using the Open GreenOps Methodology Ledger (v${result2.ledgerVersion}). Scope 2 operational emissions only \u2014 embodied carbon and water are not tracked. Math is MIT-licensed and auditable. Analysed at ${result2.analysedAt}. [Learn more](${METHODOLOGY_URL}).* -`; if (result2.unsupportedTypes.length > 0) { const typeList = result2.unsupportedTypes.map((t) => `\`${t}\``).join(", "); - out += ` -> \u26A0\uFE0F **Coverage note:** This analysis covers \`aws_instance\` and \`aws_db_instance\` resources only. The following compute-relevant types were detected but are not yet supported: ${typeList}. Their footprint is not reflected above. + out += `> \u26A0\uFE0F **Coverage note:** The following compute-relevant types were detected but are not yet supported: ${typeList}. Their footprint is not reflected above. + `; } + out += `--- +`; + out += `*Emissions calculated using the [Open GreenOps Methodology Ledger v${result2.ledgerVersion}](${METHODOLOGY_URL}). `; + out += `Scope 2 (operational) and Scope 3 (embodied) emissions tracked. `; + out += `Water consumption estimated from AWS 2023 WUE data. `; + out += `Math is MIT-licensed and auditable. Analysed at ${result2.analysedAt}.* +`; if (options.showUpgradePrompt) { out += ` -> \u{1F3E2} **Managing green-ops across dozens of repositories?** [Upgrade to GreenOps Dashboard](https://greenops-cli.dev/upgrade) to aggregate CI/CD carbon data natively. +> \u{1F3E2} **GreenOps Dashboard** \u2014 aggregate carbon data across all your repositories, set team budgets, and export ESG reports. [Join the waitlist](https://greenops-cli.dev) \xB7 Coming soon. `; } return out; @@ -1688,6 +1899,11 @@ function truncate(str, len) { return visible.substring(0, len - 3) + "..."; return visible + " ".repeat(len - visible.length); } +function formatWater2(litres) { + if (litres >= 1e3) + return `${(litres / 1e3).toFixed(1)}m\xB3`; + return `${litres.toFixed(1)}L`; +} function formatTable(result2) { let out = ` \x1B[1m\u{1F331} GreenOps Infrastructure Impact\x1B[0m @@ -1697,29 +1913,33 @@ function formatTable(result2) { return out + `No compatible infrastructure detected. `; } - out += `\u250C${"\u2500".repeat(40)}\u252C${"\u2500".repeat(15)}\u252C${"\u2500".repeat(15)}\u252C${"\u2500".repeat(15)}\u252C${"\u2500".repeat(15)}\u2510 + out += `\u250C${"\u2500".repeat(38)}\u252C${"\u2500".repeat(13)}\u252C${"\u2500".repeat(13)}\u252C${"\u2500".repeat(11)}\u252C${"\u2500".repeat(11)}\u252C${"\u2500".repeat(9)}\u252C${"\u2500".repeat(13)}\u2510 `; - out += `\u2502 ${truncate("Resource", 38)} \u2502 ${truncate("Instance", 13)} \u2502 ${truncate("Region", 13)} \u2502 ${truncate("CO2e/mo", 13)} \u2502 ${truncate("Action", 13)} \u2502 + out += `\u2502 ${truncate("Resource", 36)} \u2502 ${truncate("Instance", 11)} \u2502 ${truncate("Region", 11)} \u2502 ${truncate("Scope 2", 9)} \u2502 ${truncate("Scope 3", 9)} \u2502 ${truncate("Water", 7)} \u2502 ${truncate("Action", 11)} \u2502 `; - out += `\u251C${"\u2500".repeat(40)}\u253C${"\u2500".repeat(15)}\u253C${"\u2500".repeat(15)}\u253C${"\u2500".repeat(15)}\u253C${"\u2500".repeat(15)}\u2524 + out += `\u251C${"\u2500".repeat(38)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(13)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(11)}\u253C${"\u2500".repeat(9)}\u253C${"\u2500".repeat(13)}\u2524 `; for (const r of result2.resources) { - const c = formatGrams(r.baseline.totalCo2eGramsPerMonth); + const scope2 = formatGrams(r.baseline.totalCo2eGramsPerMonth); + const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth); + const water = formatWater2(r.baseline.waterLitresPerMonth); const action = r.recommendation ? `\x1B[33mUPGRADE\x1B[0m` : `\x1B[32mOK\x1B[0m`; - out += `\u2502 ${truncate(r.input.resourceId, 38)} \u2502 ${truncate(r.input.instanceType, 13)} \u2502 ${truncate(r.input.region, 13)} \u2502 ${truncate(c, 13)} \u2502 ${truncate(action, 13)} \u2502 + out += `\u2502 ${truncate(r.input.resourceId, 36)} \u2502 ${truncate(r.input.instanceType, 11)} \u2502 ${truncate(r.input.region, 11)} \u2502 ${truncate(scope2, 9)} \u2502 ${truncate(scope3, 9)} \u2502 ${truncate(water, 7)} \u2502 ${truncate(action, 11)} \u2502 `; } for (const s of result2.skipped) { - out += `\u2502 \x1B[90m${truncate(s.resourceId, 38)}\x1B[0m \u2502 \x1B[90m${truncate("---", 13)}\x1B[0m \u2502 \x1B[90m${truncate("---", 13)}\x1B[0m \u2502 \x1B[90m${truncate("---", 13)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 SKIPPED", 13)}\x1B[0m \u2502 + out += `\u2502 \x1B[90m${truncate(s.resourceId, 36)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 11)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 9)}\x1B[0m \u2502 \x1B[90m${truncate("---", 7)}\x1B[0m \u2502 \x1B[33m${truncate("\u26A0 SKIPPED", 11)}\x1B[0m \u2502 `; } - out += `\u2514${"\u2500".repeat(40)}\u2534${"\u2500".repeat(15)}\u2534${"\u2500".repeat(15)}\u2534${"\u2500".repeat(15)}\u2534${"\u2500".repeat(15)}\u2518 + out += `\u2514${"\u2500".repeat(38)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(13)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(11)}\u2534${"\u2500".repeat(9)}\u2534${"\u2500".repeat(13)}\u2518 `; - out += `Current: ${formatGrams(result2.totals.currentCo2eGramsPerMonth)} | $${result2.totals.currentCostUsdPerMonth.toFixed(2)} + out += `Scope 2: ${formatGrams(result2.totals.currentCo2eGramsPerMonth)} | Scope 3: ${formatGrams(result2.totals.currentEmbodiedCo2eGramsPerMonth)} | Lifecycle: ${formatGrams(result2.totals.currentLifecycleCo2eGramsPerMonth)} +`; + out += `Water: ${formatWater2(result2.totals.currentWaterLitresPerMonth)} | Cost: $${result2.totals.currentCostUsdPerMonth.toFixed(2)}/month `; if (result2.totals.potentialCo2eSavingGramsPerMonth > 0) { - out += `\x1B[32mSavings: ${formatDelta2(-result2.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta2(-result2.totals.potentialCostSavingUsdPerMonth)}\x1B[0m + out += `\x1B[32mScope 2 Savings: ${formatDelta2(-result2.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta2(-result2.totals.potentialCostSavingUsdPerMonth)}\x1B[0m `; } if (result2.skipped.length > 0) { diff --git a/engine.test.ts b/engine.test.ts index 9a29ee5..83336e7 100644 --- a/engine.test.ts +++ b/engine.test.ts @@ -167,10 +167,10 @@ describe('calculateBaseline', () => { assert.ok(max.totalCo2eGramsPerMonth > idle.totalCo2eGramsPerMonth, 'Max utilization should produce more carbon'); }); - it('returns scope SCOPE_2_OPERATIONAL on all estimates', () => { + it('returns scope SCOPE_2_AND_3 on all estimates', () => { const result = calculateBaseline({ resourceId: 'test', region: 'us-east-1', instanceType: 'm5.large', }); - assert.equal(result.scope, 'SCOPE_2_OPERATIONAL'); + assert.equal(result.scope, 'SCOPE_2_AND_3'); }); }); diff --git a/engine.ts b/engine.ts index 10e2996..cfb76e6 100644 --- a/engine.ts +++ b/engine.ts @@ -9,7 +9,7 @@ import type { } from './types'; // --------------------------------------------------------------------------- -// Internal types for ledger shape (mirrors factors.json structure) +// Internal types for ledger shape (mirrors factors.json v1.3.0) // --------------------------------------------------------------------------- interface LedgerInstance { @@ -17,12 +17,19 @@ interface LedgerInstance { vcpus: number; memory_gb: number; power_watts: { idle: number; max: number }; + /** Prorated Scope 3 embodied carbon from manufacturing lifecycle (gCO2e/month). + * Source: CCF DELL R740 baseline (1,200 kgCO2e/server, 4yr lifespan, 48 vCPUs). + * ARM (Graviton) applies 20% discount reflecting smaller die + lower TDP. */ + embodied_co2e_grams_per_month: number; } interface LedgerRegion { location: string; grid_intensity_gco2e_per_kwh: number; pue: number; + /** AWS WUE (Water Usage Effectiveness) in litres per kWh of IT load. + * Source: AWS 2023 Sustainability Report. */ + water_intensity_litres_per_kwh: number; } interface Ledger { @@ -41,17 +48,13 @@ interface Ledger { // Constants // --------------------------------------------------------------------------- -const HOURS_PER_MONTH = 730; // 365 days * 24h / 12 months -const GRAMS_PER_KWH_TO_KWH_FACTOR = 1000; // grid intensity is in gCO2e/kWh +const HOURS_PER_MONTH = 730; +const GRAMS_PER_KWH_TO_KWH_FACTOR = 1000; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/** - * Resolves the effective utilization for a resource. - * Precedence: explicit input → ledger metadata default. - */ function resolveUtilization(input: ResourceInput, ledger: Ledger): number { if (input.avgUtilization !== undefined && (input.avgUtilization < 0 || input.avgUtilization > 1)) { throw new RangeError(`avgUtilization must be between 0 and 1, got ${input.avgUtilization}`); @@ -63,33 +66,22 @@ function resolveUtilization(input: ResourceInput, ledger: Ledger): number { } /** - * Linear interpolation power model: - * watts = idle + (max - idle) * utilization - * - * This is the standard CCF model for general-purpose compute. - * It assumes power scales linearly between idle and max TDP - * as CPU utilization increases from 0% to 100%. + * Linear interpolation power model (standard CCF methodology): + * W = W_idle + (W_max - W_idle) × utilization * - * NOTE (CPU-only): This model uses only CPU TDP bounds. Memory power draw - * is a known omission — GreenPixie and some CCF extensions include a separate - * memory power component. Our factors.json stores memory_gb per instance for - * future expansion, but it is NOT used in the current calculation. + * CPU-only. Memory power draw tracked via memory_gb in factors.json + * but not yet included in the calculation (reserved for v1.4.0). */ -function linearInterpolationWatts( - idle: number, - max: number, - utilization: number -): number { +function linearInterpolationWatts(idle: number, max: number, utilization: number): number { return idle + (max - idle) * utilization; } /** - * Converts watt-hours of data center energy to grams of CO2e. - * - * energy_kwh = watts * hours / 1000 - * carbon_g = energy_kwh * grid_intensity_gco2e_per_kwh + * Converts effective CPU watts to monthly Scope 2 CO2e grams. + * energy_kwh = watts × pue × hours / 1000 + * carbon_g = energy_kwh × grid_intensity_gco2e_per_kwh */ -function wattsToCarbon( +function wattsToScope2Carbon( watts: number, hours: number, pue: number, @@ -99,15 +91,21 @@ function wattsToCarbon( return energyKwh * gridIntensityGco2ePerKwh; } +/** + * Calculates monthly water consumption from operational energy. + * energy_kwh (IT load, before PUE) × WUE litres/kWh + */ +function wattsToWater(watts: number, hours: number, waterIntensityLitresPerKwh: number): number { + const energyKwh = (watts * hours) / GRAMS_PER_KWH_TO_KWH_FACTOR; + return energyKwh * waterIntensityLitresPerKwh; +} + // --------------------------------------------------------------------------- // ARM recommendation map -// Maps x86 families → their ARM equivalents (same vCPU/RAM class). -// Extend this as new instance families are added to the ledger. // --------------------------------------------------------------------------- const ARM_UPGRADE_MAP: Record = { - // x86 → ARM64 upgrade targets (same vCPU/RAM class, lower power draw) - // Source: AWS EC2 instance family documentation + CCF hardware coefficients + // x86 → ARM64 upgrade targets (same vCPU/RAM class, lower power + embodied) t3: 't4g', t3a: 't4g', m5: 'm6g', @@ -118,39 +116,19 @@ const ARM_UPGRADE_MAP: Record = { r5a: 'r6g', }; -/** - * Given an instance type like "m5.large", returns the ARM equivalent - * "m6g.large" if a mapping exists and the target is supported in the ledger. - * Returns null if no upgrade is available. - */ -function getArmAlternative( - instanceType: string, - ledger: Ledger -): string | null { +function getArmAlternative(instanceType: string, ledger: Ledger): string | null { const [family, size] = instanceType.split('.'); if (!family || !size) return null; - const armFamily = ARM_UPGRADE_MAP[family]; if (!armFamily) return null; - const candidate = `${armFamily}.${size}`; return ledger.instances[candidate] ? candidate : null; } -/** - * Finds the cleanest (lowest grid intensity) supported region - * that is NOT the current region, to use as a region-shift recommendation. - */ -function getCleanerRegion( - currentRegion: string, - instanceType: string, - ledger: Ledger -): string | null { +function getCleanerRegion(currentRegion: string, instanceType: string, ledger: Ledger): string | null { const regions = Object.entries(ledger.regions) .filter(([regionId]) => { - // Must be a different region if (regionId === currentRegion) return false; - // Must have pricing data for this instance type return !!ledger.pricing_usd_per_hour[regionId]?.[instanceType]; }) .sort(([, a], [, b]) => a.grid_intensity_gco2e_per_kwh - b.grid_intensity_gco2e_per_kwh); @@ -158,28 +136,24 @@ function getCleanerRegion( if (regions.length === 0) return null; const [cleanestRegionId, cleanestRegion] = regions[0]; - // If current region is unknown, treat intensity as Infinity so no region can appear "cleaner" — this - // prevents recommendations when we can't establish a valid baseline for comparison. const currentIntensity = ledger.regions[currentRegion]?.grid_intensity_gco2e_per_kwh ?? Infinity; - - // Only recommend if the cleaner region is meaningfully better (>10% reduction) if (cleanestRegion.grid_intensity_gco2e_per_kwh >= currentIntensity * 0.9) return null; - return cleanestRegionId; } // --------------------------------------------------------------------------- -// Core Engine Functions +// Core Engine // --------------------------------------------------------------------------- /** - * Calculates the baseline emissions and cost for a single resource. + * Calculates the full environmental and cost baseline for a single resource. * - * Returns a structured estimate with full transparency on every assumption - * applied — suitable for inclusion in an audit ledger export. + * Returns three emission dimensions: + * - Scope 2 operational (CPU power × grid carbon intensity × PUE) + * - Scope 3 embodied (prorated hardware manufacturing lifecycle) + * - Water consumption (operational energy × regional AWS WUE) * - * If the region or instance type is not present in the ledger, returns an - * estimate with confidence "LOW_ASSUMED_DEFAULT" and an unsupportedReason. + * Every assumption applied is recorded in assumptionsApplied for audit transparency. */ export function calculateBaseline( input: ResourceInput, @@ -188,59 +162,47 @@ export function calculateBaseline( const hours = input.hoursPerMonth ?? HOURS_PER_MONTH; const utilization = resolveUtilization(input, ledger); - // --- Validate region --- + const zeroResult = (unsupportedReason: string, gridIntensity = 0, embodied = 0, waterIntensity = 0): EmissionAndCostEstimate => ({ + totalCo2eGramsPerMonth: 0, + embodiedCo2eGramsPerMonth: 0, + totalLifecycleCo2eGramsPerMonth: 0, + waterLitresPerMonth: 0, + totalCostUsdPerMonth: 0, + confidence: 'LOW_ASSUMED_DEFAULT', + scope: 'SCOPE_2_AND_3', + unsupportedReason, + assumptionsApplied: { + utilizationApplied: utilization, + gridIntensityApplied: gridIntensity, + powerModelUsed: 'LINEAR_INTERPOLATION', + embodiedCo2ePerVcpuPerMonthApplied: embodied, + waterIntensityLitresPerKwhApplied: waterIntensity, + }, + }); + const regionData = ledger.regions[input.region]; if (!regionData) { - return { - totalCo2eGramsPerMonth: 0, - totalCostUsdPerMonth: 0, - confidence: 'LOW_ASSUMED_DEFAULT', - scope: 'SCOPE_2_OPERATIONAL', - unsupportedReason: `Region "${input.region}" is not present in the open methodology ledger v${ledger.metadata.ledger_version}.`, - assumptionsApplied: { - utilizationApplied: utilization, - gridIntensityApplied: 0, - powerModelUsed: 'LINEAR_INTERPOLATION', - }, - }; + return zeroResult(`Region "${input.region}" is not present in the Open GreenOps Methodology Ledger v${ledger.metadata.ledger_version}.`); } - // --- Validate instance type --- const instanceData = ledger.instances[input.instanceType]; if (!instanceData) { - return { - totalCo2eGramsPerMonth: 0, - totalCostUsdPerMonth: 0, - confidence: 'LOW_ASSUMED_DEFAULT', - scope: 'SCOPE_2_OPERATIONAL', - unsupportedReason: `Instance type "${input.instanceType}" is not present in the open methodology ledger v${ledger.metadata.ledger_version}.`, - assumptionsApplied: { - utilizationApplied: utilization, - gridIntensityApplied: regionData.grid_intensity_gco2e_per_kwh, - powerModelUsed: 'LINEAR_INTERPOLATION', - }, - }; + return zeroResult( + `Instance type "${input.instanceType}" is not present in the Open GreenOps Methodology Ledger v${ledger.metadata.ledger_version}.`, + regionData.grid_intensity_gco2e_per_kwh, 0, regionData.water_intensity_litres_per_kwh + ); } - // --- Validate pricing --- const pricePerHour = ledger.pricing_usd_per_hour[input.region]?.[input.instanceType]; if (pricePerHour === undefined) { - return { - totalCo2eGramsPerMonth: 0, - totalCostUsdPerMonth: 0, - confidence: 'LOW_ASSUMED_DEFAULT', - scope: 'SCOPE_2_OPERATIONAL', - unsupportedReason: `No pricing data for "${input.instanceType}" in "${input.region}" in the open methodology ledger v${ledger.metadata.ledger_version}.`, - assumptionsApplied: { - utilizationApplied: utilization, - gridIntensityApplied: regionData.grid_intensity_gco2e_per_kwh, - powerModelUsed: 'LINEAR_INTERPOLATION', - }, - }; + return zeroResult( + `No pricing data for "${input.instanceType}" in "${input.region}" in the Open GreenOps Methodology Ledger v${ledger.metadata.ledger_version}.`, + regionData.grid_intensity_gco2e_per_kwh, + instanceData.embodied_co2e_grams_per_month, + regionData.water_intensity_litres_per_kwh + ); } - // --- Power model: LINEAR_INTERPOLATION --- - // watts = idle + (max - idle) * utilization const powerModel: PowerModel = 'LINEAR_INTERPOLATION'; const effectiveWatts = linearInterpolationWatts( instanceData.power_watts.idle, @@ -248,115 +210,102 @@ export function calculateBaseline( utilization ); - // --- Carbon calculation --- - // Applies PUE to account for data center overhead (cooling, networking, etc.) - const totalCo2eGramsPerMonth = wattsToCarbon( - effectiveWatts, - hours, - regionData.pue, - regionData.grid_intensity_gco2e_per_kwh + // Scope 2: operational emissions + const totalCo2eGramsPerMonth = wattsToScope2Carbon( + effectiveWatts, hours, regionData.pue, regionData.grid_intensity_gco2e_per_kwh ); - // --- Cost calculation --- - const totalCostUsdPerMonth = pricePerHour * hours; + // Scope 3: embodied emissions — prorated by hours if partial month + const embodiedCo2eGramsPerMonth = + instanceData.embodied_co2e_grams_per_month * (hours / HOURS_PER_MONTH); - // --- Confidence --- - // HIGH: all values sourced from ledger, default utilization applied. - // MEDIUM: caller supplied explicit avgUtilization. The estimate is still - // mathematically valid but depends on the accuracy of the supplied - // value. SaaS consumers should surface this to the end user. - // LOW_ASSUMED_DEFAULT: unsupported region/instance/pricing — estimate is zero and - // unreliable. See unsupportedReason for details. - const confidence: ConfidenceLevel = - input.avgUtilization !== undefined ? 'MEDIUM' : 'HIGH'; + // Water consumption + const waterLitresPerMonth = wattsToWater( + effectiveWatts, hours, regionData.water_intensity_litres_per_kwh + ); + + const totalLifecycleCo2eGramsPerMonth = totalCo2eGramsPerMonth + embodiedCo2eGramsPerMonth; + const totalCostUsdPerMonth = pricePerHour * hours; + const confidence: ConfidenceLevel = input.avgUtilization !== undefined ? 'MEDIUM' : 'HIGH'; return { totalCo2eGramsPerMonth, + embodiedCo2eGramsPerMonth, + totalLifecycleCo2eGramsPerMonth, + waterLitresPerMonth, totalCostUsdPerMonth, confidence, - scope: 'SCOPE_2_OPERATIONAL', + scope: 'SCOPE_2_AND_3', assumptionsApplied: { utilizationApplied: utilization, gridIntensityApplied: regionData.grid_intensity_gco2e_per_kwh, powerModelUsed: powerModel, + embodiedCo2ePerVcpuPerMonthApplied: instanceData.embodied_co2e_grams_per_month, + waterIntensityLitresPerKwhApplied: regionData.water_intensity_litres_per_kwh, }, }; } /** - * Analyses a baseline estimate and generates the single best recommendation - * for reducing carbon and cost. + * Generates the single best recommendation for reducing environmental impact. * - * Strategy (in priority order): - * 1. ARM upgrade: Same region, switch x86 → ARM (same vCPU/RAM class) - * 2. Region shift: Same instance, move to lowest grid-intensity region - * 3. ARM + region shift: Combined — tried only if individual improvements - * are below a minimum threshold (reserved for v1) - * - * Returns null if no improvement can be found in the current ledger. + * Scoring: 60% weight on CO2 reduction, 40% on cost reduction. + * Both dimensions normalised to percentage-of-baseline. + * ARM upgrades surface embodied carbon benefit in the rationale. */ export function generateRecommendation( input: ResourceInput, baseline: EmissionAndCostEstimate, ledger: Ledger = factorsData as Ledger ): UpgradeRecommendation | null { - // Cannot recommend for unsupported resources if (baseline.confidence === 'LOW_ASSUMED_DEFAULT') return null; const candidates: UpgradeRecommendation[] = []; - // --- Strategy 1: ARM upgrade --- + // Strategy 1: ARM upgrade const armAlternative = getArmAlternative(input.instanceType, ledger); if (armAlternative) { - const armEstimate = calculateBaseline( - { ...input, instanceType: armAlternative }, - ledger - ); + const armEstimate = calculateBaseline({ ...input, instanceType: armAlternative }, ledger); if (armEstimate.confidence !== 'LOW_ASSUMED_DEFAULT') { const co2Delta = armEstimate.totalCo2eGramsPerMonth - baseline.totalCo2eGramsPerMonth; const costDelta = armEstimate.totalCostUsdPerMonth - baseline.totalCostUsdPerMonth; - - // Only include if it actually reduces both carbon AND cost + const embodiedDelta = armEstimate.embodiedCo2eGramsPerMonth - baseline.embodiedCo2eGramsPerMonth; if (co2Delta < 0 && costDelta < 0) { + const embodiedNote = embodiedDelta < 0 + ? ` ARM64 also reduces embodied (Scope 3) carbon by ${Math.abs(Math.round(embodiedDelta))}g CO2e/month.` + : ''; candidates.push({ suggestedInstanceType: armAlternative, co2eDeltaGramsPerMonth: co2Delta, costDeltaUsdPerMonth: costDelta, - rationale: `Switching from ${input.instanceType} (x86_64) to ${armAlternative} (ARM64) provides identical vCPU and memory at lower power draw, reducing carbon by ${Math.abs(Math.round(co2Delta))}g CO2e/month and cost by $${Math.abs(costDelta).toFixed(2)}/month.`, + rationale: `Switching from ${input.instanceType} (x86_64) to ${armAlternative} (ARM64) provides identical vCPU and memory at lower power draw, reducing Scope 2 carbon by ${Math.abs(Math.round(co2Delta))}g CO2e/month and cost by $${Math.abs(costDelta).toFixed(2)}/month.${embodiedNote}`, }); } } } - // --- Strategy 2: Region shift --- + // Strategy 2: Region shift const cleanerRegion = getCleanerRegion(input.region, input.instanceType, ledger); if (cleanerRegion) { - const regionEstimate = calculateBaseline( - { ...input, region: cleanerRegion }, - ledger - ); + const regionEstimate = calculateBaseline({ ...input, region: cleanerRegion }, ledger); if (regionEstimate.confidence !== 'LOW_ASSUMED_DEFAULT') { const co2Delta = regionEstimate.totalCo2eGramsPerMonth - baseline.totalCo2eGramsPerMonth; const costDelta = regionEstimate.totalCostUsdPerMonth - baseline.totalCostUsdPerMonth; - - // Region shifts may increase cost — include if carbon reduction is significant (>15%) - // Guard against division by zero: if baseline carbon is 0, no meaningful reduction to compare. const co2ReductionPct = baseline.totalCo2eGramsPerMonth > 0 - ? Math.abs(co2Delta) / baseline.totalCo2eGramsPerMonth - : 0; - + ? Math.abs(co2Delta) / baseline.totalCo2eGramsPerMonth : 0; if (co2Delta < 0 && co2ReductionPct > 0.15) { const regionName = ledger.regions[cleanerRegion]?.location ?? cleanerRegion; - const costNote = - costDelta > 0 - ? ` (note: cost increases by $${costDelta.toFixed(2)}/month)` - : ` saving $${Math.abs(costDelta).toFixed(2)}/month`; - + const costNote = costDelta > 0 + ? ` (note: cost increases by $${costDelta.toFixed(2)}/month)` + : ` saving $${Math.abs(costDelta).toFixed(2)}/month`; + const waterDelta = regionEstimate.waterLitresPerMonth - baseline.waterLitresPerMonth; + const waterNote = waterDelta < -0.1 + ? ` Water consumption also decreases by ${Math.abs(waterDelta).toFixed(1)}L/month.` : ''; candidates.push({ suggestedRegion: cleanerRegion, co2eDeltaGramsPerMonth: co2Delta, costDeltaUsdPerMonth: costDelta, - rationale: `Moving ${input.instanceType} from ${input.region} to ${regionName} (${cleanerRegion}) reduces grid carbon intensity from ${ledger.regions[input.region]?.grid_intensity_gco2e_per_kwh}g to ${ledger.regions[cleanerRegion]?.grid_intensity_gco2e_per_kwh}g CO2e/kWh, saving ${Math.abs(Math.round(co2Delta))}g CO2e/month${costNote}.`, + rationale: `Moving ${input.instanceType} from ${input.region} to ${regionName} (${cleanerRegion}) reduces Scope 2 grid carbon intensity from ${ledger.regions[input.region]?.grid_intensity_gco2e_per_kwh}g to ${ledger.regions[cleanerRegion]?.grid_intensity_gco2e_per_kwh}g CO2e/kWh, saving ${Math.abs(Math.round(co2Delta))}g CO2e/month${costNote}.${waterNote}`, }); } } @@ -364,19 +313,13 @@ export function generateRecommendation( if (candidates.length === 0) return null; - // Return the recommendation with the greatest combined carbon + cost reduction. - // We weight carbon reduction at 60% and cost at 40% to reflect the tool's primary mission. - // Both dimensions are normalized to percentage-of-baseline so the weighting is accurate. const scored = candidates.map((rec) => { const co2Pct = baseline.totalCo2eGramsPerMonth > 0 - ? Math.abs(rec.co2eDeltaGramsPerMonth) / baseline.totalCo2eGramsPerMonth - : 0; + ? Math.abs(rec.co2eDeltaGramsPerMonth) / baseline.totalCo2eGramsPerMonth : 0; const costPct = baseline.totalCostUsdPerMonth > 0 - ? Math.abs(rec.costDeltaUsdPerMonth) / baseline.totalCostUsdPerMonth - : 0; + ? Math.abs(rec.costDeltaUsdPerMonth) / baseline.totalCostUsdPerMonth : 0; return { rec, score: co2Pct * 0.6 + costPct * 0.4 }; }); - scored.sort((a, b) => b.score - a.score); return scored[0].rec; } @@ -385,11 +328,6 @@ export function generateRecommendation( // Plan-level aggregator // --------------------------------------------------------------------------- -/** - * Runs calculateBaseline + generateRecommendation for every resource in a - * parsed plan and assembles the full PlanAnalysisResult, including pre-computed - * totals for the PR comment headline. - */ export function analysePlan( resources: ResourceInput[], skipped: PlanAnalysisResult['skipped'], @@ -406,20 +344,21 @@ export function analysePlan( const totals = analysedResources.reduce( (acc, { baseline, recommendation }) => { acc.currentCo2eGramsPerMonth += baseline.totalCo2eGramsPerMonth; + acc.currentEmbodiedCo2eGramsPerMonth += baseline.embodiedCo2eGramsPerMonth; + acc.currentLifecycleCo2eGramsPerMonth += baseline.totalLifecycleCo2eGramsPerMonth; + acc.currentWaterLitresPerMonth += baseline.waterLitresPerMonth; acc.currentCostUsdPerMonth += baseline.totalCostUsdPerMonth; if (recommendation) { - // Deltas are negative for improvements, so we negate for "saving" fields - acc.potentialCo2eSavingGramsPerMonth += Math.abs( - recommendation.co2eDeltaGramsPerMonth - ); - acc.potentialCostSavingUsdPerMonth += Math.abs( - recommendation.costDeltaUsdPerMonth - ); + acc.potentialCo2eSavingGramsPerMonth += Math.abs(recommendation.co2eDeltaGramsPerMonth); + acc.potentialCostSavingUsdPerMonth += Math.abs(recommendation.costDeltaUsdPerMonth); } return acc; }, { currentCo2eGramsPerMonth: 0, + currentEmbodiedCo2eGramsPerMonth: 0, + currentLifecycleCo2eGramsPerMonth: 0, + currentWaterLitresPerMonth: 0, currentCostUsdPerMonth: 0, potentialCo2eSavingGramsPerMonth: 0, potentialCostSavingUsdPerMonth: 0, diff --git a/factors.json b/factors.json index d64ddae..ec5b8d4 100644 --- a/factors.json +++ b/factors.json @@ -1,15 +1,17 @@ { "metadata": { - "ledger_version": "1.2.0", + "ledger_version": "1.3.0", "updated_at": "2026-03-27T00:00:00Z", "sources": { "grid": "electricity-maps-2024-avg", "hardware": "cloud-carbon-footprint-v3", - "pricing": "aws-public-pricing-api-2026-q1" + "pricing": "aws-public-pricing-api-2026-q1", + "embodied": "cloud-carbon-footprint-v3-dell-r740-baseline", + "water": "aws-sustainability-report-2023-wue" }, "assumptions": { "default_utilization": { - "value": 0.50, + "value": 0.5, "citation": "Cloud Carbon Footprint (CCF) standard assumed average utilization for general-purpose compute where no telemetry is available.", "url": "https://www.cloudcarbonfootprint.org/docs/methodology/#utilization" } @@ -19,72 +21,86 @@ "us-east-1": { "location": "US East (N. Virginia)", "grid_intensity_gco2e_per_kwh": 384.5, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.46 }, "us-east-2": { "location": "US East (Ohio)", "grid_intensity_gco2e_per_kwh": 410.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.52 }, "us-west-1": { "location": "US West (N. California)", "grid_intensity_gco2e_per_kwh": 220.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.38 }, "us-west-2": { "location": "US West (Oregon)", "grid_intensity_gco2e_per_kwh": 240.1, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.18 }, "eu-west-1": { "location": "Europe (Ireland)", "grid_intensity_gco2e_per_kwh": 334.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.22 }, "eu-west-2": { "location": "Europe (London)", "grid_intensity_gco2e_per_kwh": 268.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.25 }, "eu-central-1": { "location": "Europe (Frankfurt)", "grid_intensity_gco2e_per_kwh": 420.5, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.28 }, "eu-north-1": { "location": "Europe (Stockholm)", "grid_intensity_gco2e_per_kwh": 8.8, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.1 }, "ap-southeast-1": { "location": "Asia Pacific (Singapore)", "grid_intensity_gco2e_per_kwh": 408.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.58 }, "ap-southeast-2": { "location": "Asia Pacific (Sydney)", "grid_intensity_gco2e_per_kwh": 650.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.45 }, "ap-northeast-1": { "location": "Asia Pacific (Tokyo)", "grid_intensity_gco2e_per_kwh": 506.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.5 }, "ap-south-1": { "location": "Asia Pacific (Mumbai)", "grid_intensity_gco2e_per_kwh": 723.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.72 }, "ca-central-1": { "location": "Canada (Central)", "grid_intensity_gco2e_per_kwh": 130.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.2 }, "sa-east-1": { - "location": "South America (São Paulo)", + "location": "South America (S\u00e3o Paulo)", "grid_intensity_gco2e_per_kwh": 74.0, - "pue": 1.13 + "pue": 1.13, + "water_intensity_litres_per_kwh": 0.35 } }, "instances": { @@ -92,421 +108,892 @@ "architecture": "x86_64", "vcpus": 2, "memory_gb": 1, - "power_watts": { "idle": 1.4, "max": 5.0 } + "power_watts": { + "idle": 1.4, + "max": 5.0 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "t3.small": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 2, - "power_watts": { "idle": 2.0, "max": 7.0 } + "power_watts": { + "idle": 2.0, + "max": 7.0 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "t3.medium": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 4, - "power_watts": { "idle": 3.4, "max": 10.2 } + "power_watts": { + "idle": 3.4, + "max": 10.2 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "t3.large": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 8, - "power_watts": { "idle": 6.8, "max": 20.4 } + "power_watts": { + "idle": 6.8, + "max": 20.4 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "t3.xlarge": { "architecture": "x86_64", "vcpus": 4, "memory_gb": 16, - "power_watts": { "idle": 13.6, "max": 40.8 } + "power_watts": { + "idle": 13.6, + "max": 40.8 + }, + "embodied_co2e_grams_per_month": 2083.3 }, "t3a.medium": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 4, - "power_watts": { "idle": 3.2, "max": 9.8 } + "power_watts": { + "idle": 3.2, + "max": 9.8 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "t3a.large": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 8, - "power_watts": { "idle": 6.4, "max": 19.6 } + "power_watts": { + "idle": 6.4, + "max": 19.6 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "m5.large": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 8, - "power_watts": { "idle": 6.8, "max": 20.4 } + "power_watts": { + "idle": 6.8, + "max": 20.4 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "m5.xlarge": { "architecture": "x86_64", "vcpus": 4, "memory_gb": 16, - "power_watts": { "idle": 13.6, "max": 40.8 } + "power_watts": { + "idle": 13.6, + "max": 40.8 + }, + "embodied_co2e_grams_per_month": 2083.3 }, "m5.2xlarge": { "architecture": "x86_64", "vcpus": 8, "memory_gb": 32, - "power_watts": { "idle": 27.2, "max": 81.6 } + "power_watts": { + "idle": 27.2, + "max": 81.6 + }, + "embodied_co2e_grams_per_month": 4166.7 }, "m5a.large": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 8, - "power_watts": { "idle": 6.5, "max": 19.5 } + "power_watts": { + "idle": 6.5, + "max": 19.5 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "m5a.xlarge": { "architecture": "x86_64", "vcpus": 4, "memory_gb": 16, - "power_watts": { "idle": 13.0, "max": 39.0 } + "power_watts": { + "idle": 13.0, + "max": 39.0 + }, + "embodied_co2e_grams_per_month": 2083.3 }, "c5.large": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 4, - "power_watts": { "idle": 6.5, "max": 22.0 } + "power_watts": { + "idle": 6.5, + "max": 22.0 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "c5.xlarge": { "architecture": "x86_64", "vcpus": 4, "memory_gb": 8, - "power_watts": { "idle": 13.0, "max": 44.0 } + "power_watts": { + "idle": 13.0, + "max": 44.0 + }, + "embodied_co2e_grams_per_month": 2083.3 }, "c5.2xlarge": { "architecture": "x86_64", "vcpus": 8, "memory_gb": 16, - "power_watts": { "idle": 26.0, "max": 88.0 } + "power_watts": { + "idle": 26.0, + "max": 88.0 + }, + "embodied_co2e_grams_per_month": 4166.7 }, "c5a.large": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 4, - "power_watts": { "idle": 6.2, "max": 21.0 } + "power_watts": { + "idle": 6.2, + "max": 21.0 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "c5a.xlarge": { "architecture": "x86_64", "vcpus": 4, "memory_gb": 8, - "power_watts": { "idle": 12.4, "max": 42.0 } + "power_watts": { + "idle": 12.4, + "max": 42.0 + }, + "embodied_co2e_grams_per_month": 2083.3 }, "r5.large": { "architecture": "x86_64", "vcpus": 2, "memory_gb": 16, - "power_watts": { "idle": 8.0, "max": 24.0 } + "power_watts": { + "idle": 8.0, + "max": 24.0 + }, + "embodied_co2e_grams_per_month": 1041.7 }, "r5.xlarge": { "architecture": "x86_64", "vcpus": 4, "memory_gb": 32, - "power_watts": { "idle": 16.0, "max": 48.0 } + "power_watts": { + "idle": 16.0, + "max": 48.0 + }, + "embodied_co2e_grams_per_month": 2083.3 }, "t4g.micro": { "architecture": "arm64", "vcpus": 2, "memory_gb": 1, - "power_watts": { "idle": 0.9, "max": 3.2 } + "power_watts": { + "idle": 0.9, + "max": 3.2 + }, + "embodied_co2e_grams_per_month": 833.3 }, "t4g.small": { "architecture": "arm64", "vcpus": 2, "memory_gb": 2, - "power_watts": { "idle": 1.4, "max": 4.5 } + "power_watts": { + "idle": 1.4, + "max": 4.5 + }, + "embodied_co2e_grams_per_month": 833.3 }, "t4g.medium": { "architecture": "arm64", "vcpus": 2, "memory_gb": 4, - "power_watts": { "idle": 2.2, "max": 6.8 } + "power_watts": { + "idle": 2.2, + "max": 6.8 + }, + "embodied_co2e_grams_per_month": 833.3 }, "t4g.large": { "architecture": "arm64", "vcpus": 2, "memory_gb": 8, - "power_watts": { "idle": 4.4, "max": 13.6 } + "power_watts": { + "idle": 4.4, + "max": 13.6 + }, + "embodied_co2e_grams_per_month": 833.3 }, "t4g.xlarge": { "architecture": "arm64", "vcpus": 4, "memory_gb": 16, - "power_watts": { "idle": 8.8, "max": 27.2 } + "power_watts": { + "idle": 8.8, + "max": 27.2 + }, + "embodied_co2e_grams_per_month": 1666.7 }, "m6g.medium": { "architecture": "arm64", "vcpus": 1, "memory_gb": 4, - "power_watts": { "idle": 2.1, "max": 6.6 } + "power_watts": { + "idle": 2.1, + "max": 6.6 + }, + "embodied_co2e_grams_per_month": 416.7 }, "m6g.large": { "architecture": "arm64", "vcpus": 2, "memory_gb": 8, - "power_watts": { "idle": 4.1, "max": 13.2 } + "power_watts": { + "idle": 4.1, + "max": 13.2 + }, + "embodied_co2e_grams_per_month": 833.3 }, "m6g.xlarge": { "architecture": "arm64", "vcpus": 4, "memory_gb": 16, - "power_watts": { "idle": 8.2, "max": 26.4 } + "power_watts": { + "idle": 8.2, + "max": 26.4 + }, + "embodied_co2e_grams_per_month": 1666.7 }, "m6g.2xlarge": { "architecture": "arm64", "vcpus": 8, "memory_gb": 32, - "power_watts": { "idle": 16.4, "max": 52.8 } + "power_watts": { + "idle": 16.4, + "max": 52.8 + }, + "embodied_co2e_grams_per_month": 3333.3 }, "m7g.medium": { "architecture": "arm64", "vcpus": 1, "memory_gb": 4, - "power_watts": { "idle": 1.8, "max": 5.8 } + "power_watts": { + "idle": 1.8, + "max": 5.8 + }, + "embodied_co2e_grams_per_month": 416.7 }, "m7g.large": { "architecture": "arm64", "vcpus": 2, "memory_gb": 8, - "power_watts": { "idle": 3.6, "max": 11.6 } + "power_watts": { + "idle": 3.6, + "max": 11.6 + }, + "embodied_co2e_grams_per_month": 833.3 }, "m7g.xlarge": { "architecture": "arm64", "vcpus": 4, "memory_gb": 16, - "power_watts": { "idle": 7.2, "max": 23.2 } + "power_watts": { + "idle": 7.2, + "max": 23.2 + }, + "embodied_co2e_grams_per_month": 1666.7 }, "m7g.2xlarge": { "architecture": "arm64", "vcpus": 8, "memory_gb": 32, - "power_watts": { "idle": 14.4, "max": 46.4 } + "power_watts": { + "idle": 14.4, + "max": 46.4 + }, + "embodied_co2e_grams_per_month": 3333.3 }, "c6g.medium": { "architecture": "arm64", "vcpus": 1, "memory_gb": 2, - "power_watts": { "idle": 2.0, "max": 7.3 } + "power_watts": { + "idle": 2.0, + "max": 7.3 + }, + "embodied_co2e_grams_per_month": 416.7 }, "c6g.large": { "architecture": "arm64", "vcpus": 2, "memory_gb": 4, - "power_watts": { "idle": 3.9, "max": 14.5 } + "power_watts": { + "idle": 3.9, + "max": 14.5 + }, + "embodied_co2e_grams_per_month": 833.3 }, "c6g.xlarge": { "architecture": "arm64", "vcpus": 4, "memory_gb": 8, - "power_watts": { "idle": 7.8, "max": 29.0 } + "power_watts": { + "idle": 7.8, + "max": 29.0 + }, + "embodied_co2e_grams_per_month": 1666.7 }, "c6g.2xlarge": { "architecture": "arm64", "vcpus": 8, "memory_gb": 16, - "power_watts": { "idle": 15.6, "max": 58.0 } + "power_watts": { + "idle": 15.6, + "max": 58.0 + }, + "embodied_co2e_grams_per_month": 3333.3 }, "c7g.large": { "architecture": "arm64", "vcpus": 2, "memory_gb": 4, - "power_watts": { "idle": 3.5, "max": 13.0 } + "power_watts": { + "idle": 3.5, + "max": 13.0 + }, + "embodied_co2e_grams_per_month": 833.3 }, "c7g.xlarge": { "architecture": "arm64", "vcpus": 4, "memory_gb": 8, - "power_watts": { "idle": 7.0, "max": 26.0 } + "power_watts": { + "idle": 7.0, + "max": 26.0 + }, + "embodied_co2e_grams_per_month": 1666.7 }, "r6g.large": { "architecture": "arm64", "vcpus": 2, "memory_gb": 16, - "power_watts": { "idle": 4.8, "max": 15.0 } + "power_watts": { + "idle": 4.8, + "max": 15.0 + }, + "embodied_co2e_grams_per_month": 833.3 }, "r6g.xlarge": { "architecture": "arm64", "vcpus": 4, "memory_gb": 32, - "power_watts": { "idle": 9.6, "max": 30.0 } + "power_watts": { + "idle": 9.6, + "max": 30.0 + }, + "embodied_co2e_grams_per_month": 1666.7 } }, "pricing_usd_per_hour": { "us-east-1": { - "t3.micro": 0.0104, "t3.small": 0.0208, "t3.medium": 0.0416, "t3.large": 0.0832, "t3.xlarge": 0.1664, - "t3a.medium": 0.0376, "t3a.large": 0.0752, - "m5.large": 0.0960, "m5.xlarge": 0.1920, "m5.2xlarge": 0.3840, - "m5a.large": 0.0860, "m5a.xlarge": 0.1720, - "c5.large": 0.0850, "c5.xlarge": 0.1700, "c5.2xlarge": 0.3400, - "c5a.large": 0.0770, "c5a.xlarge": 0.1540, - "r5.large": 0.1260, "r5.xlarge": 0.2520, - "t4g.micro": 0.0084, "t4g.small": 0.0168, "t4g.medium": 0.0336, "t4g.large": 0.0672, "t4g.xlarge": 0.1344, - "m6g.medium": 0.0385, "m6g.large": 0.0770, "m6g.xlarge": 0.1540, "m6g.2xlarge": 0.3080, - "m7g.medium": 0.0408, "m7g.large": 0.0816, "m7g.xlarge": 0.1632, "m7g.2xlarge": 0.3264, - "c6g.medium": 0.0340, "c6g.large": 0.0680, "c6g.xlarge": 0.1360, "c6g.2xlarge": 0.2720, - "c7g.large": 0.0725, "c7g.xlarge": 0.1450, - "r6g.large": 0.1008, "r6g.xlarge": 0.2016 + "t3.micro": 0.0104, + "t3.small": 0.0208, + "t3.medium": 0.0416, + "t3.large": 0.0832, + "t3.xlarge": 0.1664, + "t3a.medium": 0.0376, + "t3a.large": 0.0752, + "m5.large": 0.096, + "m5.xlarge": 0.192, + "m5.2xlarge": 0.384, + "m5a.large": 0.086, + "m5a.xlarge": 0.172, + "c5.large": 0.085, + "c5.xlarge": 0.17, + "c5.2xlarge": 0.34, + "c5a.large": 0.077, + "c5a.xlarge": 0.154, + "r5.large": 0.126, + "r5.xlarge": 0.252, + "t4g.micro": 0.0084, + "t4g.small": 0.0168, + "t4g.medium": 0.0336, + "t4g.large": 0.0672, + "t4g.xlarge": 0.1344, + "m6g.medium": 0.0385, + "m6g.large": 0.077, + "m6g.xlarge": 0.154, + "m6g.2xlarge": 0.308, + "m7g.medium": 0.0408, + "m7g.large": 0.0816, + "m7g.xlarge": 0.1632, + "m7g.2xlarge": 0.3264, + "c6g.medium": 0.034, + "c6g.large": 0.068, + "c6g.xlarge": 0.136, + "c6g.2xlarge": 0.272, + "c7g.large": 0.0725, + "c7g.xlarge": 0.145, + "r6g.large": 0.1008, + "r6g.xlarge": 0.2016 }, "us-east-2": { - "t3.micro": 0.0104, "t3.small": 0.0208, "t3.medium": 0.0416, "t3.large": 0.0832, "t3.xlarge": 0.1664, - "t3a.medium": 0.0376, "t3a.large": 0.0752, - "m5.large": 0.0960, "m5.xlarge": 0.1920, "m5.2xlarge": 0.3840, - "m5a.large": 0.0860, "m5a.xlarge": 0.1720, - "c5.large": 0.0850, "c5.xlarge": 0.1700, "c5.2xlarge": 0.3400, - "c5a.large": 0.0770, "c5a.xlarge": 0.1540, - "r5.large": 0.1260, "r5.xlarge": 0.2520, - "t4g.micro": 0.0084, "t4g.small": 0.0168, "t4g.medium": 0.0336, "t4g.large": 0.0672, "t4g.xlarge": 0.1344, - "m6g.medium": 0.0385, "m6g.large": 0.0770, "m6g.xlarge": 0.1540, "m6g.2xlarge": 0.3080, - "m7g.medium": 0.0408, "m7g.large": 0.0816, "m7g.xlarge": 0.1632, "m7g.2xlarge": 0.3264, - "c6g.medium": 0.0340, "c6g.large": 0.0680, "c6g.xlarge": 0.1360, "c6g.2xlarge": 0.2720, - "c7g.large": 0.0725, "c7g.xlarge": 0.1450, - "r6g.large": 0.1008, "r6g.xlarge": 0.2016 + "t3.micro": 0.0104, + "t3.small": 0.0208, + "t3.medium": 0.0416, + "t3.large": 0.0832, + "t3.xlarge": 0.1664, + "t3a.medium": 0.0376, + "t3a.large": 0.0752, + "m5.large": 0.096, + "m5.xlarge": 0.192, + "m5.2xlarge": 0.384, + "m5a.large": 0.086, + "m5a.xlarge": 0.172, + "c5.large": 0.085, + "c5.xlarge": 0.17, + "c5.2xlarge": 0.34, + "c5a.large": 0.077, + "c5a.xlarge": 0.154, + "r5.large": 0.126, + "r5.xlarge": 0.252, + "t4g.micro": 0.0084, + "t4g.small": 0.0168, + "t4g.medium": 0.0336, + "t4g.large": 0.0672, + "t4g.xlarge": 0.1344, + "m6g.medium": 0.0385, + "m6g.large": 0.077, + "m6g.xlarge": 0.154, + "m6g.2xlarge": 0.308, + "m7g.medium": 0.0408, + "m7g.large": 0.0816, + "m7g.xlarge": 0.1632, + "m7g.2xlarge": 0.3264, + "c6g.medium": 0.034, + "c6g.large": 0.068, + "c6g.xlarge": 0.136, + "c6g.2xlarge": 0.272, + "c7g.large": 0.0725, + "c7g.xlarge": 0.145, + "r6g.large": 0.1008, + "r6g.xlarge": 0.2016 }, "us-west-1": { - "t3.micro": 0.0116, "t3.small": 0.0232, "t3.medium": 0.0464, "t3.large": 0.0928, "t3.xlarge": 0.1856, - "m5.large": 0.1070, "m5.xlarge": 0.2140, "m5.2xlarge": 0.4280, - "c5.large": 0.0960, "c5.xlarge": 0.1920, "c5.2xlarge": 0.3840, - "t4g.medium": 0.0376, "t4g.large": 0.0752, "t4g.xlarge": 0.1504, - "m6g.large": 0.0860, "m6g.xlarge": 0.1720, "m6g.2xlarge": 0.3440, - "m7g.large": 0.0912, "m7g.xlarge": 0.1824, - "c6g.large": 0.0760, "c6g.xlarge": 0.1520, - "r6g.large": 0.1127, "r6g.xlarge": 0.2254 + "t3.micro": 0.0116, + "t3.small": 0.0232, + "t3.medium": 0.0464, + "t3.large": 0.0928, + "t3.xlarge": 0.1856, + "m5.large": 0.107, + "m5.xlarge": 0.214, + "m5.2xlarge": 0.428, + "c5.large": 0.096, + "c5.xlarge": 0.192, + "c5.2xlarge": 0.384, + "t4g.medium": 0.0376, + "t4g.large": 0.0752, + "t4g.xlarge": 0.1504, + "m6g.large": 0.086, + "m6g.xlarge": 0.172, + "m6g.2xlarge": 0.344, + "m7g.large": 0.0912, + "m7g.xlarge": 0.1824, + "c6g.large": 0.076, + "c6g.xlarge": 0.152, + "r6g.large": 0.1127, + "r6g.xlarge": 0.2254 }, "us-west-2": { - "t3.micro": 0.0104, "t3.small": 0.0208, "t3.medium": 0.0416, "t3.large": 0.0832, "t3.xlarge": 0.1664, - "t3a.medium": 0.0376, "t3a.large": 0.0752, - "m5.large": 0.0960, "m5.xlarge": 0.1920, "m5.2xlarge": 0.3840, - "m5a.large": 0.0860, "m5a.xlarge": 0.1720, - "c5.large": 0.0850, "c5.xlarge": 0.1700, "c5.2xlarge": 0.3400, - "c5a.large": 0.0770, "c5a.xlarge": 0.1540, - "r5.large": 0.1260, "r5.xlarge": 0.2520, - "t4g.micro": 0.0084, "t4g.small": 0.0168, "t4g.medium": 0.0336, "t4g.large": 0.0672, "t4g.xlarge": 0.1344, - "m6g.medium": 0.0385, "m6g.large": 0.0770, "m6g.xlarge": 0.1540, "m6g.2xlarge": 0.3080, - "m7g.medium": 0.0408, "m7g.large": 0.0816, "m7g.xlarge": 0.1632, "m7g.2xlarge": 0.3264, - "c6g.medium": 0.0340, "c6g.large": 0.0680, "c6g.xlarge": 0.1360, "c6g.2xlarge": 0.2720, - "c7g.large": 0.0725, "c7g.xlarge": 0.1450, - "r6g.large": 0.1008, "r6g.xlarge": 0.2016 + "t3.micro": 0.0104, + "t3.small": 0.0208, + "t3.medium": 0.0416, + "t3.large": 0.0832, + "t3.xlarge": 0.1664, + "t3a.medium": 0.0376, + "t3a.large": 0.0752, + "m5.large": 0.096, + "m5.xlarge": 0.192, + "m5.2xlarge": 0.384, + "m5a.large": 0.086, + "m5a.xlarge": 0.172, + "c5.large": 0.085, + "c5.xlarge": 0.17, + "c5.2xlarge": 0.34, + "c5a.large": 0.077, + "c5a.xlarge": 0.154, + "r5.large": 0.126, + "r5.xlarge": 0.252, + "t4g.micro": 0.0084, + "t4g.small": 0.0168, + "t4g.medium": 0.0336, + "t4g.large": 0.0672, + "t4g.xlarge": 0.1344, + "m6g.medium": 0.0385, + "m6g.large": 0.077, + "m6g.xlarge": 0.154, + "m6g.2xlarge": 0.308, + "m7g.medium": 0.0408, + "m7g.large": 0.0816, + "m7g.xlarge": 0.1632, + "m7g.2xlarge": 0.3264, + "c6g.medium": 0.034, + "c6g.large": 0.068, + "c6g.xlarge": 0.136, + "c6g.2xlarge": 0.272, + "c7g.large": 0.0725, + "c7g.xlarge": 0.145, + "r6g.large": 0.1008, + "r6g.xlarge": 0.2016 }, "eu-west-1": { - "t3.micro": 0.0116, "t3.small": 0.0232, "t3.medium": 0.0456, "t3.large": 0.0912, "t3.xlarge": 0.1824, - "t3a.medium": 0.0416, "t3a.large": 0.0832, - "m5.large": 0.1070, "m5.xlarge": 0.2140, "m5.2xlarge": 0.4280, - "m5a.large": 0.0960, "m5a.xlarge": 0.1920, - "c5.large": 0.0960, "c5.xlarge": 0.1920, "c5.2xlarge": 0.3840, - "c5a.large": 0.0870, "c5a.xlarge": 0.1740, - "r5.large": 0.1410, "r5.xlarge": 0.2820, - "t4g.micro": 0.0094, "t4g.small": 0.0188, "t4g.medium": 0.0376, "t4g.large": 0.0752, "t4g.xlarge": 0.1504, - "m6g.medium": 0.0430, "m6g.large": 0.0860, "m6g.xlarge": 0.1720, "m6g.2xlarge": 0.3440, - "m7g.medium": 0.0456, "m7g.large": 0.0912, "m7g.xlarge": 0.1824, "m7g.2xlarge": 0.3648, - "c6g.medium": 0.0380, "c6g.large": 0.0760, "c6g.xlarge": 0.1520, "c6g.2xlarge": 0.3040, - "c7g.large": 0.0812, "c7g.xlarge": 0.1624, - "r6g.large": 0.1127, "r6g.xlarge": 0.2254 + "t3.micro": 0.0116, + "t3.small": 0.0232, + "t3.medium": 0.0456, + "t3.large": 0.0912, + "t3.xlarge": 0.1824, + "t3a.medium": 0.0416, + "t3a.large": 0.0832, + "m5.large": 0.107, + "m5.xlarge": 0.214, + "m5.2xlarge": 0.428, + "m5a.large": 0.096, + "m5a.xlarge": 0.192, + "c5.large": 0.096, + "c5.xlarge": 0.192, + "c5.2xlarge": 0.384, + "c5a.large": 0.087, + "c5a.xlarge": 0.174, + "r5.large": 0.141, + "r5.xlarge": 0.282, + "t4g.micro": 0.0094, + "t4g.small": 0.0188, + "t4g.medium": 0.0376, + "t4g.large": 0.0752, + "t4g.xlarge": 0.1504, + "m6g.medium": 0.043, + "m6g.large": 0.086, + "m6g.xlarge": 0.172, + "m6g.2xlarge": 0.344, + "m7g.medium": 0.0456, + "m7g.large": 0.0912, + "m7g.xlarge": 0.1824, + "m7g.2xlarge": 0.3648, + "c6g.medium": 0.038, + "c6g.large": 0.076, + "c6g.xlarge": 0.152, + "c6g.2xlarge": 0.304, + "c7g.large": 0.0812, + "c7g.xlarge": 0.1624, + "r6g.large": 0.1127, + "r6g.xlarge": 0.2254 }, "eu-west-2": { - "t3.micro": 0.0126, "t3.small": 0.0252, "t3.medium": 0.0504, "t3.large": 0.1008, "t3.xlarge": 0.2016, - "m5.large": 0.1178, "m5.xlarge": 0.2356, "m5.2xlarge": 0.4712, - "c5.large": 0.1054, "c5.xlarge": 0.2108, "c5.2xlarge": 0.4216, - "t4g.medium": 0.0414, "t4g.large": 0.0828, "t4g.xlarge": 0.1656, - "m6g.large": 0.0945, "m6g.xlarge": 0.1890, "m6g.2xlarge": 0.3780, - "m7g.large": 0.1001, "m7g.xlarge": 0.2002, - "c6g.large": 0.0836, "c6g.xlarge": 0.1672, - "r6g.large": 0.1240, "r6g.xlarge": 0.2480 + "t3.micro": 0.0126, + "t3.small": 0.0252, + "t3.medium": 0.0504, + "t3.large": 0.1008, + "t3.xlarge": 0.2016, + "m5.large": 0.1178, + "m5.xlarge": 0.2356, + "m5.2xlarge": 0.4712, + "c5.large": 0.1054, + "c5.xlarge": 0.2108, + "c5.2xlarge": 0.4216, + "t4g.medium": 0.0414, + "t4g.large": 0.0828, + "t4g.xlarge": 0.1656, + "m6g.large": 0.0945, + "m6g.xlarge": 0.189, + "m6g.2xlarge": 0.378, + "m7g.large": 0.1001, + "m7g.xlarge": 0.2002, + "c6g.large": 0.0836, + "c6g.xlarge": 0.1672, + "r6g.large": 0.124, + "r6g.xlarge": 0.248 }, "eu-central-1": { - "t3.micro": 0.0120, "t3.small": 0.0240, "t3.medium": 0.0496, "t3.large": 0.0992, "t3.xlarge": 0.1984, - "t3a.medium": 0.0448, "t3a.large": 0.0896, - "m5.large": 0.1150, "m5.xlarge": 0.2300, "m5.2xlarge": 0.4600, - "m5a.large": 0.1030, "m5a.xlarge": 0.2060, - "c5.large": 0.1020, "c5.xlarge": 0.2040, "c5.2xlarge": 0.4080, - "r5.large": 0.1510, "r5.xlarge": 0.3020, - "t4g.micro": 0.0100, "t4g.small": 0.0200, "t4g.medium": 0.0416, "t4g.large": 0.0832, "t4g.xlarge": 0.1664, - "m6g.medium": 0.0460, "m6g.large": 0.0920, "m6g.xlarge": 0.1840, "m6g.2xlarge": 0.3680, - "m7g.medium": 0.0488, "m7g.large": 0.0976, "m7g.xlarge": 0.1952, "m7g.2xlarge": 0.3904, - "c6g.medium": 0.0410, "c6g.large": 0.0820, "c6g.xlarge": 0.1640, "c6g.2xlarge": 0.3280, - "c7g.large": 0.0875, "c7g.xlarge": 0.1750, - "r6g.large": 0.1210, "r6g.xlarge": 0.2420 + "t3.micro": 0.012, + "t3.small": 0.024, + "t3.medium": 0.0496, + "t3.large": 0.0992, + "t3.xlarge": 0.1984, + "t3a.medium": 0.0448, + "t3a.large": 0.0896, + "m5.large": 0.115, + "m5.xlarge": 0.23, + "m5.2xlarge": 0.46, + "m5a.large": 0.103, + "m5a.xlarge": 0.206, + "c5.large": 0.102, + "c5.xlarge": 0.204, + "c5.2xlarge": 0.408, + "r5.large": 0.151, + "r5.xlarge": 0.302, + "t4g.micro": 0.01, + "t4g.small": 0.02, + "t4g.medium": 0.0416, + "t4g.large": 0.0832, + "t4g.xlarge": 0.1664, + "m6g.medium": 0.046, + "m6g.large": 0.092, + "m6g.xlarge": 0.184, + "m6g.2xlarge": 0.368, + "m7g.medium": 0.0488, + "m7g.large": 0.0976, + "m7g.xlarge": 0.1952, + "m7g.2xlarge": 0.3904, + "c6g.medium": 0.041, + "c6g.large": 0.082, + "c6g.xlarge": 0.164, + "c6g.2xlarge": 0.328, + "c7g.large": 0.0875, + "c7g.xlarge": 0.175, + "r6g.large": 0.121, + "r6g.xlarge": 0.242 }, "eu-north-1": { - "t3.micro": 0.0108, "t3.small": 0.0216, "t3.medium": 0.0432, "t3.large": 0.0864, "t3.xlarge": 0.1728, - "m5.large": 0.1000, "m5.xlarge": 0.2000, "m5.2xlarge": 0.4000, - "c5.large": 0.0890, "c5.xlarge": 0.1780, "c5.2xlarge": 0.3560, - "t4g.medium": 0.0362, "t4g.large": 0.0724, "t4g.xlarge": 0.1448, - "m6g.large": 0.0800, "m6g.xlarge": 0.1600, "m6g.2xlarge": 0.3200, - "m7g.large": 0.0848, "m7g.xlarge": 0.1696, - "c6g.large": 0.0712, "c6g.xlarge": 0.1424, - "r6g.large": 0.1054, "r6g.xlarge": 0.2108 + "t3.micro": 0.0108, + "t3.small": 0.0216, + "t3.medium": 0.0432, + "t3.large": 0.0864, + "t3.xlarge": 0.1728, + "m5.large": 0.1, + "m5.xlarge": 0.2, + "m5.2xlarge": 0.4, + "c5.large": 0.089, + "c5.xlarge": 0.178, + "c5.2xlarge": 0.356, + "t4g.medium": 0.0362, + "t4g.large": 0.0724, + "t4g.xlarge": 0.1448, + "m6g.large": 0.08, + "m6g.xlarge": 0.16, + "m6g.2xlarge": 0.32, + "m7g.large": 0.0848, + "m7g.xlarge": 0.1696, + "c6g.large": 0.0712, + "c6g.xlarge": 0.1424, + "r6g.large": 0.1054, + "r6g.xlarge": 0.2108 }, "ap-southeast-1": { - "t3.micro": 0.0132, "t3.small": 0.0264, "t3.medium": 0.0528, "t3.large": 0.1056, "t3.xlarge": 0.2112, - "m5.large": 0.1240, "m5.xlarge": 0.2480, "m5.2xlarge": 0.4960, - "c5.large": 0.1070, "c5.xlarge": 0.2140, "c5.2xlarge": 0.4280, - "t4g.medium": 0.0438, "t4g.large": 0.0876, "t4g.xlarge": 0.1752, - "m6g.large": 0.0992, "m6g.xlarge": 0.1984, "m6g.2xlarge": 0.3968, - "m7g.large": 0.1051, "m7g.xlarge": 0.2102, - "c6g.large": 0.0860, "c6g.xlarge": 0.1720, - "r6g.large": 0.1307, "r6g.xlarge": 0.2614 + "t3.micro": 0.0132, + "t3.small": 0.0264, + "t3.medium": 0.0528, + "t3.large": 0.1056, + "t3.xlarge": 0.2112, + "m5.large": 0.124, + "m5.xlarge": 0.248, + "m5.2xlarge": 0.496, + "c5.large": 0.107, + "c5.xlarge": 0.214, + "c5.2xlarge": 0.428, + "t4g.medium": 0.0438, + "t4g.large": 0.0876, + "t4g.xlarge": 0.1752, + "m6g.large": 0.0992, + "m6g.xlarge": 0.1984, + "m6g.2xlarge": 0.3968, + "m7g.large": 0.1051, + "m7g.xlarge": 0.2102, + "c6g.large": 0.086, + "c6g.xlarge": 0.172, + "r6g.large": 0.1307, + "r6g.xlarge": 0.2614 }, "ap-southeast-2": { - "t3.micro": 0.0136, "t3.small": 0.0272, "t3.medium": 0.0544, "t3.large": 0.1088, "t3.xlarge": 0.2176, - "t3a.medium": 0.0492, "t3a.large": 0.0984, - "m5.large": 0.1340, "m5.xlarge": 0.2680, "m5.2xlarge": 0.5360, - "m5a.large": 0.1200, "m5a.xlarge": 0.2400, - "c5.large": 0.1180, "c5.xlarge": 0.2360, "c5.2xlarge": 0.4720, - "r5.large": 0.1760, "r5.xlarge": 0.3520, - "t4g.micro": 0.0113, "t4g.small": 0.0226, "t4g.medium": 0.0452, "t4g.large": 0.0904, "t4g.xlarge": 0.1808, - "m6g.medium": 0.0535, "m6g.large": 0.1070, "m6g.xlarge": 0.2140, "m6g.2xlarge": 0.4280, - "m7g.medium": 0.0567, "m7g.large": 0.1134, "m7g.xlarge": 0.2268, "m7g.2xlarge": 0.4536, - "c6g.medium": 0.0470, "c6g.large": 0.0940, "c6g.xlarge": 0.1880, "c6g.2xlarge": 0.3760, - "c7g.large": 0.1002, "c7g.xlarge": 0.2004, - "r6g.large": 0.1411, "r6g.xlarge": 0.2822 + "t3.micro": 0.0136, + "t3.small": 0.0272, + "t3.medium": 0.0544, + "t3.large": 0.1088, + "t3.xlarge": 0.2176, + "t3a.medium": 0.0492, + "t3a.large": 0.0984, + "m5.large": 0.134, + "m5.xlarge": 0.268, + "m5.2xlarge": 0.536, + "m5a.large": 0.12, + "m5a.xlarge": 0.24, + "c5.large": 0.118, + "c5.xlarge": 0.236, + "c5.2xlarge": 0.472, + "r5.large": 0.176, + "r5.xlarge": 0.352, + "t4g.micro": 0.0113, + "t4g.small": 0.0226, + "t4g.medium": 0.0452, + "t4g.large": 0.0904, + "t4g.xlarge": 0.1808, + "m6g.medium": 0.0535, + "m6g.large": 0.107, + "m6g.xlarge": 0.214, + "m6g.2xlarge": 0.428, + "m7g.medium": 0.0567, + "m7g.large": 0.1134, + "m7g.xlarge": 0.2268, + "m7g.2xlarge": 0.4536, + "c6g.medium": 0.047, + "c6g.large": 0.094, + "c6g.xlarge": 0.188, + "c6g.2xlarge": 0.376, + "c7g.large": 0.1002, + "c7g.xlarge": 0.2004, + "r6g.large": 0.1411, + "r6g.xlarge": 0.2822 }, "ap-northeast-1": { - "t3.micro": 0.0140, "t3.small": 0.0280, "t3.medium": 0.0560, "t3.large": 0.1120, "t3.xlarge": 0.2240, - "t3a.medium": 0.0504, "t3a.large": 0.1008, - "m5.large": 0.1280, "m5.xlarge": 0.2560, "m5.2xlarge": 0.5120, - "m5a.large": 0.1150, "m5a.xlarge": 0.2300, - "c5.large": 0.1140, "c5.xlarge": 0.2280, "c5.2xlarge": 0.4560, - "r5.large": 0.1690, "r5.xlarge": 0.3380, - "t4g.micro": 0.0116, "t4g.small": 0.0232, "t4g.medium": 0.0464, "t4g.large": 0.0928, "t4g.xlarge": 0.1856, - "m6g.medium": 0.0549, "m6g.large": 0.1098, "m6g.xlarge": 0.2196, "m6g.2xlarge": 0.4392, - "m7g.medium": 0.0582, "m7g.large": 0.1164, "m7g.xlarge": 0.2328, "m7g.2xlarge": 0.4656, - "c6g.medium": 0.0482, "c6g.large": 0.0964, "c6g.xlarge": 0.1928, "c6g.2xlarge": 0.3856, - "c7g.large": 0.1028, "c7g.xlarge": 0.2056, - "r6g.large": 0.1448, "r6g.xlarge": 0.2896 + "t3.micro": 0.014, + "t3.small": 0.028, + "t3.medium": 0.056, + "t3.large": 0.112, + "t3.xlarge": 0.224, + "t3a.medium": 0.0504, + "t3a.large": 0.1008, + "m5.large": 0.128, + "m5.xlarge": 0.256, + "m5.2xlarge": 0.512, + "m5a.large": 0.115, + "m5a.xlarge": 0.23, + "c5.large": 0.114, + "c5.xlarge": 0.228, + "c5.2xlarge": 0.456, + "r5.large": 0.169, + "r5.xlarge": 0.338, + "t4g.micro": 0.0116, + "t4g.small": 0.0232, + "t4g.medium": 0.0464, + "t4g.large": 0.0928, + "t4g.xlarge": 0.1856, + "m6g.medium": 0.0549, + "m6g.large": 0.1098, + "m6g.xlarge": 0.2196, + "m6g.2xlarge": 0.4392, + "m7g.medium": 0.0582, + "m7g.large": 0.1164, + "m7g.xlarge": 0.2328, + "m7g.2xlarge": 0.4656, + "c6g.medium": 0.0482, + "c6g.large": 0.0964, + "c6g.xlarge": 0.1928, + "c6g.2xlarge": 0.3856, + "c7g.large": 0.1028, + "c7g.xlarge": 0.2056, + "r6g.large": 0.1448, + "r6g.xlarge": 0.2896 }, "ap-south-1": { - "t3.micro": 0.0114, "t3.small": 0.0228, "t3.medium": 0.0456, "t3.large": 0.0912, "t3.xlarge": 0.1824, - "t3a.medium": 0.0410, "t3a.large": 0.0820, - "m5.large": 0.1060, "m5.xlarge": 0.2120, "m5.2xlarge": 0.4240, - "c5.large": 0.0940, "c5.xlarge": 0.1880, "c5.2xlarge": 0.3760, - "r5.large": 0.1396, "r5.xlarge": 0.2792, - "t4g.micro": 0.0095, "t4g.small": 0.0190, "t4g.medium": 0.0380, "t4g.large": 0.0760, "t4g.xlarge": 0.1520, - "m6g.medium": 0.0454, "m6g.large": 0.0908, "m6g.xlarge": 0.1816, "m6g.2xlarge": 0.3632, - "m7g.medium": 0.0481, "m7g.large": 0.0962, "m7g.xlarge": 0.1924, "m7g.2xlarge": 0.3848, - "c6g.medium": 0.0399, "c6g.large": 0.0798, "c6g.xlarge": 0.1596, "c6g.2xlarge": 0.3192, - "r6g.large": 0.1197, "r6g.xlarge": 0.2394 + "t3.micro": 0.0114, + "t3.small": 0.0228, + "t3.medium": 0.0456, + "t3.large": 0.0912, + "t3.xlarge": 0.1824, + "t3a.medium": 0.041, + "t3a.large": 0.082, + "m5.large": 0.106, + "m5.xlarge": 0.212, + "m5.2xlarge": 0.424, + "c5.large": 0.094, + "c5.xlarge": 0.188, + "c5.2xlarge": 0.376, + "r5.large": 0.1396, + "r5.xlarge": 0.2792, + "t4g.micro": 0.0095, + "t4g.small": 0.019, + "t4g.medium": 0.038, + "t4g.large": 0.076, + "t4g.xlarge": 0.152, + "m6g.medium": 0.0454, + "m6g.large": 0.0908, + "m6g.xlarge": 0.1816, + "m6g.2xlarge": 0.3632, + "m7g.medium": 0.0481, + "m7g.large": 0.0962, + "m7g.xlarge": 0.1924, + "m7g.2xlarge": 0.3848, + "c6g.medium": 0.0399, + "c6g.large": 0.0798, + "c6g.xlarge": 0.1596, + "c6g.2xlarge": 0.3192, + "r6g.large": 0.1197, + "r6g.xlarge": 0.2394 }, "ca-central-1": { - "t3.micro": 0.0116, "t3.small": 0.0232, "t3.medium": 0.0464, "t3.large": 0.0928, "t3.xlarge": 0.1856, - "t3a.medium": 0.0418, "t3a.large": 0.0836, - "m5.large": 0.1070, "m5.xlarge": 0.2140, "m5.2xlarge": 0.4280, - "m5a.large": 0.0960, "m5a.xlarge": 0.1920, - "c5.large": 0.0950, "c5.xlarge": 0.1900, "c5.2xlarge": 0.3800, - "r5.large": 0.1410, "r5.xlarge": 0.2820, - "t4g.micro": 0.0096, "t4g.small": 0.0192, "t4g.medium": 0.0386, "t4g.large": 0.0772, "t4g.xlarge": 0.1544, - "m6g.medium": 0.0462, "m6g.large": 0.0924, "m6g.xlarge": 0.1848, "m6g.2xlarge": 0.3696, - "m7g.medium": 0.0490, "m7g.large": 0.0980, "m7g.xlarge": 0.1960, "m7g.2xlarge": 0.3920, - "c6g.medium": 0.0408, "c6g.large": 0.0816, "c6g.xlarge": 0.1632, "c6g.2xlarge": 0.3264, - "c7g.large": 0.0870, "c7g.xlarge": 0.1740, - "r6g.large": 0.1218, "r6g.xlarge": 0.2436 + "t3.micro": 0.0116, + "t3.small": 0.0232, + "t3.medium": 0.0464, + "t3.large": 0.0928, + "t3.xlarge": 0.1856, + "t3a.medium": 0.0418, + "t3a.large": 0.0836, + "m5.large": 0.107, + "m5.xlarge": 0.214, + "m5.2xlarge": 0.428, + "m5a.large": 0.096, + "m5a.xlarge": 0.192, + "c5.large": 0.095, + "c5.xlarge": 0.19, + "c5.2xlarge": 0.38, + "r5.large": 0.141, + "r5.xlarge": 0.282, + "t4g.micro": 0.0096, + "t4g.small": 0.0192, + "t4g.medium": 0.0386, + "t4g.large": 0.0772, + "t4g.xlarge": 0.1544, + "m6g.medium": 0.0462, + "m6g.large": 0.0924, + "m6g.xlarge": 0.1848, + "m6g.2xlarge": 0.3696, + "m7g.medium": 0.049, + "m7g.large": 0.098, + "m7g.xlarge": 0.196, + "m7g.2xlarge": 0.392, + "c6g.medium": 0.0408, + "c6g.large": 0.0816, + "c6g.xlarge": 0.1632, + "c6g.2xlarge": 0.3264, + "c7g.large": 0.087, + "c7g.xlarge": 0.174, + "r6g.large": 0.1218, + "r6g.xlarge": 0.2436 }, "sa-east-1": { - "t3.micro": 0.0168, "t3.small": 0.0336, "t3.medium": 0.0672, "t3.large": 0.1344, "t3.xlarge": 0.2688, - "m5.large": 0.1620, "m5.xlarge": 0.3240, "m5.2xlarge": 0.6480, - "c5.large": 0.1440, "c5.xlarge": 0.2880, "c5.2xlarge": 0.5760, - "t4g.medium": 0.0560, "t4g.large": 0.1120, "t4g.xlarge": 0.2240, - "m6g.large": 0.1296, "m6g.xlarge": 0.2592, "m6g.2xlarge": 0.5184, - "m7g.large": 0.1374, "m7g.xlarge": 0.2748, - "c6g.large": 0.1152, "c6g.xlarge": 0.2304, - "r6g.large": 0.1706, "r6g.xlarge": 0.3412 + "t3.micro": 0.0168, + "t3.small": 0.0336, + "t3.medium": 0.0672, + "t3.large": 0.1344, + "t3.xlarge": 0.2688, + "m5.large": 0.162, + "m5.xlarge": 0.324, + "m5.2xlarge": 0.648, + "c5.large": 0.144, + "c5.xlarge": 0.288, + "c5.2xlarge": 0.576, + "t4g.medium": 0.056, + "t4g.large": 0.112, + "t4g.xlarge": 0.224, + "m6g.large": 0.1296, + "m6g.xlarge": 0.2592, + "m6g.2xlarge": 0.5184, + "m7g.large": 0.1374, + "m7g.xlarge": 0.2748, + "c6g.large": 0.1152, + "c6g.xlarge": 0.2304, + "r6g.large": 0.1706, + "r6g.xlarge": 0.3412 } } -} +} \ No newline at end of file diff --git a/formatters/markdown.test.ts b/formatters/markdown.test.ts index d84bfc9..d1785cf 100644 --- a/formatters/markdown.test.ts +++ b/formatters/markdown.test.ts @@ -3,20 +3,48 @@ import * as assert from 'node:assert/strict'; import { formatMarkdown } from './markdown.js'; import { PlanAnalysisResult } from '../types.js'; +function makeMockBaseline(overrides: Record = {}) { + return { + totalCo2eGramsPerMonth: 1000, + embodiedCo2eGramsPerMonth: 833.3, + totalLifecycleCo2eGramsPerMonth: 1833.3, + waterLitresPerMonth: 1.8, + totalCostUsdPerMonth: 50, + confidence: 'HIGH' as const, + scope: 'SCOPE_2_AND_3' as const, + assumptionsApplied: { + utilizationApplied: 0.5, + gridIntensityApplied: 240.1, + powerModelUsed: 'LINEAR_INTERPOLATION' as const, + embodiedCo2ePerVcpuPerMonthApplied: 833.3, + waterIntensityLitresPerKwhApplied: 0.18, + }, + ...overrides, + }; +} + +function makeMockTotals(overrides: Record = {}) { + return { + currentCo2eGramsPerMonth: 0, + currentEmbodiedCo2eGramsPerMonth: 0, + currentLifecycleCo2eGramsPerMonth: 0, + currentWaterLitresPerMonth: 0, + currentCostUsdPerMonth: 0, + potentialCo2eSavingGramsPerMonth: 0, + potentialCostSavingUsdPerMonth: 0, + ...overrides, + }; +} + function makeMockResult(overrides: Partial = {}): PlanAnalysisResult { return { analysedAt: '2026-03-25T00:00:00Z', - ledgerVersion: '1.1.0', + ledgerVersion: '1.3.0', planFile: 'plan.json', resources: [], skipped: [], unsupportedTypes: [], - totals: { - currentCo2eGramsPerMonth: 0, - currentCostUsdPerMonth: 0, - potentialCo2eSavingGramsPerMonth: 0, - potentialCostSavingUsdPerMonth: 0, - }, + totals: makeMockTotals(), ...overrides, }; } @@ -26,20 +54,14 @@ describe('formatMarkdown', () => { const result = makeMockResult({ resources: [{ input: { resourceId: 'aws_instance.web', instanceType: 'm6g.large', region: 'us-west-2' }, - baseline: { - totalCo2eGramsPerMonth: 1000, - totalCostUsdPerMonth: 50, - confidence: 'HIGH', - scope: 'SCOPE_2_OPERATIONAL', - assumptionsApplied: { utilizationApplied: 0.5, gridIntensityApplied: 240.1, powerModelUsed: 'LINEAR_INTERPOLATION' }, - }, + baseline: makeMockBaseline({ totalCo2eGramsPerMonth: 1000, totalCostUsdPerMonth: 50 }), recommendation: null, }], - totals: { currentCo2eGramsPerMonth: 1000, currentCostUsdPerMonth: 50, potentialCo2eSavingGramsPerMonth: 0, potentialCostSavingUsdPerMonth: 0 }, + totals: makeMockTotals({ currentCo2eGramsPerMonth: 1000, currentCostUsdPerMonth: 50 }), }); const md = formatMarkdown(result); - assert.ok(md.includes('Already optimally configured'), 'Should show optimally configured message'); + assert.ok(md.includes('optimally configured') || md.includes('Optimal'), 'Should show optimal message'); assert.ok(!md.includes('NaN'), 'Should not contain NaN'); }); @@ -47,17 +69,15 @@ describe('formatMarkdown', () => { const result = makeMockResult({ resources: [{ input: { resourceId: 'aws_instance.test', instanceType: 'x99.fake', region: 'us-east-1' }, - baseline: { + baseline: makeMockBaseline({ totalCo2eGramsPerMonth: 0, totalCostUsdPerMonth: 0, confidence: 'LOW_ASSUMED_DEFAULT', - scope: 'SCOPE_2_OPERATIONAL', unsupportedReason: 'test', - assumptionsApplied: { utilizationApplied: 0.5, gridIntensityApplied: 0, powerModelUsed: 'LINEAR_INTERPOLATION' }, - }, + }), recommendation: { suggestedInstanceType: 'y99.fake', co2eDeltaGramsPerMonth: -100, costDeltaUsdPerMonth: -5, rationale: 'test' }, }], - totals: { currentCo2eGramsPerMonth: 0, currentCostUsdPerMonth: 0, potentialCo2eSavingGramsPerMonth: 100, potentialCostSavingUsdPerMonth: 5 }, + totals: makeMockTotals({ potentialCo2eSavingGramsPerMonth: 100, potentialCostSavingUsdPerMonth: 5 }), }); const md = formatMarkdown(result); @@ -68,13 +88,7 @@ describe('formatMarkdown', () => { const result = makeMockResult({ resources: [{ input: { resourceId: 'aws_instance.web', instanceType: 'm5.large', region: 'us-east-1' }, - baseline: { - totalCo2eGramsPerMonth: 4313, - totalCostUsdPerMonth: 70, - confidence: 'HIGH', - scope: 'SCOPE_2_OPERATIONAL', - assumptionsApplied: { utilizationApplied: 0.5, gridIntensityApplied: 384.5, powerModelUsed: 'LINEAR_INTERPOLATION' }, - }, + baseline: makeMockBaseline({ totalCo2eGramsPerMonth: 4313, totalCostUsdPerMonth: 70 }), recommendation: { suggestedInstanceType: 'm6g.large', co2eDeltaGramsPerMonth: -1500, @@ -82,7 +96,7 @@ describe('formatMarkdown', () => { rationale: 'Switch to ARM64', }, }], - totals: { currentCo2eGramsPerMonth: 4313, currentCostUsdPerMonth: 70, potentialCo2eSavingGramsPerMonth: 1500, potentialCostSavingUsdPerMonth: 13.87 }, + totals: makeMockTotals({ currentCo2eGramsPerMonth: 4313, currentCostUsdPerMonth: 70, potentialCo2eSavingGramsPerMonth: 1500, potentialCostSavingUsdPerMonth: 13.87 }), }); const md = formatMarkdown(result); @@ -90,6 +104,21 @@ describe('formatMarkdown', () => { assert.ok(md.includes('m6g.large'), 'Should include suggested instance type'); }); + it('shows embodied carbon and water in resource breakdown', () => { + const result = makeMockResult({ + resources: [{ + input: { resourceId: 'aws_instance.web', instanceType: 'm5.large', region: 'us-east-1' }, + baseline: makeMockBaseline({ embodiedCo2eGramsPerMonth: 1041.7, waterLitresPerMonth: 5.16 }), + recommendation: null, + }], + totals: makeMockTotals({ currentEmbodiedCo2eGramsPerMonth: 1041.7, currentWaterLitresPerMonth: 5.16 }), + }); + + const md = formatMarkdown(result); + assert.ok(md.includes('Scope 3'), 'Should include Scope 3 column'); + assert.ok(md.includes('Water'), 'Should include Water column'); + }); + it('shows upgrade prompt when option is true', () => { const result = makeMockResult(); const md = formatMarkdown(result, { showUpgradePrompt: true }); @@ -102,10 +131,11 @@ describe('formatMarkdown', () => { assert.ok(!md.includes('GreenOps Dashboard'), 'Should not include upgrade prompt'); }); - it('includes scope disclaimer in footer', () => { + it('includes Scope 2 and Scope 3 in footer', () => { const result = makeMockResult(); const md = formatMarkdown(result); - assert.ok(md.includes('Scope 2 operational emissions only'), 'Should include scope disclaimer'); + assert.ok(md.includes('Scope 2'), 'Should include Scope 2 in footer'); + assert.ok(md.includes('Scope 3'), 'Should include Scope 3 in footer'); }); it('shows coverage note when unsupported compute types are present', () => { diff --git a/formatters/markdown.ts b/formatters/markdown.ts index 0042e54..852fe55 100644 --- a/formatters/markdown.ts +++ b/formatters/markdown.ts @@ -6,36 +6,53 @@ export interface FormatterOptions { showUpgradePrompt?: boolean; } +function formatWater(litres: number): string { + if (litres >= 1000) return `${(litres / 1000).toFixed(2)}m³`; + return `${litres.toFixed(1)}L`; +} + export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOptions = {}): string { const METHODOLOGY_URL = options.repositoryUrl || 'https://github.com/omrdev1/greenops-cli/blob/main/METHODOLOGY.md'; const recsCount = result.resources.filter(r => r.recommendation).length; let out = `## 🌱 GreenOps Infrastructure Impact\n\n`; - - out += `> **Total Current Footprint:** ${formatGrams(result.totals.currentCo2eGramsPerMonth)} CO2e/month | **$${result.totals.currentCostUsdPerMonth.toFixed(2)}**/month\n`; - + + const scope2 = formatGrams(result.totals.currentCo2eGramsPerMonth); + const scope3 = formatGrams(result.totals.currentEmbodiedCo2eGramsPerMonth); + const lifecycle = formatGrams(result.totals.currentLifecycleCo2eGramsPerMonth); + const water = formatWater(result.totals.currentWaterLitresPerMonth); + const cost = result.totals.currentCostUsdPerMonth.toFixed(2); + + out += `> | Metric | Monthly Total |\n`; + out += `> |---|---|\n`; + out += `> | 🔋 Scope 2 — Operational CO2e | **${scope2}** |\n`; + out += `> | 🏭 Scope 3 — Embodied CO2e | **${scope3}** |\n`; + out += `> | 🌍 Total Lifecycle CO2e | **${lifecycle}** |\n`; + out += `> | 💧 Water Consumption | **${water}** |\n`; + out += `> | 💰 Infrastructure Cost | **$${cost}/month** |\n\n`; + if (recsCount > 0) { const pct = result.totals.currentCo2eGramsPerMonth > 0 ? ((result.totals.potentialCo2eSavingGramsPerMonth / result.totals.currentCo2eGramsPerMonth) * 100).toFixed(1) : '0.0'; - out += `> **Potential Savings:** -${formatGrams(result.totals.potentialCo2eSavingGramsPerMonth)} CO2e/month (${pct}%) | -$${result.totals.potentialCostSavingUsdPerMonth.toFixed(2)}/month\n`; + out += `> **Potential Scope 2 Savings:** -${formatGrams(result.totals.potentialCo2eSavingGramsPerMonth)} CO2e/month (${pct}%) | -$${result.totals.potentialCostSavingUsdPerMonth.toFixed(2)}/month\n`; out += `> 💡 Found **${recsCount}** optimization ${recsCount === 1 ? 'recommendation' : 'recommendations'}.\n\n`; } else { - out += `> ✅ **Already optimally configured!** No upgrades recommended.\n\n`; + out += `> ✅ **Already optimally configured.** No upgrades recommended.\n\n`; } out += `### Resource Breakdown\n\n`; - out += `| Resource | Type | Region | CO2e/month | Cost/month | Action |\n`; - out += `|---|---|---|---|---|---|\n`; + out += `| Resource | Type | Region | Scope 2 CO2e | Scope 3 CO2e | Water | Cost/mo | Action |\n`; + out += `|---|---|---|---|---|---|---|---|\n`; for (const r of result.resources) { - const action = r.recommendation ? `💡 [View Recommendation](#recommendations)` : `✅ No change needed`; - out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |\n`; + const action = r.recommendation ? `💡 [View Recommendation](#recommendations)` : `✅ Optimal`; + out += `| \`${r.input.resourceId}\` | \`${r.input.instanceType}\` | \`${r.input.region}\` | ${formatGrams(r.baseline.totalCo2eGramsPerMonth)} | ${formatGrams(r.baseline.embodiedCo2eGramsPerMonth)} | ${formatWater(r.baseline.waterLitresPerMonth)} | $${r.baseline.totalCostUsdPerMonth.toFixed(2)} | ${action} |\n`; } out += `\n`; if (result.skipped.length > 0) { out += `
⚠️ ${result.skipped.length} Skipped Resources\n\n`; - out += `The following resources were skipped from calculation (usually due to runtime abstractions). The actual footprint may be higher.\n\n`; + out += `The following resources were excluded from analysis (typically due to runtime-resolved attributes). The actual footprint may be higher.\n\n`; out += `| Resource | Reason |\n|---|---|\n`; for (const s of result.skipped) { out += `| \`${s.resourceId}\` | \`${s.reason}\` |\n`; @@ -52,22 +69,25 @@ export function formatMarkdown(result: PlanAnalysisResult, options: FormatterOpt const sugRegion = r.recommendation.suggestedRegion || r.input.region; const sugInst = r.recommendation.suggestedInstanceType || r.input.instanceType; out += `- **Suggested:** \`${sugInst}\` in \`${sugRegion}\`\n`; - out += `- **Impact:** ${formatDelta(r.recommendation.co2eDeltaGramsPerMonth)} CO2e/month | ${formatCostDelta(r.recommendation.costDeltaUsdPerMonth)}/month\n`; + out += `- **Scope 2 Impact:** ${formatDelta(r.recommendation.co2eDeltaGramsPerMonth)} CO2e/month | ${formatCostDelta(r.recommendation.costDeltaUsdPerMonth)}/month\n`; out += `- **Rationale:** ${r.recommendation.rationale}\n\n`; } } } - out += `---\n`; - out += `*Emissions calculated using the Open GreenOps Methodology Ledger (v${result.ledgerVersion}). Scope 2 operational emissions only — embodied carbon and water are not tracked. Math is MIT-licensed and auditable. Analysed at ${result.analysedAt}. [Learn more](${METHODOLOGY_URL}).*\n`; - if (result.unsupportedTypes.length > 0) { const typeList = result.unsupportedTypes.map(t => `\`${t}\``).join(', '); - out += `\n> ⚠️ **Coverage note:** This analysis covers \`aws_instance\` and \`aws_db_instance\` resources only. The following compute-relevant types were detected but are not yet supported: ${typeList}. Their footprint is not reflected above.\n`; + out += `> ⚠️ **Coverage note:** The following compute-relevant types were detected but are not yet supported: ${typeList}. Their footprint is not reflected above.\n\n`; } + out += `---\n`; + out += `*Emissions calculated using the [Open GreenOps Methodology Ledger v${result.ledgerVersion}](${METHODOLOGY_URL}). `; + out += `Scope 2 (operational) and Scope 3 (embodied) emissions tracked. `; + out += `Water consumption estimated from AWS 2023 WUE data. `; + out += `Math is MIT-licensed and auditable. Analysed at ${result.analysedAt}.*\n`; + if (options.showUpgradePrompt) { - out += `\n> 🏢 **Managing green-ops across dozens of repositories?** [Upgrade to GreenOps Dashboard](https://greenops-cli.dev/upgrade) to aggregate CI/CD carbon data natively.\n`; + out += `\n> 🏢 **GreenOps Dashboard** — aggregate carbon data across all your repositories, set team budgets, and export ESG reports. [Join the waitlist](https://greenops-cli.dev) · Coming soon.\n`; } return out; diff --git a/formatters/table.test.ts b/formatters/table.test.ts index 7fa7df9..d69e721 100644 --- a/formatters/table.test.ts +++ b/formatters/table.test.ts @@ -3,20 +3,48 @@ import * as assert from 'node:assert/strict'; import { formatTable } from './table.js'; import { PlanAnalysisResult } from '../types.js'; +function makeMockBaseline(overrides: Record = {}) { + return { + totalCo2eGramsPerMonth: 1000, + embodiedCo2eGramsPerMonth: 833.3, + totalLifecycleCo2eGramsPerMonth: 1833.3, + waterLitresPerMonth: 1.8, + totalCostUsdPerMonth: 50, + confidence: 'HIGH' as const, + scope: 'SCOPE_2_AND_3' as const, + assumptionsApplied: { + utilizationApplied: 0.5, + gridIntensityApplied: 240.1, + powerModelUsed: 'LINEAR_INTERPOLATION' as const, + embodiedCo2ePerVcpuPerMonthApplied: 833.3, + waterIntensityLitresPerKwhApplied: 0.18, + }, + ...overrides, + }; +} + +function makeMockTotals(overrides: Record = {}) { + return { + currentCo2eGramsPerMonth: 0, + currentEmbodiedCo2eGramsPerMonth: 0, + currentLifecycleCo2eGramsPerMonth: 0, + currentWaterLitresPerMonth: 0, + currentCostUsdPerMonth: 0, + potentialCo2eSavingGramsPerMonth: 0, + potentialCostSavingUsdPerMonth: 0, + ...overrides, + }; +} + function makeMockResult(overrides: Partial = {}): PlanAnalysisResult { return { analysedAt: '2026-03-25T00:00:00Z', - ledgerVersion: '1.1.0', + ledgerVersion: '1.3.0', planFile: 'plan.json', resources: [], skipped: [], unsupportedTypes: [], - totals: { - currentCo2eGramsPerMonth: 0, - currentCostUsdPerMonth: 0, - potentialCo2eSavingGramsPerMonth: 0, - potentialCostSavingUsdPerMonth: 0, - }, + totals: makeMockTotals(), ...overrides, }; } @@ -33,16 +61,10 @@ describe('formatTable', () => { const result = makeMockResult({ resources: [{ input: { resourceId: longId, instanceType: 'm5.large', region: 'us-east-1' }, - baseline: { - totalCo2eGramsPerMonth: 1000, - totalCostUsdPerMonth: 50, - confidence: 'HIGH', - scope: 'SCOPE_2_OPERATIONAL', - assumptionsApplied: { utilizationApplied: 0.5, gridIntensityApplied: 384.5, powerModelUsed: 'LINEAR_INTERPOLATION' }, - }, + baseline: makeMockBaseline({ totalCo2eGramsPerMonth: 1000, totalCostUsdPerMonth: 50 }), recommendation: null, }], - totals: { currentCo2eGramsPerMonth: 1000, currentCostUsdPerMonth: 50, potentialCo2eSavingGramsPerMonth: 0, potentialCostSavingUsdPerMonth: 0 }, + totals: makeMockTotals({ currentCo2eGramsPerMonth: 1000, currentCostUsdPerMonth: 50 }), }); const table = formatTable(result); @@ -57,4 +79,19 @@ describe('formatTable', () => { const table = formatTable(result); assert.ok(table.includes('SKIPPED'), 'Should show SKIPPED for skipped resources'); }); + + it('shows Scope 2 and Scope 3 columns', () => { + const result = makeMockResult({ + resources: [{ + input: { resourceId: 'aws_instance.web', instanceType: 'm5.large', region: 'us-east-1' }, + baseline: makeMockBaseline(), + recommendation: null, + }], + totals: makeMockTotals({ currentCo2eGramsPerMonth: 1000 }), + }); + + const table = formatTable(result); + assert.ok(table.includes('Scope 2'), 'Should show Scope 2 label'); + assert.ok(table.includes('Scope 3'), 'Should show Scope 3 label'); + }); }); diff --git a/formatters/table.ts b/formatters/table.ts index a7a0598..c583441 100644 --- a/formatters/table.ts +++ b/formatters/table.ts @@ -1,19 +1,17 @@ import { PlanAnalysisResult } from '../types.js'; import { formatDelta, formatCostDelta, formatGrams } from './util.js'; -// Strip ANSI escape codes to get the true visible length of a string, -// so padEnd() aligns columns correctly in the terminal table. -function visibleLength(str: string): number { - return str.replace(/\x1b\[[0-9;]*m/g, '').length; -} - function truncate(str: string, len: number): string { - // Always use visible text for consistent output — callers handle coloring at row level const visible = str.replace(/\x1b\[[0-9;]*m/g, ''); if (visible.length > len) return visible.substring(0, len - 3) + '...'; return visible + ' '.repeat(len - visible.length); } +function formatWater(litres: number): string { + if (litres >= 1000) return `${(litres / 1000).toFixed(1)}m³`; + return `${litres.toFixed(1)}L`; +} + export function formatTable(result: PlanAnalysisResult): string { let out = `\n\x1b[1m🌱 GreenOps Infrastructure Impact\x1b[0m\n\n`; @@ -21,23 +19,27 @@ export function formatTable(result: PlanAnalysisResult): string { return out + `No compatible infrastructure detected.\n`; } - out += `┌${'─'.repeat(40)}┬${'─'.repeat(15)}┬${'─'.repeat(15)}┬${'─'.repeat(15)}┬${'─'.repeat(15)}┐\n`; - out += `│ ${truncate('Resource', 38)} │ ${truncate('Instance', 13)} │ ${truncate('Region', 13)} │ ${truncate('CO2e/mo', 13)} │ ${truncate('Action', 13)} │\n`; - out += `├${'─'.repeat(40)}┼${'─'.repeat(15)}┼${'─'.repeat(15)}┼${'─'.repeat(15)}┼${'─'.repeat(15)}┤\n`; + out += `┌${'─'.repeat(38)}┬${'─'.repeat(13)}┬${'─'.repeat(13)}┬${'─'.repeat(11)}┬${'─'.repeat(11)}┬${'─'.repeat(9)}┬${'─'.repeat(13)}┐\n`; + out += `│ ${truncate('Resource', 36)} │ ${truncate('Instance', 11)} │ ${truncate('Region', 11)} │ ${truncate('Scope 2', 9)} │ ${truncate('Scope 3', 9)} │ ${truncate('Water', 7)} │ ${truncate('Action', 11)} │\n`; + out += `├${'─'.repeat(38)}┼${'─'.repeat(13)}┼${'─'.repeat(13)}┼${'─'.repeat(11)}┼${'─'.repeat(11)}┼${'─'.repeat(9)}┼${'─'.repeat(13)}┤\n`; for (const r of result.resources) { - const c = formatGrams(r.baseline.totalCo2eGramsPerMonth); + const scope2 = formatGrams(r.baseline.totalCo2eGramsPerMonth); + const scope3 = formatGrams(r.baseline.embodiedCo2eGramsPerMonth); + const water = formatWater(r.baseline.waterLitresPerMonth); const action = r.recommendation ? `\x1b[33mUPGRADE\x1b[0m` : `\x1b[32mOK\x1b[0m`; - out += `│ ${truncate(r.input.resourceId, 38)} │ ${truncate(r.input.instanceType, 13)} │ ${truncate(r.input.region, 13)} │ ${truncate(c, 13)} │ ${truncate(action, 13)} │\n`; + out += `│ ${truncate(r.input.resourceId, 36)} │ ${truncate(r.input.instanceType, 11)} │ ${truncate(r.input.region, 11)} │ ${truncate(scope2, 9)} │ ${truncate(scope3, 9)} │ ${truncate(water, 7)} │ ${truncate(action, 11)} │\n`; } for (const s of result.skipped) { - out += `│ \x1b[90m${truncate(s.resourceId, 38)}\x1b[0m │ \x1b[90m${truncate('---', 13)}\x1b[0m │ \x1b[90m${truncate('---', 13)}\x1b[0m │ \x1b[90m${truncate('---', 13)}\x1b[0m │ \x1b[33m${truncate('⚠ SKIPPED', 13)}\x1b[0m │\n`; + out += `│ \x1b[90m${truncate(s.resourceId, 36)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 11)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 9)}\x1b[0m │ \x1b[90m${truncate('---', 7)}\x1b[0m │ \x1b[33m${truncate('⚠ SKIPPED', 11)}\x1b[0m │\n`; } - out += `└${'─'.repeat(40)}┴${'─'.repeat(15)}┴${'─'.repeat(15)}┴${'─'.repeat(15)}┴${'─'.repeat(15)}┘\n\n`; + out += `└${'─'.repeat(38)}┴${'─'.repeat(13)}┴${'─'.repeat(13)}┴${'─'.repeat(11)}┴${'─'.repeat(11)}┴${'─'.repeat(9)}┴${'─'.repeat(13)}┘\n\n`; + + out += `Scope 2: ${formatGrams(result.totals.currentCo2eGramsPerMonth)} | Scope 3: ${formatGrams(result.totals.currentEmbodiedCo2eGramsPerMonth)} | Lifecycle: ${formatGrams(result.totals.currentLifecycleCo2eGramsPerMonth)}\n`; + out += `Water: ${formatWater(result.totals.currentWaterLitresPerMonth)} | Cost: $${result.totals.currentCostUsdPerMonth.toFixed(2)}/month\n`; - out += `Current: ${formatGrams(result.totals.currentCo2eGramsPerMonth)} | $${result.totals.currentCostUsdPerMonth.toFixed(2)}\n`; if (result.totals.potentialCo2eSavingGramsPerMonth > 0) { - out += `\x1b[32mSavings: ${formatDelta(-result.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta(-result.totals.potentialCostSavingUsdPerMonth)}\x1b[0m\n`; + out += `\x1b[32mScope 2 Savings: ${formatDelta(-result.totals.potentialCo2eSavingGramsPerMonth)} | ${formatCostDelta(-result.totals.potentialCostSavingUsdPerMonth)}\x1b[0m\n`; } if (result.skipped.length > 0) { diff --git a/json.test.ts b/json.test.ts index c9d7300..27ea4a7 100644 --- a/json.test.ts +++ b/json.test.ts @@ -1,31 +1,56 @@ import { describe, it } from 'node:test'; import * as assert from 'node:assert/strict'; import { formatJson } from './formatters/json.js'; -import { PlanAnalysisResult } from './types.js'; +import { calculateBaseline, analysePlan } from './engine.js'; describe('JSON Formatter', () => { - it('outputs valid, compact JSON with schemaVersion matching ledgerVersion and no ANSI characters', () => { - const mockResult: PlanAnalysisResult = { - analysedAt: new Date().toISOString(), - ledgerVersion: '1.1.0', - planFile: 'plan.json', - resources: [], - skipped: [], - unsupportedTypes: [], - totals: { - currentCo2eGramsPerMonth: 500, - currentCostUsdPerMonth: 10, - potentialCo2eSavingGramsPerMonth: 0, - potentialCostSavingUsdPerMonth: 0 - } - }; - const jsonStr = formatJson(mockResult); + it('outputs valid compact JSON with schemaVersion, all emission scopes, and no ANSI characters', () => { + // Use real engine output — no mocks + const result = analysePlan( + [{ resourceId: 'aws_instance.web', instanceType: 'm5.large', region: 'us-east-1' }], + [], + 'plan.json' + ); + const jsonStr = formatJson(result); const parsed = JSON.parse(jsonStr); - // schemaVersion must always mirror the ledgerVersion from the result - assert.equal(parsed.schemaVersion, '1.1.0'); - assert.equal(parsed.result.totals.currentCo2eGramsPerMonth, 500); - assert.ok(!jsonStr.includes('\\n')); - assert.ok(!jsonStr.includes('\\x1b')); + + // Schema version mirrors ledger version + assert.equal(parsed.schemaVersion, result.ledgerVersion); + + // Scope 2 operational — non-zero for a supported instance + assert.ok(parsed.result.totals.currentCo2eGramsPerMonth > 0, 'Should have Scope 2 CO2e'); + + // Scope 3 embodied — non-zero + assert.ok(parsed.result.totals.currentEmbodiedCo2eGramsPerMonth > 0, 'Should have Scope 3 embodied CO2e'); + + // Lifecycle total = Scope 2 + Scope 3 + assert.ok( + Math.abs(parsed.result.totals.currentLifecycleCo2eGramsPerMonth - + (parsed.result.totals.currentCo2eGramsPerMonth + parsed.result.totals.currentEmbodiedCo2eGramsPerMonth)) < 0.001, + 'Lifecycle should equal Scope 2 + Scope 3' + ); + + // Water consumption — non-zero + assert.ok(parsed.result.totals.currentWaterLitresPerMonth > 0, 'Should have water consumption'); + + // No terminal escape codes or newlines in JSON output + assert.ok(!jsonStr.includes('\n'), 'JSON should be compact'); + assert.ok(!jsonStr.includes('\x1b'), 'JSON should not contain ANSI codes'); + }); + + it('individual resource baseline includes all three dimensions', () => { + const result = analysePlan( + [{ resourceId: 'aws_instance.api', instanceType: 'm6g.large', region: 'eu-north-1' }], + [], + 'plan.json' + ); + const parsed = JSON.parse(formatJson(result)); + const resource = parsed.result.resources[0]; + + assert.ok(resource.baseline.totalCo2eGramsPerMonth > 0, 'Scope 2 present'); + assert.ok(resource.baseline.embodiedCo2eGramsPerMonth > 0, 'Scope 3 present'); + assert.ok(resource.baseline.waterLitresPerMonth > 0, 'Water present'); + assert.equal(resource.baseline.scope, 'SCOPE_2_AND_3', 'Scope should be SCOPE_2_AND_3'); }); }); diff --git a/package.json b/package.json index 5db0b6f..a82536d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "greenops-cli", - "version": "0.3.0", + "version": "0.4.0", "description": "Carbon footprint linting for Terraform plans. Analyses infrastructure changes for CO2e impact and cost, posts recommendations directly on GitHub PRs.", "main": "dist/index.cjs", "bin": { diff --git a/policy.test.ts b/policy.test.ts index 639b8ce..8882cc3 100644 --- a/policy.test.ts +++ b/policy.test.ts @@ -14,7 +14,10 @@ function makeMockResult(overrides: Partial = {}): skipped: [], unsupportedTypes: [], totals: { - currentCo2eGramsPerMonth: 5000, // 5kg + currentCo2eGramsPerMonth: 5000, + currentEmbodiedCo2eGramsPerMonth: 1041.7, + currentLifecycleCo2eGramsPerMonth: 6041.7, + currentWaterLitresPerMonth: 2.3, currentCostUsdPerMonth: 200, potentialCo2eSavingGramsPerMonth: 1000, potentialCostSavingUsdPerMonth: 20, diff --git a/types.ts b/types.ts index 04e8aee..2c3837e 100644 --- a/types.ts +++ b/types.ts @@ -1,74 +1,122 @@ /** * Core Types for GreenOps Plan Parser + * + * Emission scopes covered: + * SCOPE_2_OPERATIONAL — CPU power draw × grid carbon intensity × PUE + * SCOPE_3_EMBODIED — Prorated hardware manufacturing lifecycle emissions + * + * Water consumption is tracked separately as it is not an emission scope + * but is a material environmental impact that GreenPixie and other tools report. */ export type ConfidenceLevel = "HIGH" | "MEDIUM" | "LOW_ASSUMED_DEFAULT"; -export type PowerModel = - | "LINEAR_INTERPOLATION" // (min + (max - min) * util) * pue - | "IDLE_PLUS_DYNAMIC" // for Lambda-style invocation models later +export type PowerModel = + | "LINEAR_INTERPOLATION" // W = idle + (max - idle) * util — standard CCF model + | "IDLE_PLUS_DYNAMIC" // for Lambda-style invocation models (future) | "STATIC_TDP"; // fallback when only max TDP is known export interface ResourceInput { resourceId: string; // e.g., "aws_instance.web_server" instanceType: string; // e.g., "m5.large" region: string; // e.g., "us-east-1" - hoursPerMonth?: number; // Default: 730 - avgUtilization?: number; // Uses factors.json metadata default if omitted + hoursPerMonth?: number; // Default: 730 (full calendar month) + avgUtilization?: number; // Uses factors.json metadata default (50%) if omitted } export interface EmissionAndCostEstimate { + // --- Scope 2: Operational emissions --- + /** CPU power draw × PUE × grid carbon intensity. Primary metric. */ totalCo2eGramsPerMonth: number; + + // --- Scope 3: Embodied emissions --- + /** + * Prorated hardware manufacturing lifecycle carbon for this resource. + * Calculated as: (server_total_embodied_gco2e / lifespan_hours / vcpus_per_server) + * × vcpus × 730h + * Source: CCF DELL R740 baseline (1,200 kgCO2e/server, 4yr lifespan, 48 vCPUs). + * ARM (Graviton) instances apply a 20% discount reflecting smaller die size and + * lower TDP manufacturing footprint. + */ + embodiedCo2eGramsPerMonth: number; + + /** Combined Scope 2 + Scope 3 total. Use this for full-lifecycle reporting. */ + totalLifecycleCo2eGramsPerMonth: number; + + // --- Water consumption --- + /** + * Estimated water consumption from data center cooling. + * Calculated as: operational_energy_kwh × regional_wue_litres_per_kwh + * Source: AWS 2023 Sustainability Report (WUE by region). + * Covers direct water withdrawal for cooling only — not supply chain water. + */ + waterLitresPerMonth: number; + + // --- Cost --- totalCostUsdPerMonth: number; - + + // --- Metadata --- confidence: ConfidenceLevel; - unsupportedReason?: string; + unsupportedReason?: string; - /** Which emission scopes this estimate covers. - * Currently SCOPE_2_OPERATIONAL only — embodied emissions (Scope 3) and - * water consumption are not tracked. */ - scope: 'SCOPE_2_OPERATIONAL'; + /** + * Which emission scopes this estimate covers. + * SCOPE_2_OPERATIONAL | SCOPE_3_EMBODIED | BOTH + */ + scope: 'SCOPE_2_OPERATIONAL' | 'SCOPE_3_EMBODIED' | 'SCOPE_2_AND_3'; assumptionsApplied: { utilizationApplied: number; gridIntensityApplied: number; - powerModelUsed: PowerModel; + powerModelUsed: PowerModel; + embodiedCo2ePerVcpuPerMonthApplied: number; + waterIntensityLitresPerKwhApplied: number; }; } export interface UpgradeRecommendation { suggestedInstanceType?: string; suggestedRegion?: string; - - co2eDeltaGramsPerMonth: number; - costDeltaUsdPerMonth: number; - + + /** Negative = saving. Scope 2 operational only (matches current resource delta). */ + co2eDeltaGramsPerMonth: number; + /** Negative = saving. */ + costDeltaUsdPerMonth: number; + rationale: string; } export interface PlanAnalysisResult { - analysedAt: string; // ISO timestamp - ledgerVersion: string; // from factors.json metadata - planFile: string; // path or hash of the input - + analysedAt: string; // ISO timestamp + ledgerVersion: string; // from factors.json metadata + planFile: string; // path of the input plan + resources: Array<{ input: ResourceInput; baseline: EmissionAndCostEstimate; recommendation: UpgradeRecommendation | null; }>; - + skipped: Array<{ resourceId: string; reason: "known_after_apply" | "unsupported_instance" | "unsupported_region" | string; }>; - /** Compute-relevant resource types present in the plan that are not yet supported for analysis - * (e.g. aws_launch_template, aws_ecs_service). Empty array means full coverage of detected compute. */ + /** Compute-relevant types in the plan not yet analysable (e.g. aws_lambda_function). */ unsupportedTypes: string[]; - + totals: { + // Scope 2 currentCo2eGramsPerMonth: number; + // Scope 3 + currentEmbodiedCo2eGramsPerMonth: number; + // Combined Scope 2 + 3 + currentLifecycleCo2eGramsPerMonth: number; + // Water + currentWaterLitresPerMonth: number; + // Cost currentCostUsdPerMonth: number; + // Savings potential (Scope 2 only — recommendations target operational emissions) potentialCo2eSavingGramsPerMonth: number; potentialCostSavingUsdPerMonth: number; };