diff --git a/docs/src/modules.md b/docs/src/modules.md index da463d18..9de59622 100644 --- a/docs/src/modules.md +++ b/docs/src/modules.md @@ -243,27 +243,41 @@ factors that the impact modules in the next stage multiply the energy estimates ### PWUE Loads both **Power Usage Effectiveness (PUE)** and **Water Usage Effectiveness (WUE)** -factors from a CSV resource file bundled per provider: [`aws-pue-wue.csv`](https://github.com/DigitalPebble/spruce/blob/main/src/main/resources/aws-pue-wue.csv) uses the 2024 data -[published by AWS](https://sustainability.aboutamazon.com/aws-wue-pue.csv), and +factors from a CSV resource file bundled per provider: [`aws-pue-wue.csv`](https://github.com/DigitalPebble/spruce/blob/main/src/main/resources/aws-pue-wue.csv) carries the 2022–2025 figures +[published by AWS](https://sustainability.aboutamazon.com/products-services/aws-cloud), and [`azure-pue-wue.csv`](https://github.com/DigitalPebble/spruce/blob/main/src/main/resources/azure-pue-wue.csv) is sourced from [Microsoft's data centre sustainability pages](https://datacenters.microsoft.com/sustainability/efficiency/). +These factors are published per year and move noticeably from one year to the next, so they +are keyed by region **and** year rather than applied as a blanket value. The year is that of +the line item's usage date, read from whichever of `ChargePeriodStart`, +`line_item_usage_start_date`, `Date` or `BILLING_PERIOD` the report carries. + The lookup logic follows this priority: 1. Exact region match (e.g. `us-east-1`) -2. Regex pattern match (e.g. `us-.+`) -3. Default configured value (fallback to 1.15 for PUE, null for WUE) +2. Regex pattern match (e.g. `eu-.+`), i.e. the geography-level average +3. The provider-wide `GLOBAL` average, where the CSV has one +4. Default configured value (fallback to 1.15 for PUE, null for WUE) + +Within a tier the entry for the usage year is used; when that year is not covered — a +region AWS started reporting on recently, a WUE only published from 2024 onwards, or a row +with no usable date — the closest year available for that region is used instead. + +!!! note "Azure figures are not dated" + Microsoft does not break its PUE and WUE down by year, so the rows in + `azure-pue-wue.csv` leave the year empty and apply to every year. | | | |---|---| | **Class** | `com.digitalpebble.spruce.modules.PWUE` | -| **Reads** | `region` | +| **Reads** | `region`, the usage date of the line item | | **Writes** | `power_usage_effectiveness`, `water_usage_effectiveness` | **Configuration**: | Key | Default | Description | |---|---|---| -| `default` | 1.15 | PUE used when a region matches neither an exact entry nor a pattern | +| `default` | 1.15 | PUE used when a region matches no entry at any tier, global average included | ### AverageCarbonIntensity diff --git a/src/main/java/com/digitalpebble/spruce/CURColumn.java b/src/main/java/com/digitalpebble/spruce/CURColumn.java index b76700b9..e58cadee 100644 --- a/src/main/java/com/digitalpebble/spruce/CURColumn.java +++ b/src/main/java/com/digitalpebble/spruce/CURColumn.java @@ -17,6 +17,8 @@ public class CURColumn extends RowColumn { public static CURColumn LINE_ITEM_PRODUCT_CODE = new CURColumn("line_item_product_code", StringType); public static CURColumn LINE_ITEM_TYPE = new CURColumn("line_item_line_item_type", StringType); public static CURColumn LINE_ITEM_USAGE_TYPE = new CURColumn("line_item_usage_type", StringType); + public static CURColumn LINE_ITEM_USAGE_START_DATE = new CURColumn("line_item_usage_start_date", StringType); + public static CURColumn LINE_ITEM_USAGE_END_DATE = new CURColumn("line_item_usage_end_date", StringType); public static CURColumn PRICING_UNIT= new CURColumn("pricing_unit", StringType); public static CURColumn PRODUCT = new CURColumn("product", MapType.apply(StringType,StringType)); public static CURColumn PRODUCT_INSTANCE_TYPE = new CURColumn("product_instance_type", StringType); diff --git a/src/main/java/com/digitalpebble/spruce/RowColumn.java b/src/main/java/com/digitalpebble/spruce/RowColumn.java index 018772aa..7d6b41ca 100644 --- a/src/main/java/com/digitalpebble/spruce/RowColumn.java +++ b/src/main/java/com/digitalpebble/spruce/RowColumn.java @@ -6,9 +6,13 @@ import org.apache.spark.sql.types.DataType; import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; /** * Abstract base class for native column types that work with Spark Row objects. @@ -75,6 +79,44 @@ public LocalDate getDate(Row r) { return null; } + /** Matches a four-digit year not embedded in a longer number, e.g. in 2025-01-01T00:00:00Z, + * 01/15/2025 or the yyyy-MM of a billing period. */ + private static final Pattern YEAR = Pattern.compile("(? * This module centralizes the loading of PUE and WUE values that were previously * loaded separately by the PUE and Water modules. The values are stored in * columns for use by downstream modules. *

+ * Providers publish these factors per year and they move noticeably from one year to the + * next, so the figures are keyed by region and year rather than applied as a blanket + * value. The year comes from the usage date of the line item; when the report carries no + * usable date the most recent figures are used. + *

* The lookup logic follows this priority: *

    *
  1. Exact region match (e.g., "us-east-1")
  2. - *
  3. Regex pattern match (e.g., "us-.+")
  4. + *
  5. Regex pattern match on the region id (e.g., "eu-.+"), i.e. the geography average
  6. + *
  7. The provider-wide "GLOBAL" entry, where the CSV has one
  8. *
  9. Default configured value (fallback to 1.15 for PUE, null for WUE)
  10. *
