diff --git a/docs/src/comparison.md b/docs/src/comparison.md index b90e6f70..538051fb 100644 --- a/docs/src/comparison.md +++ b/docs/src/comparison.md @@ -33,7 +33,7 @@ SPRUCE is part of a growing ecosystem of open source tools focused on measuring | **Data Privacy** | Processes data locally, no external API calls for core functionality | Requires cloud provider credentials | | **Modularity** | Highly modular with configurable enrichment pipelines | Fixed calculation pipeline with configuration options | | **Output** | Enriched Parquet/CSV files for custom analytics and visualization | Pre-built dashboard and recommendations | -| **Embodied Carbon** | Includes embodied emissions via Boavizta integration | Limited embodied carbon estimates | +| **Embodied Carbon** | Includes embodied emissions for compute via Boavizta, and for storage from drive LCAs | Limited embodied carbon estimates | | **Scalability** | Designed for large-scale data processing with Apache Spark | Suitable for smaller to medium deployments | | **Carbon Intensity** | Uses Ember average data | Default factors outdated | | **Maintenance Status** | Actively maintained with regular updates | No longer actively maintained | diff --git a/docs/src/methodology.md b/docs/src/methodology.md index 0c8829eb..378bb531 100644 --- a/docs/src/methodology.md +++ b/docs/src/methodology.md @@ -1,6 +1,6 @@ --- title: "How cloud carbon and water estimates are calculated" -description: "The methodology behind SPRUCE: embodied emissions from Boavizta, energy models from Cloud Carbon Footprint, Ember grid carbon intensity, PUE and WUE." +description: "The methodology behind SPRUCE: embodied emissions from Boavizta and drive LCAs, energy models from Cloud Carbon Footprint, Ember grid carbon intensity, PUE and WUE." --- # How SPRUCE calculates cloud carbon, energy and water estimates @@ -30,7 +30,7 @@ The main columns added by SPRUCE are: : emissions of CO2 eq in grams from the energy usage. `embodied_emissions_co2eq_g` -: emissions of CO2 eq in grams embodied in the hardware used by the service, i.e. how much did it take to produce it. +: emissions of CO2 eq in grams embodied in the hardware used by the service, i.e. how much did it take to produce it. Estimated for compute instances, LLM inference, and the drives behind storage; other services leave it null rather than zero. The total emissions for a service are `operational_emissions_co2eq_g` + `embodied_emissions_co2eq_g`. diff --git a/docs/src/modules.md b/docs/src/modules.md index 9de59622..dca55c04 100644 --- a/docs/src/modules.md +++ b/docs/src/modules.md @@ -43,7 +43,7 @@ flowchart LR | Module | Providers | Writes | Based on | |---|---|---|---| | [RegionExtraction](#regionextraction) | AWS, Azure, FOCUS | `region` | — | -| [Storage](#storage) | AWS, Azure | `operational_energy_kwh` | [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/) | +| [Storage](#storage) | AWS, Azure | `operational_energy_kwh`, `embodied_emissions_co2eq_g` | [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/) for energy, [Boavizta](https://doc.api.boavizta.org/) and vendor LCAs for embodied | | [Networking](#networking) | AWS, Azure | `operational_energy_kwh` | [Boavizta](https://boavizta.org/) coefficients | | [Serverless](#serverless) | AWS | `operational_energy_kwh` | [Tailpipe](https://tailpipe.ai/methodology/serverless-explained/) | | [Accelerators](#accelerators) | AWS | `operational_energy_kwh` | [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/) | @@ -70,29 +70,66 @@ provider-neutral: it reads the standard `RegionId` column. ## Stage 2 — Energy and embodied estimates -The modules in this stage estimate the energy used by a row of usage — and, for Boavizta -and EcoLogits, the related embodied emissions. Each module handles a different kind of +The modules in this stage estimate the energy used by a row of usage and, for Storage, +Boavizta and EcoLogits, the related embodied emissions. Each module handles a different kind of usage (storage, networking, compute, …), so they complement each other rather than overlap. ### Storage Estimates the energy used for storage by applying a flat coefficient per GB, following the -approach of the [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/docs/methodology#storage) project. -Service-specific replication factors are applied. On Azure, managed disks are estimated -from their provisioned capacity. +approach of the [Cloud Carbon Footprint](https://www.cloudcarbonfootprint.org/docs/methodology#storage) project, +and the embodied emissions of the drives holding the data, amortised over their service life. +Service-specific replication factors are applied to both, since the same bytes occupy that many +times more physical drives. On Azure, managed disks are estimated from their provisioned capacity. | | | |---|---| | **Classes** | `com.digitalpebble.spruce.modules.ccf.aws.Storage`
`com.digitalpebble.spruce.modules.ccf.azure.Storage` | -| **Writes** | `operational_energy_kwh` | +| **Writes** | `operational_energy_kwh`
`embodied_emissions_co2eq_g` | -**Configuration** (in Wh per TB-hour): +**Energy configuration** (in Wh per TB-hour): | Key | Default | Description | |---|---|---| | `hdd_coefficient_tb_h` | 0.65 | Energy per TB-hour for HDD storage | | `ssd_coefficient_tb_h` | 1.2 | Energy per TB-hour for SSD storage | +**Embodied emissions configuration**: + +| Key | Default | Description | +|---|---|---| +| `hdd_embodied_kg_per_drive` | 30.0 | Embodied emissions of one hard drive, in kg CO2eq | +| `hdd_capacity_gb` | 15000.0 | Capacity assumed for one hard drive | +| `ssd_embodied_kg_per_gb` | 0.055 | Embodied emissions of an SSD, per GB of capacity | +| `storage_lifetime_hours` | 43800.0 | Service life the embodied emissions are amortised over (5 years) | + +The two media are modelled on different bases. A hard drive costs roughly the same to manufacture +whatever its capacity, since the platters, motor, actuator, casing and PCB are near-fixed for a +3.5" unit and areal density does the work, so its embodied emissions are a constant per drive +divided by an assumed capacity. An SSD's die area scales with capacity, so its figure is a rate +per GB. With the defaults that works out at 0.40 kg CO2eq per TB-year for HDD and 11 kg for SSD. + +Note that `hdd_capacity_gb` describes the physical drive rather than a provisioned volume, so it +is not the same thing as an Azure Managed Disk size or an EBS volume size. + +**Data sources for the embodied figures**: + +| Figure | Value | Source | +|---|---|---| +| HDD, per drive | 31.11 kg CO2eq | [BoaviztAPI HDD component](https://doc.api.boavizta.org/Explanations/components/hdd/), from [Umweltbundesamt, *Green Cloud Computing* 2021](https://www.umweltbundesamt.de/sites/default/files/medien/5750/publikationen/2021-06-17_texte_94-2021_green-cloud-computing.pdf) | +| HDD, Seagate Exos X22 LCA | 28.7 kg CO2eq for a 22 TB drive | [Tailpipe manufacture methodology](https://tailpipe.ai/methodology/embodied-emissions-methodology-manufacture/) | +| HDD, per TB-year | 0.27 kg CO2eq | [Seagate, hard drives and data centre sustainability](https://www.seagate.com/blog/hard-drives-the-key-to-data-center-sustainability/) | +| HDD, 24 vendor LCAs | 0.02 kg CO2eq/GB over a 512 GB to 6 TB sample | [Tannu & Nair, *The Dirty Secret of SSDs: Embodied Carbon*](https://arxiv.org/pdf/2207.10793) | +| SSD, die-area formula | 0.052 kg CO2eq/GB | [BoaviztAPI SSD component](https://doc.api.boavizta.org/Explanations/components/ssd/) | +| SSD, 3D NAND study | 0.056 kg CO2eq/GB | [Tailpipe manufacture methodology](https://tailpipe.ai/methodology/embodied-emissions-methodology-manufacture/), from [*Embodied Carbon Footprint of 3D NAND Memories*](https://hal.science/hal-05015578v1/document) | +| Drive capacity mix | 15 TB installed-fleet average | [Backblaze Drive Stats 2025](https://www.backblaze.com/blog/backblaze-drive-stats-for-2025/) | + +The first four rows converge on roughly 30 kg per drive across a 40x range of capacities, which +is itself the evidence for treating HDD embodied carbon as capacity independent. The Tannu & +Nair rate cannot be applied per GB to current hardware: it encodes the drive sizes of a pre-2023 +corpus and overstates per-byte embodied carbon by about an order of magnitude. See +[issue #102](https://github.com/DigitalPebble/spruce/issues/102) for the full derivation. + ### Networking Estimates the energy used for networking in and out of data centres. The module diff --git a/src/main/java/com/digitalpebble/spruce/Utils.java b/src/main/java/com/digitalpebble/spruce/Utils.java index e9c0143e..7d4458f0 100644 --- a/src/main/java/com/digitalpebble/spruce/Utils.java +++ b/src/main/java/com/digitalpebble/spruce/Utils.java @@ -107,6 +107,20 @@ public static Row withUpdatedValues(Row row, Map updates) { return new GenericRowWithSchema(values, row.schema()); } + /** + * Reads a numeric module parameter, tolerating the whole-number literals JSON configs + * routinely carry as integers rather than doubles. + * + * @param params module configuration + * @param key parameter name + * @param fallback value returned when the parameter is absent + * @return the configured value, or {@code fallback} + */ + public static double doubleParam(Map params, String key, double fallback) { + Number value = (Number) params.get(key); + return value != null ? value.doubleValue() : fallback; + } + /** * Utility conversions used throughout the codebase. * diff --git a/src/main/java/com/digitalpebble/spruce/modules/ccf/aws/Storage.java b/src/main/java/com/digitalpebble/spruce/modules/ccf/aws/Storage.java index 84ca7c60..806825f5 100644 --- a/src/main/java/com/digitalpebble/spruce/modules/ccf/aws/Storage.java +++ b/src/main/java/com/digitalpebble/spruce/modules/ccf/aws/Storage.java @@ -18,12 +18,38 @@ import java.util.Map; import static com.digitalpebble.spruce.CURColumn.*; +import static com.digitalpebble.spruce.SpruceColumn.EMBODIED_EMISSIONS; import static com.digitalpebble.spruce.SpruceColumn.ENERGY_USED; import static com.digitalpebble.spruce.Utils.loadJSONResources; /** - * Provides an estimate of energy used for storage. - * Applies a flat coefficient per Gb + * Provides an estimate of energy used for storage, and of the embodied emissions of the drives + * holding it. Applies a flat coefficient per Gb + * + *

