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) {