+ * Within a tier, the entry for the usage year is used; if that year is not covered, the + * closest year available for that region is used instead. **/ public class PWUE implements EnrichmentModule { private double defaultPueValue = 1.15; private static final String DEFAULT_CSV_RESOURCE_PATH = "aws-pue-wue.csv"; - // PUE lookup maps - private final Map pueExactMatches = new HashMap<>(); - private final Map pueRegexMatches = new HashMap<>(); + /** RegionID marking the provider-wide average, used when no region entry matches. */ + private static final String GLOBAL_KEY = "GLOBAL"; + + /** Year given to entries whose CSV row leaves the year empty, i.e. figures that are not + * broken down by year. Below any real year, so it is only picked when nothing else is. */ + private static final int UNDATED = 0; + + /** Year used for rows with no usable usage date: yields the most recent figures. */ + private static final int LATEST = Integer.MAX_VALUE; + + /** + * Columns a usage date can be read from, in the order they are probed. All are optional: a + * report only carries the ones its provider and format define, and the FOCUS columns exist + * but are still null in native reports, where the FOCUS bridge module fills them in later. + */ + private static final RowColumn[] DATE_COLUMNS = { + FOCUSColumn.CHARGE_PERIOD_START, + CURColumn.LINE_ITEM_USAGE_START_DATE, + AzureColumn.DATE, + CURColumn.BILLING_PERIOD + }; + + /** PUE and WUE by year for one CSV key — a region id, or a regex over region ids. */ + private static class Factors implements Serializable { + /** null when the key is an exact region id rather than a pattern. */ + final Pattern pattern; + final NavigableMap pue = new TreeMap<>(); + final NavigableMap wue = new TreeMap<>(); - // WUE lookup maps - private final Map wueExactMatches = new HashMap<>(); - private final Map wueRegexMatches = new HashMap<>(); + Factors(Pattern pattern) { + this.pattern = pattern; + } + } + + private final Map exactMatches = new HashMap<>(); + /** A list rather than a map so the patterns are tried in the order the CSV lists them. */ + private final List regexMatches = new ArrayList<>(); + private Factors globalMatch; @Override public void init(Map params) { @@ -68,44 +115,28 @@ public void init(Map params, Provider provider) { List rows = Utils.loadCSV(csvResourcePath); for (String[] parts : rows) { - // We need at least 3 columns: Geography, RegionID, PUE (WUE is optional) - if (parts.length >= 3) { - String key = parts[1].trim(); - String pueStr = parts[2].trim(); - String wueStr = parts.length >= 4 ? parts[3].trim() : ""; - - // Process PUE value - if (!pueStr.isEmpty()) { - try { - double pueValue = Double.parseDouble(pueStr); - - // Only treat as regex if it contains regex metacharacters - if (key.contains(".") || key.contains("+") || key.contains("*")) { - pueRegexMatches.put(Pattern.compile(key), pueValue); - } else { - pueExactMatches.put(key, pueValue); - } - } catch (NumberFormatException e) { - System.err.println("Invalid PUE format in CSV for key: " + key); - } - } + // We need at least 4 columns: Geography, RegionID, Year, PUE (WUE is optional) + if (parts.length < 4) { + continue; + } + String key = parts[1].trim(); + String yearStr = parts[2].trim(); + String pueStr = parts[3].trim(); + String wueStr = parts.length >= 5 ? parts[4].trim() : ""; - // Process WUE value - if (!wueStr.isEmpty()) { - try { - double wueValue = Double.parseDouble(wueStr); - - // Only treat as regex if it contains regex metacharacters - if (key.contains(".") || key.contains("+") || key.contains("*")) { - wueRegexMatches.put(Pattern.compile(key), wueValue); - } else { - wueExactMatches.put(key, wueValue); - } - } catch (NumberFormatException e) { - System.err.println("Invalid WUE format in CSV for key: " + key); - } + int year = UNDATED; + if (!yearStr.isEmpty()) { + try { + year = Integer.parseInt(yearStr); + } catch (NumberFormatException e) { + System.err.println("Invalid year format in CSV for key: " + key); + continue; } } + + Factors factors = factorsFor(key); + store(factors.pue, key, year, pueStr, "PUE"); + store(factors.wue, key, year, wueStr, "WUE"); } if (params.containsKey("default")) { @@ -122,6 +153,40 @@ public void init(Map params, Provider provider) { } } + /** Returns the entry holding the figures for this CSV key, creating it on first sight. */ + private Factors factorsFor(String key) { + if (GLOBAL_KEY.equals(key)) { + if (globalMatch == null) { + globalMatch = new Factors(null); + } + return globalMatch; + } + // Only treat as regex if it contains regex metacharacters + if (key.contains(".") || key.contains("+") || key.contains("*")) { + for (Factors existing : regexMatches) { + if (existing.pattern.pattern().equals(key)) { + return existing; + } + } + Factors factors = new Factors(Pattern.compile(key)); + regexMatches.add(factors); + return factors; + } + return exactMatches.computeIfAbsent(key, k -> new Factors(null)); + } + + private static void store(NavigableMap byYear, String key, int year, + String value, String label) { + if (value.isEmpty()) { + return; + } + try { + byYear.put(year, Double.parseDouble(value)); + } catch (NumberFormatException e) { + System.err.println("Invalid " + label + " format in CSV for key: " + key); + } + } + @Override public Column[] columnsNeeded() { return new Column[]{REGION}; @@ -135,51 +200,58 @@ public Column[] columnsAdded() { @Override public void enrich(Row row, Map enrichedValues) { String region = REGION.getString(enrichedValues); + int year = usageYear(row); // Get and store PUE value - double pueValue = getPueForRegion(region); - enrichedValues.put(SpruceColumn.PUE, pueValue); + Double pueValue = lookup(region, year, false); + enrichedValues.put(SpruceColumn.PUE, pueValue != null ? pueValue : defaultPueValue); // Get and store WUE value - Double wueValue = getWueForRegion(region); + Double wueValue = lookup(region, year, true); if (wueValue != null) { enrichedValues.put(SpruceColumn.WUE, wueValue); } } - private double getPueForRegion(String region) { - if (region == null || region.isEmpty()) { - return defaultPueValue; - } - - if (pueExactMatches.containsKey(region)) { - return pueExactMatches.get(region); + /** Returns the year the line item was incurred in, or {@link #LATEST} if it has no date. */ + private static int usageYear(Row row) { + for (RowColumn column : DATE_COLUMNS) { + Integer year = column.getYear(row); + if (year != null) { + return year; + } } + return LATEST; + } - for (Map.Entry entry : pueRegexMatches.entrySet()) { - if (entry.getKey().matcher(region).matches()) { - return entry.getValue(); + private Double lookup(String region, int year, boolean water) { + if (region != null && !region.isEmpty()) { + Double value = valueFor(exactMatches.get(region), year, water); + if (value != null) { + return value; + } + for (Factors factors : regexMatches) { + if (factors.pattern.matcher(region).matches()) { + value = valueFor(factors, year, water); + if (value != null) { + return value; + } + } } } - - return defaultPueValue; + return valueFor(globalMatch, year, water); } - private Double getWueForRegion(String region) { - if (region == null || region.isEmpty()) { + /** Returns the figure published for that year, or the closest year available. */ + private static Double valueFor(Factors factors, int year, boolean water) { + if (factors == null) { return null; } - - if (wueExactMatches.containsKey(region)) { - return wueExactMatches.get(region); + NavigableMap byYear = water ? factors.wue : factors.pue; + Map.Entry entry = byYear.floorEntry(year); + if (entry == null) { + entry = byYear.firstEntry(); } - - for (Map.Entry entry : wueRegexMatches.entrySet()) { - if (entry.getKey().matcher(region).matches()) { - return entry.getValue(); - } - } - - return null; + return entry == null ? null : entry.getValue(); } -} \ No newline at end of file +} diff --git a/src/main/java/com/digitalpebble/spruce/modules/aws/FOCUSColumns.java b/src/main/java/com/digitalpebble/spruce/modules/aws/FOCUSColumns.java index 498d5731..f0c79c8d 100644 --- a/src/main/java/com/digitalpebble/spruce/modules/aws/FOCUSColumns.java +++ b/src/main/java/com/digitalpebble/spruce/modules/aws/FOCUSColumns.java @@ -16,6 +16,8 @@ import static com.digitalpebble.spruce.CURColumn.LINE_ITEM_OPERATION; import static com.digitalpebble.spruce.CURColumn.LINE_ITEM_PRODUCT_CODE; import static com.digitalpebble.spruce.CURColumn.LINE_ITEM_TYPE; +import static com.digitalpebble.spruce.CURColumn.LINE_ITEM_USAGE_END_DATE; +import static com.digitalpebble.spruce.CURColumn.LINE_ITEM_USAGE_START_DATE; import static com.digitalpebble.spruce.CURColumn.LINE_ITEM_USAGE_TYPE; import static com.digitalpebble.spruce.CURColumn.PRODUCT_FROM_REGION_CODE; import static com.digitalpebble.spruce.CURColumn.PRODUCT_REGION_CODE; @@ -50,8 +52,6 @@ public class FOCUSColumns implements EnrichmentModule { public static final CURColumn LINE_ITEM_UNBLENDED_COST = new CURColumn("line_item_unblended_cost", DataTypes.DoubleType); public static final CURColumn LINE_ITEM_USAGE_ACCOUNT_ID = new CURColumn("line_item_usage_account_id", DataTypes.StringType); - public static final CURColumn LINE_ITEM_USAGE_START_DATE = new CURColumn("line_item_usage_start_date", DataTypes.StringType); - public static final CURColumn LINE_ITEM_USAGE_END_DATE = new CURColumn("line_item_usage_end_date", DataTypes.StringType); public static final CURColumn RESOURCE_TAGS = new CURColumn("resource_tags", DataTypes.createMapType(DataTypes.StringType, DataTypes.StringType)); public static final CURColumn PRICING_PUBLIC_ON_DEMAND_COST = new CURColumn("pricing_public_on_demand_cost", DataTypes.DoubleType); diff --git a/src/main/resources/aws-pue-wue.csv b/src/main/resources/aws-pue-wue.csv index 6a257006..43388b27 100644 --- a/src/main/resources/aws-pue-wue.csv +++ b/src/main/resources/aws-pue-wue.csv @@ -1,30 +1,126 @@ -# Geography,RegionID,PUE,WUE -Europe,eu-.+,1.11,0.04 -Midde East,me-.+,1.3, -North America,us-.+,1.14,0.13 -North America,mx-.+,1.14, -Asia Pacific (excl. China),ap-.+,1.27,0.98 -China,cn-.+,1.25, -Africa (Cape Town),af-south-1,1.24, -Asia-Pacific (Tokyo),ap-northeast-1,1.27,0.91 -Asia-Pacific (Mumbai),ap-south-1,1.42, -Asia-Pacific (Hyderabad),ap-south-2,1.46, -Asia-Pacific (Singapore),ap-southeast-1,1.32,1.68 -Asia-Pacific (Sydney),ap-southeast-2,,0.12 -Asia-Pacific (Jakarta),ap-southeast-3,1.4,2.75 -Asia-Pacific (Melbourne),ap-southeast-4,,0.02 -Canada (Central),ca-central-1,1.19,0.04 -Canada (West),ca-west-1,1.17,0.08 -China (Ningxia),cn-northwest-1,1.25, -Europe (Frankfurt),eu-central-1,1.35,0.01 -Europe (Stockholm),eu-north-1,1.1,0.02 -Europe (Spain),eu-south-2,1.09,0.24 -Europe (Ireland),eu-west-1,1.11,0.03 -Middle East (UAE),me-central-1,1.27, -Middle East (Bahrain),me-south-1,1.33, -South America (Sao Paulo),sa-east-1,1.17,0.23 -U.S. East (Northern Virginia),us-east-1,1.15,0.12 -U.S. East (Ohio),us-east-2,1.13,0.1 -U.S. West (Northern California),us-west-1,1.18,0.51 -U.S. West (Oregon),us-west-2,1.12,0.16 -Israel,il-central-1,1.3, +# PUE and WUE published by AWS, one row per region and year. +# Source: https://sustainability.aboutamazon.com/products-services/aws-cloud (retrieved 2026-08-26) +# The downloadable https://sustainability.aboutamazon.com/aws-wue-pue.csv only goes up to +# 2024, so the figures below are taken from the tables on the page itself. +# +# Lookup order: exact RegionID, then the geography-level average whose regex matches the +# region id, then the GLOBAL average. Within a tier the row for the usage year is used, +# falling back to the closest year available. An empty PUE or WUE means AWS published no +# figure for that region and year. The GLOBAL WUE for 2022 and 2023 comes from the +# downloadable CSV, which is the only place AWS published it. +# +# Geography,RegionID,Year,PUE,WUE +Global,GLOBAL,2022,1.15,0.19 +Global,GLOBAL,2023,1.15,0.18 +Global,GLOBAL,2024,1.15,0.15 +Global,GLOBAL,2025,1.14,0.12 +Europe,eu-.+,2022,1.11, +Europe,eu-.+,2023,1.11, +Europe,eu-.+,2024,1.11,0.04 +Europe,eu-.+,2025,1.11,0.05 +Middle East,(me|il)-.+,2022,1.38, +Middle East,(me|il)-.+,2023,1.33, +Middle East,(me|il)-.+,2024,1.31, +Middle East,(me|il)-.+,2025,1.29, +Africa,af-.+,2022,1.36, +Africa,af-.+,2023,1.24, +Africa,af-.+,2024,1.24, +Africa,af-.+,2025,1.19, +North America,(us|ca|mx)-.+,2022,1.14, +North America,(us|ca|mx)-.+,2023,1.14, +North America,(us|ca|mx)-.+,2024,1.14,0.13 +North America,(us|ca|mx)-.+,2025,1.14,0.08 +Central/South America,sa-.+,2023,1.18, +Central/South America,sa-.+,2024,1.17,0.23 +Central/South America,sa-.+,2025,1.14,0.09 +Asia Pacific (excl. China),ap-.+,2022,1.26, +Asia Pacific (excl. China),ap-.+,2023,1.28, +Asia Pacific (excl. China),ap-.+,2024,1.27,0.98 +Asia Pacific (excl. China),ap-.+,2025,1.25,1.1 +China,cn-.+,2022,1.28, +China,cn-.+,2023,1.26, +China,cn-.+,2024,1.25, +China,cn-.+,2025,1.25, +Europe (Frankfurt),eu-central-1,2022,1.32, +Europe (Frankfurt),eu-central-1,2023,1.33, +Europe (Frankfurt),eu-central-1,2024,1.35,0.01 +Europe (Frankfurt),eu-central-1,2025,1.24,0.17 +Europe (Ireland),eu-west-1,2022,1.1, +Europe (Ireland),eu-west-1,2023,1.1, +Europe (Ireland),eu-west-1,2024,1.11,0.03 +Europe (Ireland),eu-west-1,2025,1.1,0.02 +Europe (London),eu-west-2,2025,1.23,0.14 +Europe (Paris),eu-west-3,2025,1.35, +Europe (Spain),eu-south-2,2023,1.11, +Europe (Spain),eu-south-2,2024,1.07,0.24 +Europe (Spain),eu-south-2,2025,1.06,0.12 +Europe (Stockholm),eu-north-1,2022,1.12, +Europe (Stockholm),eu-north-1,2023,1.12, +Europe (Stockholm),eu-north-1,2024,1.1,0.02 +Europe (Stockholm),eu-north-1,2025,1.09,0.02 +Middle East (Bahrain),me-south-1,2022,1.38, +Middle East (Bahrain),me-south-1,2023,1.32, +Middle East (Bahrain),me-south-1,2024,1.33, +Middle East (Bahrain),me-south-1,2025,1.33, +Middle East (Tel Aviv),il-central-1,2025,1.27, +Middle East (UAE),me-central-1,2023,1.36, +Middle East (UAE),me-central-1,2024,1.27, +Middle East (UAE),me-central-1,2025,1.25, +Africa (Cape Town),af-south-1,2022,1.36, +Africa (Cape Town),af-south-1,2023,1.24, +Africa (Cape Town),af-south-1,2024,1.24, +Africa (Cape Town),af-south-1,2025,1.19, +Asia-Pacific (Bangkok),ap-southeast-7,2025,1.38, +Asia-Pacific (Hyderabad),ap-south-2,2023,1.5, +Asia-Pacific (Hyderabad),ap-south-2,2024,1.46, +Asia-Pacific (Hyderabad),ap-south-2,2025,1.43, +Asia-Pacific (Jakarta),ap-southeast-3,2022,1.39, +Asia-Pacific (Jakarta),ap-southeast-3,2023,1.35, +Asia-Pacific (Jakarta),ap-southeast-3,2024,1.4,2.75 +Asia-Pacific (Jakarta),ap-southeast-3,2025,1.29,2.85 +Asia-Pacific (Melbourne),ap-southeast-4,2024,,0.02 +Asia-Pacific (Melbourne),ap-southeast-4,2025,1.07,0.06 +Asia-Pacific (Mumbai),ap-south-1,2022,1.43, +Asia-Pacific (Mumbai),ap-south-1,2023,1.44, +Asia-Pacific (Mumbai),ap-south-1,2024,1.42, +Asia-Pacific (Mumbai),ap-south-1,2025,1.4, +Asia-Pacific (Osaka),ap-northeast-3,2025,1.46, +Asia-Pacific (Singapore),ap-southeast-1,2022,1.33, +Asia-Pacific (Singapore),ap-southeast-1,2023,1.3, +Asia-Pacific (Singapore),ap-southeast-1,2024,1.32,1.68 +Asia-Pacific (Singapore),ap-southeast-1,2025,1.3,1.57 +Asia-Pacific (Sydney),ap-southeast-2,2024,,0.12 +Asia-Pacific (Sydney),ap-southeast-2,2025,1.14,0.1 +Asia-Pacific (Tokyo),ap-northeast-1,2022,1.3, +Asia-Pacific (Tokyo),ap-northeast-1,2023,1.32, +Asia-Pacific (Tokyo),ap-northeast-1,2024,1.27,0.91 +Asia-Pacific (Tokyo),ap-northeast-1,2025,1.25,1.22 +China (Ningxia),cn-northwest-1,2022,1.28, +China (Ningxia),cn-northwest-1,2023,1.26, +China (Ningxia),cn-northwest-1,2024,1.25, +China (Ningxia),cn-northwest-1,2025,1.25, +South America (Sao Paulo),sa-east-1,2023,1.18, +South America (Sao Paulo),sa-east-1,2024,1.17,0.23 +South America (Sao Paulo),sa-east-1,2025,1.14,0.09 +U.S. East (Northern Virginia),us-east-1,2022,1.16, +U.S. East (Northern Virginia),us-east-1,2023,1.15, +U.S. East (Northern Virginia),us-east-1,2024,1.15,0.12 +U.S. East (Northern Virginia),us-east-1,2025,1.15,0.06 +U.S. East (Ohio),us-east-2,2022,1.12, +U.S. East (Ohio),us-east-2,2023,1.12, +U.S. East (Ohio),us-east-2,2024,1.13,0.1 +U.S. East (Ohio),us-east-2,2025,1.12,0.06 +U.S. West (Northern California),us-west-1,2022,1.17, +U.S. West (Northern California),us-west-1,2023,1.17, +U.S. West (Northern California),us-west-1,2024,1.18,0.51 +U.S. West (Northern California),us-west-1,2025,1.18,0.39 +U.S. West (Oregon),us-west-2,2022,1.13, +U.S. West (Oregon),us-west-2,2023,1.13, +U.S. West (Oregon),us-west-2,2024,1.12,0.16 +U.S. West (Oregon),us-west-2,2025,1.12,0.12 +Canada (Central),ca-central-1,2022,1.26, +Canada (Central),ca-central-1,2023,1.22, +Canada (Central),ca-central-1,2024,1.19,0.04 +Canada (Central),ca-central-1,2025,1.19,0.06 +Canada (West),ca-west-1,2024,1.17,0.08 +Canada (West),ca-west-1,2025,1.13,0.04 diff --git a/src/main/resources/azure-pue-wue.csv b/src/main/resources/azure-pue-wue.csv index 35d41fb4..e9806bbb 100644 --- a/src/main/resources/azure-pue-wue.csv +++ b/src/main/resources/azure-pue-wue.csv @@ -1,78 +1,80 @@ # https://datacenters.microsoft.com/sustainability/efficiency/ -# Name,RegionID,PUE,WUE -Australia Central (Canberra),australiacentral,1.28,0.25 -Australia Central 2 (Canberra),australiacentral2,1.28,0.25 -Australia Southeast (Melbourne),australiasoutheast,1.28,0.25 -Australia East (Sydney),australiaeast,1.28,0.25 -Austria East (Vienna),austriaeast,1.16,0.03 -Belgium Central,belgiumcentral,1.16,0.03 -Brazil Southeast (Rio),brazilsoutheast,1.16,0.34 -Brazil South (São Paulo),brazilsouth,1.16,0.34 -Canada East (Quebec City),canadaeast,1.16,0.34 -Canada Central (Toronto),canadacentral,1.16,0.34 -Chile Central (Santiago),chilecentral,1.16,0.34 -China North (Beijing),chinanorth,1.28,0.25 -China North 2 (Beijing),chinanorth2,1.28,0.25 -China North 3 (Beijing),chinanorth3,1.28,0.25 -East Asia (Hong Kong),eastasia,1.28,0.25 -China East 3 (Jiangsu),chinaeast3,1.28,0.25 -China East (Shanghai),chinaeast,1.28,0.25 -China East 2 (Shanghai),chinaeast2,1.28,0.25 -Denmark East (Copenhagen),denmarkeast,1.16,0.03 -Finland (Helsinki),finlandcentral,1.16,0.03 -France South (Marseille),francesouth,1.16,0.03 -France Central (Paris),francecentral,1.16,0.03 -Germany North (Berlin),germanynorth,1.16,0.03 -Germany West Central (Frankfurt),germanywestcentral,1.16,0.03 -Greece Central (Athens),greececentral,1.16,0.03 -South India (Chennai),southindia,1.28,0.25 -Southcentral (Hyderabad),indiasouthcentral,1.28,0.25 -West India (Mumbai),westindia,1.28,0.25 -Central India (Pune),centralindia,1.28,0.25 -Indonesia Central (Jakarta),indonesiacentral,1.28,0.25 -North Europe (Dublin),northeurope,1.16,0.03 -Israel Central (Tel Aviv),israelcentral,1.16,0.03 -Italy North (Milan),italynorth,1.16,0.03 -Japan West (Osaka),japanwest,1.28,0.25 -Japan East (Saitama Tokyo),japaneast,1.28,0.25 -Malaysia West (Kuala Lumpur),malaysiawest,1.28,0.25 -Mexico Central (Querétaro),mexicocentral,1.16,0.34 -West Europe (Amsterdam),westeurope,1.16,0.03 -New Zealand North (Auckland),newzealandnorth,1.28,0.25 -Norway East (Oslo),norwayeast,1.16,0.03 -Norway West (Stavanger),norwaywest,1.16,0.03 -Poland Central (Warsaw),polandcentral,1.16,0.03 -Qatar Central (Doha),qatarcentral,1.16,0.03 -Saudi Arabia East,saudiarabiasouthcentral,1.28,0.25 -Southeast Asia (Singapore),southeastasia,1.28,0.25 -South Africa West (Cape Town),southafricawest,1.16,0.03 -South Africa North (Johannesburg),southafricanorth,1.16,0.03 -Korea South (Busan),koreasouth,1.28,0.25 -Korea Central (Seoul),koreacentral,1.28,0.25 -Spain Central (Madrid),spaincentral,1.16,0.03 -Sweden Central (Gävle),swedencentral,1.16,0.03 -Sweden South (Staffanstorp),swedensouth,1.16,0.03 -Switzerland West (Geneva),switzerlandwest,1.16,0.03 -Switzerland North (Zürich),switzerlandnorth,1.16,0.03 -Taiwan North (Taipei),taiwannorth,1.28,0.25 -UAE Central (Abu Dhabi),uaecentral,1.16,0.03 -UAE North (Dubai),uaenorth,1.16,0.03 -UK West (Cardiff),ukwest,1.16,0.03 -UK South (London),uksouth,1.16,0.03 -East US 3 (Atlanta),eastus3,1.16,0.34 -US Gov Texas,usgovtexas,1.16,0.34 -West Central US (Cheyenne),westcentralus,1.16,0.34 -North Central US (Chicago),northcentralus,1.16,0.34 -Central US (Des Moines),centralus,1.16,0.34 -US DoD Central,usdodwest,1.16,0.34 -West US 2 (Quincy),westus2,1.16,0.34 -US Gov Arizona,usgovarizona,1.16,0.34 -West US 3 (Phoenix),westus3,1.16,0.34 -East US 2 (Richmond),eastus2,1.16,0.34 -US DoD East,usdodeast,1.16,0.34 -East US (Ashburn),eastus,1.16,0.34 -US Sec East,usseceast,1.16,0.34 -South Central US (San Antonio),southcentralus,1.16,0.34 -West US (San Francisco),westus,1.16,0.34 -US Sec West,ussecwest,1.16,0.34 -US Gov Virginia ,usgovvirginia,1.16,0.34 +# Microsoft does not break these down by year, so the Year column is left empty, +# meaning the figures apply to every year. +# Name,RegionID,Year,PUE,WUE +Australia Central (Canberra),australiacentral,,1.28,0.25 +Australia Central 2 (Canberra),australiacentral2,,1.28,0.25 +Australia Southeast (Melbourne),australiasoutheast,,1.28,0.25 +Australia East (Sydney),australiaeast,,1.28,0.25 +Austria East (Vienna),austriaeast,,1.16,0.03 +Belgium Central,belgiumcentral,,1.16,0.03 +Brazil Southeast (Rio),brazilsoutheast,,1.16,0.34 +Brazil South (São Paulo),brazilsouth,,1.16,0.34 +Canada East (Quebec City),canadaeast,,1.16,0.34 +Canada Central (Toronto),canadacentral,,1.16,0.34 +Chile Central (Santiago),chilecentral,,1.16,0.34 +China North (Beijing),chinanorth,,1.28,0.25 +China North 2 (Beijing),chinanorth2,,1.28,0.25 +China North 3 (Beijing),chinanorth3,,1.28,0.25 +East Asia (Hong Kong),eastasia,,1.28,0.25 +China East 3 (Jiangsu),chinaeast3,,1.28,0.25 +China East (Shanghai),chinaeast,,1.28,0.25 +China East 2 (Shanghai),chinaeast2,,1.28,0.25 +Denmark East (Copenhagen),denmarkeast,,1.16,0.03 +Finland (Helsinki),finlandcentral,,1.16,0.03 +France South (Marseille),francesouth,,1.16,0.03 +France Central (Paris),francecentral,,1.16,0.03 +Germany North (Berlin),germanynorth,,1.16,0.03 +Germany West Central (Frankfurt),germanywestcentral,,1.16,0.03 +Greece Central (Athens),greececentral,,1.16,0.03 +South India (Chennai),southindia,,1.28,0.25 +Southcentral (Hyderabad),indiasouthcentral,,1.28,0.25 +West India (Mumbai),westindia,,1.28,0.25 +Central India (Pune),centralindia,,1.28,0.25 +Indonesia Central (Jakarta),indonesiacentral,,1.28,0.25 +North Europe (Dublin),northeurope,,1.16,0.03 +Israel Central (Tel Aviv),israelcentral,,1.16,0.03 +Italy North (Milan),italynorth,,1.16,0.03 +Japan West (Osaka),japanwest,,1.28,0.25 +Japan East (Saitama Tokyo),japaneast,,1.28,0.25 +Malaysia West (Kuala Lumpur),malaysiawest,,1.28,0.25 +Mexico Central (Querétaro),mexicocentral,,1.16,0.34 +West Europe (Amsterdam),westeurope,,1.16,0.03 +New Zealand North (Auckland),newzealandnorth,,1.28,0.25 +Norway East (Oslo),norwayeast,,1.16,0.03 +Norway West (Stavanger),norwaywest,,1.16,0.03 +Poland Central (Warsaw),polandcentral,,1.16,0.03 +Qatar Central (Doha),qatarcentral,,1.16,0.03 +Saudi Arabia East,saudiarabiasouthcentral,,1.28,0.25 +Southeast Asia (Singapore),southeastasia,,1.28,0.25 +South Africa West (Cape Town),southafricawest,,1.16,0.03 +South Africa North (Johannesburg),southafricanorth,,1.16,0.03 +Korea South (Busan),koreasouth,,1.28,0.25 +Korea Central (Seoul),koreacentral,,1.28,0.25 +Spain Central (Madrid),spaincentral,,1.16,0.03 +Sweden Central (Gävle),swedencentral,,1.16,0.03 +Sweden South (Staffanstorp),swedensouth,,1.16,0.03 +Switzerland West (Geneva),switzerlandwest,,1.16,0.03 +Switzerland North (Zürich),switzerlandnorth,,1.16,0.03 +Taiwan North (Taipei),taiwannorth,,1.28,0.25 +UAE Central (Abu Dhabi),uaecentral,,1.16,0.03 +UAE North (Dubai),uaenorth,,1.16,0.03 +UK West (Cardiff),ukwest,,1.16,0.03 +UK South (London),uksouth,,1.16,0.03 +East US 3 (Atlanta),eastus3,,1.16,0.34 +US Gov Texas,usgovtexas,,1.16,0.34 +West Central US (Cheyenne),westcentralus,,1.16,0.34 +North Central US (Chicago),northcentralus,,1.16,0.34 +Central US (Des Moines),centralus,,1.16,0.34 +US DoD Central,usdodwest,,1.16,0.34 +West US 2 (Quincy),westus2,,1.16,0.34 +US Gov Arizona,usgovarizona,,1.16,0.34 +West US 3 (Phoenix),westus3,,1.16,0.34 +East US 2 (Richmond),eastus2,,1.16,0.34 +US DoD East,usdodeast,,1.16,0.34 +East US (Ashburn),eastus,,1.16,0.34 +US Sec East,usseceast,,1.16,0.34 +South Central US (San Antonio),southcentralus,,1.16,0.34 +West US (San Francisco),westus,,1.16,0.34 +US Sec West,ussecwest,,1.16,0.34 +US Gov Virginia ,usgovvirginia,,1.16,0.34 diff --git a/src/test/java/com/digitalpebble/spruce/RowColumnTest.java b/src/test/java/com/digitalpebble/spruce/RowColumnTest.java new file mode 100644 index 00000000..e0c276a1 --- /dev/null +++ b/src/test/java/com/digitalpebble/spruce/RowColumnTest.java @@ -0,0 +1,52 @@ +// SPDX-License-Identifier: Apache-2.0 + +package com.digitalpebble.spruce; + +import org.apache.spark.sql.Row; +import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.Metadata; +import org.apache.spark.sql.types.StructField; +import org.apache.spark.sql.types.StructType; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +class RowColumnTest { + + private static final String FIELD = "usage_date"; + private static final StructType SCHEMA = new StructType(new StructField[]{ + new StructField(FIELD, DataTypes.StringType, true, Metadata.empty())}); + + private static final CURColumn COLUMN = new CURColumn(FIELD, DataTypes.StringType); + + private static Row row(Object value) { + return new GenericRowWithSchema(new Object[]{value}, SCHEMA); + } + + @Test + void readsTheYearFromTheRepresentationsTheReportsUse() { + // strings, as CSV exports carry them + assertEquals(2025, COLUMN.getYear(row("2025-01-01T00:00:00Z"))); + assertEquals(2024, COLUMN.getYear(row("2024-12-31"))); + assertEquals(2023, COLUMN.getYear(row("12/31/2023"))); + // the yyyy-MM of a billing period + assertEquals(2022, COLUMN.getYear(row("2022-07"))); + + // date and timestamp types, as Parquet reports carry them + assertEquals(2025, COLUMN.getYear(row(java.sql.Timestamp.valueOf("2025-06-15 12:00:00")))); + assertEquals(2025, COLUMN.getYear(row(java.sql.Date.valueOf("2025-06-15")))); + assertEquals(2025, COLUMN.getYear(row(java.time.LocalDate.of(2025, 6, 15)))); + assertEquals(2025, COLUMN.getYear(row(java.time.LocalDateTime.of(2025, 6, 15, 12, 0)))); + assertEquals(2025, COLUMN.getYear(row(java.time.Instant.parse("2025-06-15T12:00:00Z")))); + } + + @Test + void returnsNullWhenThereIsNoYearToRead() { + assertNull(COLUMN.getYear(row(null))); + assertNull(COLUMN.getYear(row("not a date"))); + // a column the report does not carry + assertNull(new CURColumn("absent", DataTypes.StringType).getYear(row("2025-01-01"))); + } +} diff --git a/src/test/java/com/digitalpebble/spruce/modules/PWUETest.java b/src/test/java/com/digitalpebble/spruce/modules/PWUETest.java index 14b85c67..3912ff82 100644 --- a/src/test/java/com/digitalpebble/spruce/modules/PWUETest.java +++ b/src/test/java/com/digitalpebble/spruce/modules/PWUETest.java @@ -2,11 +2,13 @@ package com.digitalpebble.spruce.modules; +import com.digitalpebble.spruce.CURColumn; import com.digitalpebble.spruce.Column; import com.digitalpebble.spruce.Provider; -import com.digitalpebble.spruce.Utils; import org.apache.spark.sql.Row; import org.apache.spark.sql.catalyst.expressions.GenericRowWithSchema; +import org.apache.spark.sql.types.DataTypes; +import org.apache.spark.sql.types.StructField; import org.apache.spark.sql.types.StructType; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -20,27 +22,40 @@ class PWUETest { private PWUE pwue; - private StructType schema; + + /** Only the usage date is read from the row itself; the region comes from the enriched map. */ + private static final StructType SCHEMA = new StructType(new StructField[]{ + StructField.apply(CURColumn.LINE_ITEM_USAGE_START_DATE.getLabel(), + DataTypes.StringType, true, null)}); @BeforeEach void setUp() { pwue = new PWUE(); pwue.init(new HashMap<>(), Provider.AWS); - schema = Utils.getSchema(pwue); } - private Row emptyRow() { - return new GenericRowWithSchema(new Object[schema.length()], schema); + /** A row with no usage date, so the most recent figures apply. */ + private Row undatedRow() { + return new GenericRowWithSchema(new Object[]{null}, SCHEMA); } - @Test - void loadsPUEAndWUEForExactRegionMatch() { - // us-east-1 has PUE = 1.15 and WUE = 0.12 in aws-pue-wue.csv - Row row = emptyRow(); + private Row rowForYear(int year) { + return new GenericRowWithSchema(new Object[]{year + "-06-15T00:00:00Z"}, SCHEMA); + } + + private Map enrich(Row row, String region) { Map enriched = new HashMap<>(); - enriched.put(REGION, "us-east-1"); - + if (region != null) { + enriched.put(REGION, region); + } pwue.enrich(row, enriched); + return enriched; + } + + @Test + void loadsPUEAndWUEForExactRegionMatch() { + // us-east-1 has PUE = 1.15 and WUE = 0.12 for 2024 in aws-pue-wue.csv + Map enriched = enrich(rowForYear(2024), "us-east-1"); assertTrue(enriched.containsKey(PUE)); assertTrue(enriched.containsKey(WUE)); @@ -48,79 +63,109 @@ void loadsPUEAndWUEForExactRegionMatch() { assertEquals(0.12, (Double) enriched.get(WUE), 0.01); } + @Test + void usesTheFiguresPublishedForTheUsageYear() { + // eu-central-1 improved from PUE 1.32 in 2022 to 1.24 in 2025 + assertEquals(1.32, (Double) enrich(rowForYear(2022), "eu-central-1").get(PUE), 0.01); + assertEquals(1.33, (Double) enrich(rowForYear(2023), "eu-central-1").get(PUE), 0.01); + assertEquals(1.35, (Double) enrich(rowForYear(2024), "eu-central-1").get(PUE), 0.01); + assertEquals(1.24, (Double) enrich(rowForYear(2025), "eu-central-1").get(PUE), 0.01); + + // and its WUE went the other way, from 0.01 in 2024 to 0.17 in 2025 + assertEquals(0.01, (Double) enrich(rowForYear(2024), "eu-central-1").get(WUE), 0.01); + assertEquals(0.17, (Double) enrich(rowForYear(2025), "eu-central-1").get(WUE), 0.01); + } + + @Test + void fallsBackToTheClosestYearPublishedForTheRegion() { + // eu-west-2 only has 2025 figures, used for earlier and later years alike + assertEquals(1.23, (Double) enrich(rowForYear(2023), "eu-west-2").get(PUE), 0.01); + assertEquals(1.23, (Double) enrich(rowForYear(2030), "eu-west-2").get(PUE), 0.01); + + // us-east-1 has PUE from 2022 but WUE only from 2024, so 2022 borrows the 2024 WUE + Map enriched = enrich(rowForYear(2022), "us-east-1"); + assertEquals(1.16, (Double) enriched.get(PUE), 0.01); + assertEquals(0.12, (Double) enriched.get(WUE), 0.01); + } + + @Test + void usesTheMostRecentFiguresWhenTheRowHasNoDate() { + // 2025 is the latest year published for us-east-1 + Map enriched = enrich(undatedRow(), "us-east-1"); + assertEquals(1.15, (Double) enriched.get(PUE), 0.01); + assertEquals(0.06, (Double) enriched.get(WUE), 0.01); + } + @Test void loadsPUEAndWUEForRegexRegionMatch() { - // us-gov-west-1 matches regex us-.+ with PUE = 1.14 and WUE = 0.13 - Row row = emptyRow(); - Map enriched = new HashMap<>(); - enriched.put(REGION, "us-gov-west-1"); - - pwue.enrich(row, enriched); + // us-gov-west-1 matches regex (us|ca|mx)-.+, the North America average + Map enriched = enrich(rowForYear(2024), "us-gov-west-1"); - assertTrue(enriched.containsKey(PUE)); - assertTrue(enriched.containsKey(WUE)); assertEquals(1.14, (Double) enriched.get(PUE), 0.01); assertEquals(0.13, (Double) enriched.get(WUE), 0.01); } @Test void loadsPUEAndWUEForRegionWithRegexFallback() { - // ap-south-1 has exact PUE = 1.42 but no exact WUE, so it falls back to regex ap-.+ with WUE = 0.98 - Row row = emptyRow(); - Map enriched = new HashMap<>(); - enriched.put(REGION, "ap-south-1"); - - pwue.enrich(row, enriched); + // ap-south-1 has an exact PUE for 2025 but no WUE at all, so it falls back to the + // Asia Pacific average for the same year + Map enriched = enrich(rowForYear(2025), "ap-south-1"); - assertTrue(enriched.containsKey(PUE)); - assertTrue(enriched.containsKey(WUE)); - assertEquals(1.42, (Double) enriched.get(PUE), 0.01); // Exact match takes priority for PUE - assertEquals(0.98, (Double) enriched.get(WUE), 0.01); // Regex match for WUE + assertEquals(1.4, (Double) enriched.get(PUE), 0.01); + assertEquals(1.1, (Double) enriched.get(WUE), 0.01); } @Test - void usesDefaultPUEForUnknownRegion() { - // Unknown region should get default PUE (1.15) and no WUE - Row row = emptyRow(); - Map enriched = new HashMap<>(); - enriched.put(REGION, "unknown-region-99"); - - pwue.enrich(row, enriched); + void fallsBackToTheGlobalAverageForUnknownRegion() { + Map enriched = enrich(rowForYear(2024), "unknown-region-99"); - assertTrue(enriched.containsKey(PUE)); - assertFalse(enriched.containsKey(WUE)); assertEquals(1.15, (Double) enriched.get(PUE), 0.01); + assertEquals(0.15, (Double) enriched.get(WUE), 0.01); + + // 2025 lowered the global average + enriched = enrich(rowForYear(2025), "unknown-region-99"); + assertEquals(1.14, (Double) enriched.get(PUE), 0.01); + assertEquals(0.12, (Double) enriched.get(WUE), 0.01); } @Test - void usesCustomDefaultPUEWhenConfigured() { - // Test with custom default PUE value - PWUE customLoader = new PWUE(); - Map config = new HashMap<>(); - config.put("default", 1.20); - customLoader.init(config, Provider.AWS); - - Row row = emptyRow(); - Map enriched = new HashMap<>(); - enriched.put(REGION, "unknown-region-99"); - - customLoader.enrich(row, enriched); + void handlesNullRegion() { + // no region: the global average for the usage year + Map enriched = enrich(rowForYear(2024), null); assertTrue(enriched.containsKey(PUE)); - assertEquals(1.20, (Double) enriched.get(PUE), 0.01); + assertEquals(1.15, (Double) enriched.get(PUE), 0.01); } @Test - void handlesNullRegion() { - Row row = emptyRow(); + void undatedCsvEntriesApplyToEveryYear() { + // Microsoft does not publish these by year, so the same figures apply throughout + PWUE azure = new PWUE(); + azure.init(new HashMap<>(), Provider.AZURE); + + for (int year : new int[]{2022, 2025}) { + Map enriched = new HashMap<>(); + enriched.put(REGION, "westeurope"); + azure.enrich(rowForYear(year), enriched); + assertEquals(1.16, (Double) enriched.get(PUE), 0.01); + assertEquals(0.03, (Double) enriched.get(WUE), 0.01); + } + } + + @Test + void usesDefaultPUEWhenNothingMatches() { + // the Azure CSV has no global entry, so an unknown region falls through to the default + PWUE azure = new PWUE(); + Map config = new HashMap<>(); + config.put("default", 1.20); + azure.init(config, Provider.AZURE); + Map enriched = new HashMap<>(); - // No region set - - pwue.enrich(row, enriched); + enriched.put(REGION, "unknown-region-99"); + azure.enrich(undatedRow(), enriched); - assertTrue(enriched.containsKey(PUE)); + assertEquals(1.20, (Double) enriched.get(PUE), 0.01); assertFalse(enriched.containsKey(WUE)); - assertEquals(1.15, (Double) enriched.get(PUE), 0.01); } @Test @@ -137,4 +182,4 @@ void columnsNeeded() { assertEquals(1, columns.length); assertEquals(REGION, columns[0]); } -} \ No newline at end of file +}