Embodied emissions are amortised over a five year service life. The two media are modelled + * on different bases because they behave differently: a hard drive costs roughly the same to + * manufacture whatever its capacity, since the platters, motor, actuator, casing and PCB are + * near-fixed for a 3.5" unit and areal density does the work, whereas an SSD's die area scales + * with capacity. Hence a constant per drive for HDD and a rate per GB for SSD. + * + *

The 30 kg CO2eq per drive is the convergence point of four independent sources spanning a + * 40x range of drive capacities, which is itself the evidence for treating it as capacity + * independent: Boavizta / Umweltbundesamt Green Cloud Computing 2021 (31.11 kg per unit), + * a Seagate Exos X22 LCA (28.7 kg for a 22 TB drive), Seagate's published 0.27 kg per TB-year, + * and Tannu & Nair's meta-analysis of 24 vendor LCAs (0.02 kg/GB over a 512 GB to 6 TB + * sample). Note that the last of those cannot be used as a per-GB rate on modern hardware: it + * encodes the drive sizes of a pre-2023 corpus and overstates current per-byte embodied carbon + * by an order of magnitude. + * + *

The 0.055 kg CO2eq per GB for SSD is where Boavizta's die-area formula (0.052) and the 2025 + * Embodied Carbon Footprint of 3D NAND Memories study (0.056) agree. Tannu & Nair's + * 0.16 kg/GB is roughly 3x higher because 3D NAND layer scaling has cut per-GB manufacturing + * carbon since their corpus closed. + * + *

The assumed 15 TB drive is the installed-fleet average implied by Backblaze's 2025 Drive + * Stats, which is the right basis for bytes sitting on hardware bought over several years; + * current nearline shipments average nearer 22 TB, which would give 0.27 kg per TB-year instead + * of 0.40. Both figures are configurable. * *

The values read (operations, usage types, units) are identical in CUR and FOCUS reports, * only the column labels differ: {@link #bindReportFormat(ReportFormat)} selects the bindings. @@ -32,6 +58,13 @@ * * @see CCF methodology * @see resource file + * @see Boavizta HDD embodied impacts + * @see Boavizta SSD embodied impacts + * @see Tailpipe manufacture methodology, incl. the Exos X22 LCA + * @see Seagate, embodied carbon per TB-year + * @see Tannu & Nair, The Dirty Secret of SSDs: Embodied Carbon + * @see Backblaze Drive Stats 2025, fleet capacity mix + * @see issue #102 **/ public class Storage implements EnrichmentModule { @@ -69,6 +102,20 @@ public void bindReportFormat(ReportFormat reportFormat) { // 1.2 Watt-Hours per Terabyte-Hour for SSD double ssd_gb_coefficient = 1.2 / 1024d; + /** Embodied emissions of one hard drive, in kg CO2eq; see the class javadoc for why this is + * a constant per drive rather than a rate per byte. */ + double hdd_embodied_kg_per_drive = 30d; + /** Capacity assumed for one hard drive, in GB. */ + double hdd_capacity_gb = 15_000d; + /** Embodied emissions of an SSD, in kg CO2eq per GB of capacity. */ + double ssd_embodied_kg_per_gb = 0.055d; + /** Service life over which embodied emissions are amortised, in hours (5 years). */ + double storage_lifetime_hours = 43_800d; + + /** Grams CO2eq per GB-hour of stored data, derived in {@link #init(Map)}. */ + double hdd_embodied_g_per_gb_hour; + double ssd_embodied_g_per_gb_hour; + List ssd_usage_types; List hdd_usage_types; List ssd_services; @@ -86,8 +133,19 @@ public void init(Map params) { ssd_gb_coefficient = coef / 1024d; } + hdd_embodied_kg_per_drive = Utils.doubleParam(params, "hdd_embodied_kg_per_drive", hdd_embodied_kg_per_drive); + hdd_capacity_gb = Utils.doubleParam(params, "hdd_capacity_gb", hdd_capacity_gb); + ssd_embodied_kg_per_gb = Utils.doubleParam(params, "ssd_embodied_kg_per_gb", ssd_embodied_kg_per_gb); + storage_lifetime_hours = Utils.doubleParam(params, "storage_lifetime_hours", storage_lifetime_hours); + + hdd_embodied_g_per_gb_hour = + hdd_embodied_kg_per_drive * 1000d / (hdd_capacity_gb * storage_lifetime_hours); + ssd_embodied_g_per_gb_hour = ssd_embodied_kg_per_gb * 1000d / storage_lifetime_hours; + log.info("hdd_gb_coefficient: {}", hdd_gb_coefficient); log.info("ssd_gb_coefficient: {}", ssd_gb_coefficient); + log.info("hdd_embodied_g_per_gb_hour: {}", hdd_embodied_g_per_gb_hour); + log.info("ssd_embodied_g_per_gb_hour: {}", ssd_embodied_g_per_gb_hour); try { Map map = loadJSONResources("ccf/storage.json"); @@ -109,7 +167,7 @@ public Column[] columnsNeeded() { @Override public Column[] columnsAdded() { - return new Column[]{ENERGY_USED}; + return new Column[]{ENERGY_USED, EMBODIED_EMISSIONS}; } @Override @@ -180,6 +238,10 @@ private void computeEnergy(Row row, Map enrichedValues, boolean // to kwh double energy_kwh = amount /1000 * coefficient * replication; enrichedValues.put(ENERGY_USED, energy_kwh); + // the replication factor applies to the hardware as well as to the energy: the same bytes + // occupy that many times more physical drives, and so that much more embodied carbon + double embodied_coefficient = isHDD ? hdd_embodied_g_per_gb_hour : ssd_embodied_g_per_gb_hour; + enrichedValues.put(EMBODIED_EMISSIONS, amount * embodied_coefficient * replication); } /** diff --git a/src/main/java/com/digitalpebble/spruce/modules/ccf/azure/Storage.java b/src/main/java/com/digitalpebble/spruce/modules/ccf/azure/Storage.java index 15e48f59..8923ea6c 100644 --- a/src/main/java/com/digitalpebble/spruce/modules/ccf/azure/Storage.java +++ b/src/main/java/com/digitalpebble/spruce/modules/ccf/azure/Storage.java @@ -21,14 +21,26 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; +import static com.digitalpebble.spruce.SpruceColumn.EMBODIED_EMISSIONS; import static com.digitalpebble.spruce.SpruceColumn.ENERGY_USED; /** - * Provides an estimate of energy used for Azure storage capacity meters. + * Provides an estimate of energy used for Azure storage capacity meters, and of the embodied + * emissions of the drives holding the data. * The values read (meter names, units) are identical in legacy and FOCUS reports, only the * column labels differ: {@link #bindReportFormat(ReportFormat)} selects the bindings. * + *

The embodied emissions coefficients and their derivation are the same as for AWS, since they + * describe drives rather than anything provider specific; see + * {@link com.digitalpebble.spruce.modules.ccf.aws.Storage} for the sources and the reasoning + * behind modelling HDD per drive and SSD per GB. + * + *

Note that {@code hdd_capacity_gb} describes the physical drive, not a Managed Disk SKU. The + * provisioned size of a P10 or S4 volume says nothing about the drive underneath it, so the + * capacities in {@code MANAGED_DISKS} must not be substituted here. + * * @see CCF methodology + * @see issue #102 **/ public class Storage implements EnrichmentModule { @@ -72,6 +84,20 @@ public void bindReportFormat(ReportFormat reportFormat) { // 1.2 Watt-Hours per Terabyte-Hour for SSD double ssd_gb_coefficient = 1.2 / 1024d; + /** Embodied emissions of one hard drive, in kg CO2eq; a constant per drive rather than a + * rate per byte. */ + double hdd_embodied_kg_per_drive = 30d; + /** Capacity assumed for one physical hard drive, in GB. */ + double hdd_capacity_gb = 15_000d; + /** Embodied emissions of an SSD, in kg CO2eq per GB of capacity. */ + double ssd_embodied_kg_per_gb = 0.055d; + /** Service life over which embodied emissions are amortised, in hours (5 years). */ + double storage_lifetime_hours = 43_800d; + + /** Grams CO2eq per GB-hour of stored data, derived in {@link #init(Map)}. */ + double hdd_embodied_g_per_gb_hour; + double ssd_embodied_g_per_gb_hour; + @Override @SuppressWarnings("unchecked") public void init(Map params) { @@ -84,8 +110,19 @@ public void init(Map params) { ssd_gb_coefficient = coef / 1024d; } + hdd_embodied_kg_per_drive = Utils.doubleParam(params, "hdd_embodied_kg_per_drive", hdd_embodied_kg_per_drive); + hdd_capacity_gb = Utils.doubleParam(params, "hdd_capacity_gb", hdd_capacity_gb); + ssd_embodied_kg_per_gb = Utils.doubleParam(params, "ssd_embodied_kg_per_gb", ssd_embodied_kg_per_gb); + storage_lifetime_hours = Utils.doubleParam(params, "storage_lifetime_hours", storage_lifetime_hours); + + hdd_embodied_g_per_gb_hour = + hdd_embodied_kg_per_drive * 1000d / (hdd_capacity_gb * storage_lifetime_hours); + ssd_embodied_g_per_gb_hour = ssd_embodied_kg_per_gb * 1000d / storage_lifetime_hours; + LOG.info("hdd_gb_coefficient: {}", hdd_gb_coefficient); LOG.info("ssd_gb_coefficient: {}", ssd_gb_coefficient); + LOG.info("hdd_embodied_g_per_gb_hour: {}", hdd_embodied_g_per_gb_hour); + LOG.info("ssd_embodied_g_per_gb_hour: {}", ssd_embodied_g_per_gb_hour); try { Map map = Utils.loadJSONResources("ccf/azure-storage.json"); @@ -106,7 +143,7 @@ public Column[] columnsNeeded() { @Override public Column[] columnsAdded() { - return new Column[]{ENERGY_USED}; + return new Column[]{ENERGY_USED, EMBODIED_EMISSIONS}; } @Override @@ -212,6 +249,10 @@ private void computeEnergy(double gbHours, boolean isHDD, int replication, Map provideArgsWithType() { return Stream.of( Arguments.of("Storage", 0.1d, "EUW2-TimedStorage-ByteHrs", "AmazonS3", "GB-Mo", false), - Arguments.of("Storage", 0.1d, "SomeUsageType", "AmazonDocDB", "GB-Mo", false), + // AmazonDocDB is in SSD_SERVICES, so it takes the SSD coefficients + Arguments.of("Storage", 0.1d, "SomeUsageType", "AmazonDocDB", "GB-Mo", true), Arguments.of("CreateVolume", 10d, "EUW2-EBS:VolumeUsage", "AmazonEC2", "GB-Mo", false), Arguments.of("CreateVolume-Gp2", 10d, "EBS:VolumeUsage.gp2", "AmazonEC2", "GB-Mo", true), Arguments.of("CreateVolume-Gp3", 10d, "VolumeUsage.gp3", "AmazonEC2", "GB-Mo", true) @@ -66,7 +68,54 @@ void process(String operation, double amount, String usage, String service, Stri int replication = storage.getReplicationFactor(service, usage); double coef = isSSD ? storage.ssd_gb_coefficient : storage.hdd_gb_coefficient; double expected = gb_hours * coef * replication / 1000; - assertEquals(expected, (Double) enriched.get(ENERGY_USED), 0.0001); + assertEquals(expected, (Double) enriched.get(ENERGY_USED), 1e-12); + + double embodiedCoef = isSSD ? storage.ssd_embodied_g_per_gb_hour : storage.hdd_embodied_g_per_gb_hour; + double expectedEmbodied = gb_hours * embodiedCoef * replication; + assertEquals(expectedEmbodied, (Double) enriched.get(EMBODIED_EMISSIONS), 0.0001); + } + + /** A row the module does not recognise gets neither impact, not a zero for one of them. */ + @ParameterizedTest + @MethodSource("provideArgsWrongUnit") + void noEmbodiedWithoutEnergy(String operation, double amount, String usage, String service, String unit) { + Object[] values = new Object[]{operation, amount, usage, service, unit, null}; + Row row = new GenericRowWithSchema(values, schema); + Map enriched = new HashMap<>(); + storage.enrich(row, enriched); + assertFalse(enriched.containsKey(EMBODIED_EMISSIONS)); + } + + /** + * HDD embodied carbon is a constant per drive spread over the drive's capacity and life, + * SSD a rate per GB spread over its life. Defaults: 30 kg per 15 TB drive and 0.055 kg/GB, + * both over 43800 hours. + */ + @Test + void embodiedCoefficientsDerivedFromDefaults() { + assertEquals(30_000d / (15_000d * 43_800d), storage.hdd_embodied_g_per_gb_hour, 1e-12); + assertEquals(55d / 43_800d, storage.ssd_embodied_g_per_gb_hour, 1e-12); + } + + /** The drive size and life are assumptions, so they have to be overridable. */ + @Test + void embodiedCoefficientsHonourConfig() { + Storage configured = new Storage(); + configured.init(Map.of( + "hdd_embodied_kg_per_drive", 28.7d, + "hdd_capacity_gb", 22_000d, + "ssd_embodied_kg_per_gb", 0.052d, + "storage_lifetime_hours", 49_932d)); + assertEquals(28_700d / (22_000d * 49_932d), configured.hdd_embodied_g_per_gb_hour, 1e-12); + assertEquals(52d / 49_932d, configured.ssd_embodied_g_per_gb_hour, 1e-12); + } + + /** JSON configs routinely carry whole numbers as integers rather than doubles. */ + @Test + void embodiedConfigAcceptsIntegerLiterals() { + Storage configured = new Storage(); + configured.init(Map.of("hdd_embodied_kg_per_drive", 30, "hdd_capacity_gb", 15000)); + assertEquals(30_000d / (15_000d * 43_800d), configured.hdd_embodied_g_per_gb_hour, 1e-12); } @ParameterizedTest @@ -126,6 +175,8 @@ void processS3TimedStorage() { int replication = focusStorage.getReplicationFactor("AmazonS3", "TimedStorage-ByteHrs"); double expected = gb_hours * focusStorage.hdd_gb_coefficient * replication / 1000; assertEquals(expected, (Double) enriched.get(ENERGY_USED), 0.0001); + assertEquals(gb_hours * focusStorage.hdd_embodied_g_per_gb_hour * replication, + (Double) enriched.get(EMBODIED_EMISSIONS), 0.0001); } @Test diff --git a/src/test/java/com/digitalpebble/spruce/modules/ccf/azure/StorageTest.java b/src/test/java/com/digitalpebble/spruce/modules/ccf/azure/StorageTest.java index 7fe111b0..943eb8e8 100644 --- a/src/test/java/com/digitalpebble/spruce/modules/ccf/azure/StorageTest.java +++ b/src/test/java/com/digitalpebble/spruce/modules/ccf/azure/StorageTest.java @@ -21,6 +21,7 @@ import java.util.Map; import java.util.stream.Stream; +import static com.digitalpebble.spruce.SpruceColumn.EMBODIED_EMISSIONS; import static com.digitalpebble.spruce.SpruceColumn.ENERGY_USED; import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; @@ -83,7 +84,9 @@ void processStorageRows(String meterCategory, String meterSubCategory, String me double quantity, double gbHours, int replication, boolean isHDD) { Map enriched = enrich(row(meterCategory, meterSubCategory, meterName, unit, quantity)); double expected = expected(gbHours, replication, isHDD); - assertEquals(expected, (Double) enriched.get(ENERGY_USED), 0.0001); + assertEquals(expected, (Double) enriched.get(ENERGY_USED), 1e-12); + assertEquals(expectedEmbodied(gbHours, replication, isHDD), + (Double) enriched.get(EMBODIED_EMISSIONS), 1e-12); } @ParameterizedTest @@ -92,6 +95,31 @@ void processIgnoredRows(String meterCategory, String meterSubCategory, String me Double quantity) { Map enriched = enrich(row(meterCategory, meterSubCategory, meterName, unit, quantity)); assertFalse(enriched.containsKey(ENERGY_USED)); + assertFalse(enriched.containsKey(EMBODIED_EMISSIONS)); + } + + /** + * HDD embodied carbon is a constant per drive spread over the drive's capacity and life, SSD + * a rate per GB spread over its life. Defaults match the AWS module: 30 kg per 15 TB drive + * and 0.055 kg/GB, both over 43800 hours. + */ + @Test + void embodiedCoefficientsDerivedFromDefaults() { + assertEquals(30_000d / (15_000d * 43_800d), storage.hdd_embodied_g_per_gb_hour, 1e-12); + assertEquals(55d / 43_800d, storage.ssd_embodied_g_per_gb_hour, 1e-12); + } + + /** The drive size and life are assumptions, so they have to be overridable. */ + @Test + void embodiedCoefficientsHonourConfig() { + Storage configured = new Storage(); + configured.init(Map.of( + "hdd_embodied_kg_per_drive", 28.7d, + "hdd_capacity_gb", 22_000d, + "ssd_embodied_kg_per_gb", 0.052d, + "storage_lifetime_hours", 49_932d)); + assertEquals(28_700d / (22_000d * 49_932d), configured.hdd_embodied_g_per_gb_hour, 1e-12); + assertEquals(52d / 49_932d, configured.ssd_embodied_g_per_gb_hour, 1e-12); } private Row row(String meterCategory, String meterSubCategory, String meterName, String unit, Double quantity) { @@ -110,6 +138,11 @@ private double expected(double gbHours, int replication, boolean isHDD) { return gbHours / 1000 * coefficient * replication; } + private double expectedEmbodied(double gbHours, int replication, boolean isHDD) { + double coefficient = isHDD ? storage.hdd_embodied_g_per_gb_hour : storage.ssd_embodied_g_per_gb_hour; + return gbHours * coefficient * replication; + } + /** * The FOCUS binding reads the same values from the FOCUS column names; the estimation logic * is shared with the tests above. @@ -144,13 +177,16 @@ void processStorageRow() { Map enriched = enrich(row("Storage", "Tables", "LRS Data Stored", "1 GB/Month", 10d)); double gbHours = Utils.Conversions.GBMonthsToGBHours(10d); double expected = gbHours / 1000 * focusStorage.ssd_gb_coefficient * 3; - assertEquals(expected, (Double) enriched.get(ENERGY_USED), 0.0001); + assertEquals(expected, (Double) enriched.get(ENERGY_USED), 1e-12); + assertEquals(gbHours * focusStorage.ssd_embodied_g_per_gb_hour * 3, + (Double) enriched.get(EMBODIED_EMISSIONS), 1e-12); } @Test void processRowWithoutQuantity() { Map enriched = enrich(row("Storage", "Tables", "LRS Data Stored", "1 GB/Month", null)); assertFalse(enriched.containsKey(ENERGY_USED)); + assertFalse(enriched.containsKey(EMBODIED_EMISSIONS)); } private Row row(String meterCategory, String meterSubCategory, String meterName, String unit, Double quantity) {