diff --git a/pyproject.toml b/pyproject.toml index f40efd2..a4e1932 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "rich>=14.2.0", "questionary>=2.1.1", "tqdm>=4.67.1", + "cairo", ] classifiers = [ "Intended Audience :: Developers", @@ -66,3 +67,6 @@ ignore = ["E501"] [tool.ruff.lint.per-file-ignores] "tests/*" = ["S101"] + +[tool.uv.sources] +cairo = { git = "https://github.com/NatLabRockies/CAIRO", rev = "main" } diff --git a/rate-utils/plotting_utils.py b/rate-utils/plotting_utils.py new file mode 100644 index 0000000..b9abd2e --- /dev/null +++ b/rate-utils/plotting_utils.py @@ -0,0 +1,284 @@ +"""Shared plotting and aggregation utilities for HP rate notebooks. + +This file intentionally lives in ``rate-utils/`` (not a Python package yet). +Notebooks import it by adding the ``rate-utils`` directory to ``sys.path``. +""" + +from __future__ import annotations + +import json +from pathlib import Path +from typing import TYPE_CHECKING + +import pandas as pd + +if TYPE_CHECKING: + import polars as pl + + +DIST_PARAM_KEYS = ( + "annual_future_distr_costs", + "distr_peak_hrs", + "nc_ratio_baseline", +) + + +def resolve_dist_params(defaults: dict, candidates: list[Path] | None = None) -> dict: + """Return distribution-cost parameters from the first existing JSON candidate.""" + candidates = candidates or [] + for path in candidates: + if not path.exists(): + continue + with open(path) as f: + loaded = json.load(f) + return {key: loaded[key] for key in DIST_PARAM_KEYS} + return defaults + + +def choose_latest_run(run_root: Path) -> Path: + """Return the lexicographically latest run directory under ``run_root``.""" + runs = sorted(path for path in run_root.iterdir() if path.is_dir()) + if not runs: + raise FileNotFoundError(f"No run directories found in {run_root}") + return runs[-1] + + +def force_timezone_est(df: pd.DataFrame, time_col: str = "time") -> pd.DataFrame: + """Ensure a pandas datetime column is timezone-aware and converted to EST.""" + out = df.copy() + out[time_col] = pd.to_datetime(out[time_col], errors="coerce") + if out[time_col].dt.tz is None: + out[time_col] = out[time_col].dt.tz_localize("EST") + else: + out[time_col] = out[time_col].dt.tz_convert("EST") + return out + + +def force_timezone_est_polars(frame: pl.DataFrame, timestamp_col: str = "timestamp") -> pl.DataFrame: + """Ensure a Polars datetime column is timezone-aware and converted to EST.""" + import polars as pl + + if timestamp_col not in frame.columns: + raise ValueError(f"{timestamp_col} not found in frame columns") + + dtype = frame.schema[timestamp_col] + if isinstance(dtype, pl.Datetime) and dtype.time_zone is not None: + expr = pl.col(timestamp_col).dt.convert_time_zone("EST") + else: + expr = pl.col(timestamp_col).cast(pl.Datetime, strict=False).dt.replace_time_zone("EST") + return frame.with_columns(expr.alias(timestamp_col)) + + +def build_bldg_id_to_load_filepath(path_resstock_loads: Path, building_ids: list[int]) -> dict[int, Path]: + """Map requested building IDs to their ResStock load parquet paths.""" + bldg_set = {int(i) for i in building_ids} + mapping: dict[int, Path] = {} + for parquet_file in path_resstock_loads.glob("*.parquet"): + try: + bldg_id = int(parquet_file.stem.split("-")[0]) + except ValueError: + continue + if bldg_id in bldg_set: + mapping[bldg_id] = parquet_file + missing = bldg_set - set(mapping) + if missing: + print(f"Warning: missing load files for {len(missing)} building IDs") + return mapping + + +def summarize_cross_subsidy(cross: pd.DataFrame, metadata: pd.DataFrame) -> pd.DataFrame: + """Compute weighted cross-subsidy metrics for HP and Non-HP groups.""" + merged = cross.merge( + metadata[["bldg_id", "postprocess_group.has_hp", "weight"]], + on=["bldg_id", "weight"], + how="left", + ) + + weighted_cols = { + "BAT_vol": "BAT_vol_weighted_avg", + "BAT_peak": "BAT_peak_weighted_avg", + "BAT_percustomer": "BAT_percustomer_weighted_avg", + "customer_level_residual_share_volumetric": "residual_vol_weighted_avg", + "customer_level_residual_share_peak": "residual_peak_weighted_avg", + "customer_level_residual_share_percustomer": "residual_percustomer_weighted_avg", + "Annual": "Annual_bill_weighted_avg", + "customer_level_economic_burden": "Economic_burden_weighted_avg", + } + + rows = [] + for has_hp, group in merged.groupby("postprocess_group.has_hp"): + weight_sum = group["weight"].sum() + row = { + "postprocess_group.has_hp": has_hp, + "customers_weighted": weight_sum, + "group": "HP" if has_hp else "Non-HP", + } + for source_col, output_col in weighted_cols.items(): + row[output_col] = (group[source_col] * group["weight"]).sum() / weight_sum + rows.append(row) + + return pd.DataFrame(rows) + + +def build_cost_mix(cross_summary: pd.DataFrame) -> pd.DataFrame: + """Build long-form marginal vs residual cost totals by customer group.""" + residual_labels = { + "residual_vol_weighted_avg": "Volumetric residual", + "residual_peak_weighted_avg": "Peak residual", + "residual_percustomer_weighted_avg": "Per-customer residual", + } + + cost_mix = ( + cross_summary[ + [ + "group", + "customers_weighted", + "Economic_burden_weighted_avg", + *residual_labels.keys(), + ] + ] + .melt( + id_vars=["group", "customers_weighted", "Economic_burden_weighted_avg"], + value_vars=list(residual_labels.keys()), + var_name="benchmark_key", + value_name="residual_usd_per_customer_year", + ) + .assign( + benchmark=lambda d: d["benchmark_key"].map(residual_labels), + marginal_usd_per_customer_year=lambda d: d["Economic_burden_weighted_avg"], + weighted_customers=lambda d: d["customers_weighted"], + ) + [[ + "group", + "benchmark", + "weighted_customers", + "marginal_usd_per_customer_year", + "residual_usd_per_customer_year", + ]] + ) + + totals = cost_mix.assign( + marginal_total_usd_per_year=lambda d: d["marginal_usd_per_customer_year"] * d["weighted_customers"], + residual_total_usd_per_year=lambda d: d["residual_usd_per_customer_year"] * d["weighted_customers"], + ) + + return ( + totals.melt( + id_vars=["group", "benchmark"], + value_vars=["marginal_total_usd_per_year", "residual_total_usd_per_year"], + var_name="cost_source_key", + value_name="usd_total_per_year", + ) + .assign( + cost_source=lambda d: d["cost_source_key"].map( + { + "marginal_total_usd_per_year": "Marginal (economic burden)", + "residual_total_usd_per_year": "Residual allocation", + } + ), + musd_total_per_year=lambda d: d["usd_total_per_year"] / 1e6, + ) + [["group", "benchmark", "cost_source", "musd_total_per_year"]] + ) + + +def build_hourly_group_loads(raw_load_elec: pd.DataFrame, metadata: pd.DataFrame) -> pd.DataFrame: + """Aggregate weighted hourly electricity load by HP flag and total.""" + weighted = raw_load_elec[["electricity_net"]].reset_index().merge( + metadata[["bldg_id", "postprocess_group.has_hp", "weight"]], + on="bldg_id", + how="left", + ) + weighted["weighted_load_kwh"] = weighted["electricity_net"] * weighted["weight"] + + hourly = ( + weighted.groupby(["time", "postprocess_group.has_hp"], as_index=False)["weighted_load_kwh"] + .sum() + .pivot(index="time", columns="postprocess_group.has_hp", values="weighted_load_kwh") + .rename(columns={False: "non_hp_load_kwh", True: "hp_load_kwh"}) + .fillna(0.0) + .sort_index() + ) + hourly["total_load_kwh"] = hourly["non_hp_load_kwh"] + hourly["hp_load_kwh"] + return hourly + + +def build_cross_components(cross_summary: pd.DataFrame) -> pd.DataFrame: + """Build benchmark component contributions for charting cross-subsidy impacts.""" + component_labels = { + "BAT_vol_weighted_avg": "Volumetric benchmark", + "BAT_peak_weighted_avg": "Peak benchmark", + "BAT_percustomer_weighted_avg": "Per-customer benchmark", + } + return ( + cross_summary.melt( + id_vars=["group", "customers_weighted"], + value_vars=list(component_labels.keys()), + var_name="component", + value_name="weighted_avg_bat_usd_per_customer_year", + ) + .assign( + component_label=lambda d: d["component"].map(component_labels), + component_transfer_total_musd_per_year=lambda d: ( + d["weighted_avg_bat_usd_per_customer_year"] * d["customers_weighted"] / 1e6 + ), + ) + ) + + +def summarize_positive_distribution_hours( + hourly: pd.DataFrame, + customer_count_map: dict[str, float], +) -> pd.DataFrame: + """Summarize per-customer load behavior in positive marginal distribution-cost hours.""" + rows = [] + for col, label in [("hp_load_kwh", "HP"), ("non_hp_load_kwh", "Non-HP")]: + customer_count = float(customer_count_map[label]) + annual = hourly[col].sum() + positive = hourly.loc[hourly["mdc_positive"], col].sum() + rows.append( + { + "group": label, + "weighted_customers": customer_count, + "annual_load_mwh_per_customer": (annual / customer_count) / 1000, + "positive_dist_cost_hours_load_mwh_per_customer": (positive / customer_count) / 1000, + "share_of_annual_load_in_positive_dist_cost_hours": positive / annual, + "avg_hourly_load_kwh_during_positive_dist_hours": ( + hourly.loc[hourly["mdc_positive"], col].mean() / customer_count + ), + "avg_hourly_load_kwh_during_zero_dist_hours": ( + hourly.loc[~hourly["mdc_positive"], col].mean() / customer_count + ), + } + ) + return pd.DataFrame(rows) + + +def build_tariff_components( + hourly: pd.DataFrame, + cross_summary: pd.DataFrame, + fixed_monthly: float, + vol_rate: float, +) -> pd.DataFrame: + """Compute annual fixed and volumetric charges collected by customer group.""" + group_load = pd.DataFrame( + { + "group": ["Non-HP", "HP"], + "annual_load_kwh": [hourly["non_hp_load_kwh"].sum(), hourly["hp_load_kwh"].sum()], + } + ) + + tariff_components = group_load.merge( + cross_summary[["group", "customers_weighted"]].rename( + columns={"customers_weighted": "weighted_customers"} + ), + on="group", + how="left", + ) + tariff_components["annual_fixed_charge_collected_usd"] = ( + fixed_monthly * 12 * tariff_components["weighted_customers"] + ) + tariff_components["annual_volumetric_charge_collected_usd"] = ( + vol_rate * tariff_components["annual_load_kwh"] + ) + return tariff_components diff --git a/reports/ny_hp_rates/notebooks/resstock_hourly_loads_by_hp_flag.qmd b/reports/ny_hp_rates/notebooks/resstock_hourly_loads_by_hp_flag.qmd new file mode 100644 index 0000000..f42b7f6 --- /dev/null +++ b/reports/ny_hp_rates/notebooks/resstock_hourly_loads_by_hp_flag.qmd @@ -0,0 +1,368 @@ +--- +title: NY ResStock Hourly Loads by HP Flag (2024 AMY2018 Release 2) +format: + html: + page-layout: full +toc: true +execute: + warning: false + message: false +--- + +This notebook plots NY ResStock hourly loads split by heat-pump status using a Polars lazy/streaming workflow. + +```{python} +from pathlib import Path + +import polars as pl +from plotnine import ( + aes, + coord_flip, + geom_area, + geom_col, + geom_line, + geom_ribbon, + ggplot, + labs, + scale_color_manual, + scale_fill_manual, + theme, + theme_minimal, +) +import sys + +for _root in (Path.cwd(), *Path.cwd().parents): + _rate_utils = _root / "rate-utils" + if (_rate_utils / "plotting_utils.py").is_file(): + sys.path.insert(0, str(_rate_utils)) + break +else: + raise FileNotFoundError("Could not locate rate-utils/plotting_utils.py from current working directory.") + +from plotting_utils import force_timezone_est_polars +``` + +# Parameters +```{python} +STATE = "NY" +RELEASE = "res_2024_amy2018_2" +UPGRADE = "00" +CUSTOMER_COUNT = 7_500_000 +SAMPLE_BUILDINGS = 2_000 + +DATA_ROOT_CANDIDATES = [ + Path("/metadata.sb/nrel/resstock"), + Path("/data.sb/nrel/resstock"), +] + +GROUP_COLORS = { + "HP": "#FC9706", + "Electric (non-HP heating)": "#2A9D8F", + "Non-HP": "#023047", +} + + +def choose_data_root(candidates: list[Path]) -> Path: + for root in candidates: + if root.exists(): + return root + raise FileNotFoundError( + "No data root found. Checked: " + ", ".join(str(p) for p in candidates) + ) + + +def resolve_metadata_file(base: Path) -> Path: + candidates = [ + base / "metadata-sb.parquet", + base / "metadata-sb-with-utilities.parquet", + base / "metadata.parquet", + ] + for path in candidates: + if path.exists(): + return path + raise FileNotFoundError( + "No metadata parquet found. Checked: " + ", ".join(str(p) for p in candidates) + ) + + +def build_hp_flag_expr(schema_cols: list[str]) -> pl.Expr: + if "postprocess_group.has_hp" in schema_cols: + return pl.col("postprocess_group.has_hp") + + if "in.hvac_heating_and_fuel_type" in schema_cols: + return ( + pl.col("in.hvac_heating_and_fuel_type") + .str.to_lowercase() + .str.contains("hp") + .fill_null(False) + ) + + if "in.hvac_heating_type_and_fuel" in schema_cols: + return ( + pl.col("in.hvac_heating_type_and_fuel") + .str.to_lowercase() + .str.contains("hp") + .fill_null(False) + ) + + raise ValueError( + "Could not determine HP flag from metadata columns. " + "Expected `postprocess_group.has_hp`, `in.hvac_heating_and_fuel_type`, " + "or `in.hvac_heating_type_and_fuel`." + ) + + +data_root = choose_data_root(DATA_ROOT_CANDIDATES) +resstock_root = data_root / RELEASE +metadata_dir = resstock_root / "metadata" / f"state={STATE}" / f"upgrade={UPGRADE}" +loads_dir = ( + resstock_root + / "load_curve_hourly" + # / f"state={STATE}" + # / f"upgrade={UPGRADE}" +) +loads_glob = str( + loads_dir + / "*.parquet" +) +``` + +# Metadata Processing +```{python} +metadata_path = resolve_metadata_file(metadata_dir) +meta_schema = pl.scan_parquet(str(metadata_path)).collect_schema().names() + +if "bldg_id" not in meta_schema: + raise ValueError(f"metadata is missing bldg_id: {metadata_path}") + +if "in.hvac_heating_and_fuel_type" in meta_schema: + heating_type_col = "in.hvac_heating_and_fuel_type" +elif "in.hvac_heating_type_and_fuel" in meta_schema: + heating_type_col = "in.hvac_heating_type_and_fuel" +else: + raise ValueError( + "Could not determine heating-type column from metadata. " + "Expected `in.hvac_heating_and_fuel_type` or `in.hvac_heating_type_and_fuel`." + ) + +weight_expr = ( + pl.col("weight") + if "weight" in meta_schema + else pl.lit(1.0) +) + +metadata_base_lf = ( + pl.scan_parquet(str(metadata_path)) + .select( + pl.col("bldg_id"), + pl.col(heating_type_col).fill_null("Unknown").alias("heating_type"), + build_hp_flag_expr(meta_schema).alias("hp_flag"), + weight_expr.alias("raw_weight"), + ) + .with_columns(pl.col("raw_weight").fill_null(1.0)) +) + +metadata_df = metadata_base_lf.collect(engine="streaming") +if SAMPLE_BUILDINGS and metadata_df.height > SAMPLE_BUILDINGS: + metadata_df = metadata_df.sample(n=SAMPLE_BUILDINGS, seed=42) +sample_bldg_ids = metadata_df["bldg_id"].to_list() +raw_weight_sum = metadata_df["raw_weight"].sum() +if raw_weight_sum is None or raw_weight_sum <= 0: + raise ValueError("raw_weight_sum must be positive before scaling to CUSTOMER_COUNT.") + +weight_scale = CUSTOMER_COUNT / raw_weight_sum + +metadata_scaled = metadata_df.with_columns( + (pl.col("raw_weight") * pl.lit(weight_scale)).alias("weight") +) +metadata_lf = ( + metadata_scaled + .lazy() + .with_columns( + pl.when(pl.col("hp_flag")) + .then(pl.lit("HP")) + .when(pl.col("heating_type").str.to_lowercase().str.contains("electric")) + .then(pl.lit("Electric (non-HP heating)")) + .otherwise(pl.lit("Non-HP")) + .alias("hp_group") + ) + .select("bldg_id", "hp_group", "weight") +) + +heating_type_summary = ( + metadata_scaled + .lazy() + .group_by("heating_type") + .agg(pl.col("weight").sum().alias("weighted_homes")) + .with_columns( + (pl.col("weighted_homes") / pl.col("weighted_homes").sum()).alias("share_of_homes") + ) + .sort("weighted_homes", descending=True) + .collect(engine="streaming") +) +``` + +# Load Processing +```{python} + +# load_schema = pl.scan_parquet(loads_dir).collect_schema().names() +# load_col_preference = [ +# # "out.electricity.net.energy_consumption", +# # "out.site_energy.net.energy_consumption", +# "out.electricity.total.energy_consumption", +# # "out.site_energy.total.energy_consumption", +# ] +# load_col = next((c for c in load_col_preference if c in load_schema), None) +# if load_col is None: +# raise ValueError( +# "Expected one of load columns not found: " + ", ".join(load_col_preference) +# ) +load_col = "out.electricity.net.energy_consumption" +``` +# Load Processing +```{python} +UPGRADE = int(UPGRADE) +base_loads_lf = ( + pl.scan_parquet(loads_dir) + .filter((pl.col("state") == STATE ) & (pl.col("upgrade") == UPGRADE)) + .filter(pl.col("bldg_id").is_in(sample_bldg_ids)) + .select( + pl.col("bldg_id"), + pl.col("timestamp"), + pl.col(load_col).alias("load_kwh"), + ) + .join(metadata_lf, on="bldg_id", how="inner") +) +``` +# collect loads +```{python} +hourly_timeseries = ( + base_loads_lf + .with_columns((pl.col("load_kwh") * pl.col("weight")).alias("weighted_load_kwh")) + .group_by(["timestamp", "hp_group"]) + .agg(pl.col("weighted_load_kwh").sum().alias("weighted_hourly_load_kwh")) + .sort(["timestamp", "hp_group"]) + .collect(engine="streaming") +) + +hourly_timeseries = force_timezone_est_polars(hourly_timeseries, timestamp_col="timestamp") + +print(f"Data root: {data_root}") +print(f"Metadata: {metadata_path}") +print(f"Heating type column: {heating_type_col}") +print(f"Load column: {load_col}") +print(f"Sample buildings: {metadata_df.height:,}") +print(f"Raw weight sum: {raw_weight_sum:,.3f}") +print(f"Scaled weight sum target: {CUSTOMER_COUNT:,.0f}") +print(f"Rows in hourly_timeseries: {hourly_timeseries.height:,}") +``` + +## Heating Type Distribution Across Houses + +```{python} +heating_type_summary +``` + +```{python} +heating_type_plot_pd = heating_type_summary.to_pandas() + +( + ggplot( + heating_type_plot_pd, + aes(x="reorder(heating_type, weighted_homes)", y="weighted_homes"), + ) + + geom_col(fill="#457b9d") + + coord_flip() + + theme_minimal() + + theme(figure_size=(11, 6)) + + labs( + title=f"{STATE} Distribution of `{heating_type_col}`", + subtitle=f"Release: {RELEASE}, upgrade={UPGRADE}", + x="Heating type", + y="Weighted homes", + ) +) +``` + +## Annual Hourly Timeseries (Weighted) + +```{python} +hourly_ts_pd = hourly_timeseries.to_pandas() +plot_ts = ( + ggplot(hourly_ts_pd, aes(x="timestamp", y="weighted_hourly_load_kwh", color="hp_group")) + + geom_line(alpha=0.75, size=0.35) + + scale_color_manual(values=GROUP_COLORS) + + theme_minimal() + + labs( + title=f"{STATE} ResStock Hourly Load by HP Flag", + subtitle=f"Release: {RELEASE}, upgrade={UPGRADE}", + x="Timestamp", + y="Weighted hourly load (kWh)", + color="Group", + ) +) +plot_ts +``` + +## Stacked Aggregated Load Curves (Non-HP + Electric Non-HP + HP) + +```{python} +stacked = ( + hourly_timeseries + .with_columns(pl.col("timestamp").dt.truncate("1d").alias("timestamp")) + .group_by(["timestamp", "hp_group"]) + .agg(pl.col("weighted_hourly_load_kwh").mean().alias("weighted_hourly_load_kwh")) + .pivot( + index="timestamp", + on="hp_group", + values="weighted_hourly_load_kwh", + aggregate_function="sum", + ) + .sort("timestamp") +) +required_groups = ["HP", "Electric (non-HP heating)", "Non-HP"] +for group_name in required_groups: + if group_name not in stacked.columns: + stacked = stacked.with_columns(pl.lit(0.0).alias(group_name)) + +stacked = ( + stacked + .with_columns( + pl.col("HP").fill_null(0.0), + pl.col("Electric (non-HP heating)").fill_null(0.0), + pl.col("Non-HP").fill_null(0.0), + ) + .with_columns( + (pl.col("Non-HP") + pl.col("Electric (non-HP heating)")).alias("non_hp_plus_electric_kwh") + ) + .with_columns((pl.col("non_hp_plus_electric_kwh") + pl.col("HP")).alias("hp_top_kwh")) +) +stacked_pd = stacked.to_pandas() + +( + ggplot(stacked_pd, aes(x="timestamp")) + + geom_area(aes(y="Non-HP", fill='"Non-HP"'), alpha=0.85) + + geom_ribbon( + aes( + ymin="Non-HP", + ymax="non_hp_plus_electric_kwh", + fill='"Electric (non-HP heating)"', + ), + alpha=0.95, + ) + + geom_ribbon(aes(ymin="non_hp_plus_electric_kwh", ymax="hp_top_kwh", fill='"HP"'), alpha=0.95) + + geom_line(aes(y="Non-HP"), color="#1f4e79", size=0.35) + + geom_line(aes(y="non_hp_plus_electric_kwh"), color="#2A9D8F", size=0.35) + + geom_line(aes(y="hp_top_kwh"), color="#8a3f00", size=0.45) + + scale_fill_manual(values={"Non-HP": "#6baed6", "Electric (non-HP heating)": "#2A9D8F", "HP": "#fdae6b"}) + + theme_minimal() + + theme(figure_size=(12, 4), legend_position="bottom") + + labs( + title=f"{STATE} Stacked Daily-Average Weighted Load Curves", + subtitle=f"Release: {RELEASE}, upgrade={UPGRADE}", + x="Time", + y="Daily average hourly weighted load (kWh)", + fill="Series", + ) +) +``` diff --git a/reports/ri_hp_rates/_quarto.yml b/reports/ri_hp_rates/_quarto.yml index 0cd5126..c002faf 100644 --- a/reports/ri_hp_rates/_quarto.yml +++ b/reports/ri_hp_rates/_quarto.yml @@ -3,6 +3,7 @@ project: output-dir: docs render: - notebooks/analysis.qmd + - notebooks/cross_subsidy_diagnostics.qmd - index.qmd manuscript: diff --git a/reports/ri_hp_rates/notebooks/cross_subsidy_diagnostics.qmd b/reports/ri_hp_rates/notebooks/cross_subsidy_diagnostics.qmd new file mode 100644 index 0000000..9598cb6 --- /dev/null +++ b/reports/ri_hp_rates/notebooks/cross_subsidy_diagnostics.qmd @@ -0,0 +1,365 @@ +--- +title: "RI HP Cross-Subsidy Diagnostics (Direct CAIRO)" +format: + html: + page-layout: full +toc: true +execute: + warning: false + message: false +--- + +This notebook analyzes RI CAIRO run outputs directly from `/data.sb/switchbox/cairo/ri_default_test_run/`, using CAIRO APIs inside the `reports2` Python environment. + +```{python} +from __future__ import annotations + +import json +from pathlib import Path + +import pandas as pd +from plotnine import ( + aes, + coord_flip, + facet_wrap, + geom_area, + geom_col, + geom_line, + geom_ribbon, + ggplot, + labs, + position_dodge, + scale_fill_manual, + theme, + theme_minimal, +) + +import sys + +for _root in (Path.cwd(), *Path.cwd().parents): + _rate_utils = _root / "rate-utils" + if (_rate_utils / "plotting_utils.py").is_file(): + sys.path.insert(0, str(_rate_utils)) + break +else: + raise FileNotFoundError("Could not locate rate-utils/plotting_utils.py from current working directory.") + +from plotting_utils import ( + build_bldg_id_to_load_filepath, + build_cost_mix, + build_cross_components, + build_hourly_group_loads, + build_tariff_components, + choose_latest_run, + resolve_dist_params, + summarize_cross_subsidy, + summarize_positive_distribution_hours, +) + +try: + from cairo.rates_tool.loads import _return_load + from cairo.utils.marginal_costs.marginal_cost_calculator import ( + _load_cambium_marginal_costs, + add_distribution_costs, + ) +except ModuleNotFoundError as exc: + raise ModuleNotFoundError( + "CAIRO is not installed in this reports2 environment. " + "Run `uv sync` in reports2 after adding cairo to dependencies." + ) from exc + + +pd.set_option("display.max_columns", 100) + +RUN_ROOT = Path("/data.sb/switchbox/cairo/ri_default_test_run") +RESSTOCK_LOADS = Path( + "/data.sb/nrel/resstock/res_2024_amy2018_2/load_curve_hourly/state=RI/upgrade=00" +) +CAMBIUM_COSTS = Path("/data.sb/nrel/cambium/dummy_rie_marginal_costs.csv") + +TARGET_YEAR = 2019 +DIST_DEFAULTS = { + "annual_future_distr_costs": 80.24, + "distr_peak_hrs": 100, + "nc_ratio_baseline": 1.41, +} +DIST_PARAM_CANDIDATES = [ + Path("/workspaces/rate-design-platform/rate_design/ri/hp_rates/data/distribution_cost_params.json"), + Path.home() / "rate-design-platform/rate_design/ri/hp_rates/data/distribution_cost_params.json", + Path("/ebs/home/sherry_switch_box/rate-design-platform/rate_design/ri/hp_rates/data/distribution_cost_params.json"), +] + +run_dir = choose_latest_run(RUN_ROOT) +dist_params = resolve_dist_params(defaults=DIST_DEFAULTS, candidates=DIST_PARAM_CANDIDATES) + +print(f"Using run: {run_dir}") +print(f"Distribution params: {dist_params}") +``` + +## Load Cross-Subsidy Outputs + +```{python} +metadata = pd.read_csv(run_dir / "customer_metadata.csv") +cross = pd.read_csv(run_dir / "cross_subsidization/cross_subsidization_BAT_values.csv") + +cross_summary = summarize_cross_subsidy(cross=cross, metadata=metadata) +cross_components = build_cross_components(cross_summary) + +cross_summary.sort_values("group") +``` + +## Marginal vs Residual Cost Composition (HP vs Non-HP) + +```{python} +cost_mix_long = build_cost_mix(cross_summary) +cost_mix_long["cost_source"] = pd.Categorical( + cost_mix_long["cost_source"], + categories=["Residual allocation", "Marginal (economic burden)"], + ordered=True, +) + +( + ggplot(cost_mix_long, aes(x="group", y="musd_total_per_year", fill="cost_source")) + + geom_col(position="stack", width=0.65) + + scale_fill_manual( + values={ + "Marginal (economic burden)": "#00BFC4", + "Residual allocation": "#F8766D", + }, + breaks=["Marginal (economic burden)", "Residual allocation"], + ) + + facet_wrap("~benchmark") + + labs( + title="Customer Cost Composition: Marginal vs Residual (Total $/year)", + x="Customer group", + y="$ million per year", + fill="Cost source", + ) + + theme_minimal() + + theme(figure_size=(11, 4), legend_position="bottom") +) +``` + +## Build Hourly Loads and Marginal Costs with CAIRO + +```{python} +building_ids = metadata["bldg_id"].astype(int).tolist() +load_map = build_bldg_id_to_load_filepath(RESSTOCK_LOADS, building_ids) + +raw_load_elec = _return_load( + load_type="electricity", + target_year=TARGET_YEAR, + load_filepath_key=load_map, + force_tz="EST", +) + +hourly_by_group = build_hourly_group_loads(raw_load_elec=raw_load_elec, metadata=metadata) + +dist_costs = add_distribution_costs( + raw_load_elec["electricity_net"], + annual_future_distr_costs=dist_params["annual_future_distr_costs"], + distr_peak_hrs=dist_params["distr_peak_hrs"], + nc_ratio_baseline=dist_params["nc_ratio_baseline"], +) + +supply_costs = _load_cambium_marginal_costs(CAMBIUM_COSTS, TARGET_YEAR) +costs = supply_costs.join(dist_costs, how="left") +costs["Marginal Supply Costs ($/kWh)"] = ( + costs["Marginal Energy Costs ($/kWh)"] + costs["Marginal Capacity Costs ($/kWh)"] +) + +hourly = hourly_by_group.join(costs, how="left").reset_index() +hourly["mdc_positive"] = hourly["Marginal Distribution Costs ($/kWh)"] > 0 + +hours_positive = int(hourly["mdc_positive"].sum()) +hp_annual_share = hourly["hp_load_kwh"].sum() / hourly["total_load_kwh"].sum() +hp_peakdist_share = ( + hourly.loc[hourly["mdc_positive"], "hp_load_kwh"].sum() + / hourly.loc[hourly["mdc_positive"], "total_load_kwh"].sum() +) + +print(f"Hours with marginal_distribution_cost > 0: {hours_positive}") +print(f"HP annual load share: {hp_annual_share:.2%}") +print(f"HP load share during positive distribution-cost hours: {hp_peakdist_share:.2%}") +``` + +## Stacked Aggregated Load Curves (HP + Non-HP) + +```{python} +stacked = hourly[["time", "hp_load_kwh", "non_hp_load_kwh"]].copy() +stacked["hp_top_kwh"] = stacked["non_hp_load_kwh"] + stacked["hp_load_kwh"] + +( + ggplot(stacked, aes(x="time")) + + geom_area(aes(y="non_hp_load_kwh", fill='"Non-HP"'), alpha=0.85) + + geom_ribbon(aes(ymin="non_hp_load_kwh", ymax="hp_top_kwh", fill='"HP"'), alpha=0.95) + + geom_line(aes(y="non_hp_load_kwh"), color="#1f4e79", size=0.35) + + geom_line(aes(y="hp_top_kwh"), color="#8a3f00", size=0.45) + + scale_fill_manual(values={"Non-HP": "#6baed6", "HP": "#fdae6b"}) + + labs( + title="Stacked Weighted Aggregated Load Curves (HP over Non-HP)", + x="Time", + y="Load (kWh)", + fill="Series", + ) + + theme_minimal() + + theme(figure_size=(12, 4), legend_position="bottom") +) +``` + +## Hourly Load Line Plot (HP vs Non-HP) + +```{python} +line_plot = stacked.melt( + id_vars=["time"], + value_vars=["hp_load_kwh", "non_hp_load_kwh"], + var_name="group", + value_name="load_kwh", +) +line_plot["group"] = line_plot["group"].map({"hp_load_kwh": "HP", "non_hp_load_kwh": "Non-HP"}) + +( + ggplot(line_plot, aes(x="time", y="load_kwh", color="group")) + + geom_line(size=0.55, alpha=0.9) + + labs( + title="Hourly Weighted Load Curves: HP vs Non-HP", + x="Time", + y="Load (kWh)", + color="Group", + ) + + theme_minimal() + + theme(figure_size=(12, 4), legend_position="bottom") +) +``` + +## Marginal Costs: Distribution vs Supply + +```{python} +cost_plot = hourly[["time", "Marginal Distribution Costs ($/kWh)", "Marginal Supply Costs ($/kWh)"]].melt( + id_vars=["time"], + var_name="cost_type", + value_name="cost", +) + +( + ggplot(cost_plot, aes(x="time", y="cost", color="cost_type")) + + geom_line(size=0.5) + + labs( + title="Marginal Cost Time Series", + x="Time", + y="Marginal cost ($/kWh)", + color="Cost type", + ) + + theme_minimal() + + theme(figure_size=(12, 4), legend_position="bottom") +) +``` + +## Cross-Subsidy Components by Group + +```{python} +( + ggplot( + cross_components, + aes(x="component_label", y="component_transfer_total_musd_per_year", fill="group"), + ) + + geom_col(position=position_dodge(width=0.8), width=0.7) + + coord_flip() + + labs( + title="Cross-Subsidy BAT Components (Total Transfer, $M/year)", + x="Benchmark component", + y="Transfer ($ million per year)", + fill="Group", + ) + + theme_minimal() + + theme(figure_size=(9, 4), legend_position="bottom") +) +``` + +## Consumption During Hours Where `marginal_distribution_cost > 0` + +```{python} +customer_count_map = cross_summary.set_index("group")["customers_weighted"].to_dict() +positive_hours_summary = summarize_positive_distribution_hours(hourly, customer_count_map) +positive_hours_summary +``` + +```{python} +( + ggplot( + positive_hours_summary, + aes(x="group", y="positive_dist_cost_hours_load_mwh_per_customer", fill="group"), + ) + + geom_col(width=0.6) + + labs( + title="Per-Customer Load in Hours with Positive Marginal Distribution Cost", + x="Customer group", + y="MWh/customer-year in mdc > 0 hours", + fill="Group", + ) + + theme_minimal() + + theme(figure_size=(7, 4), legend_position="none") +) +``` + +## Tariff Components and Residual Allocation Drivers + +```{python} +with open(run_dir / "tariff_final_config.json") as f: + tariff_cfg = json.load(f) + +tariff = tariff_cfg["rie_a16"] +fixed_monthly = float(tariff["ur_monthly_fixed_charge"]) +vol_rate = float(tariff["ur_ec_tou_mat"][0][4]) + +tariff_components = build_tariff_components( + hourly=hourly, + cross_summary=cross_summary, + fixed_monthly=fixed_monthly, + vol_rate=vol_rate, +) + +tariff_components[[ + "group", + "weighted_customers", + "annual_load_kwh", + "annual_fixed_charge_collected_usd", + "annual_volumetric_charge_collected_usd", +]] +``` + +```{python} +tariff_components_plot = tariff_components.melt( + id_vars=["group"], + value_vars=["annual_fixed_charge_collected_usd", "annual_volumetric_charge_collected_usd"], + var_name="component", + value_name="collected_usd", +) +tariff_components_plot["component"] = tariff_components_plot["component"].map( + { + "annual_fixed_charge_collected_usd": "Fixed charge", + "annual_volumetric_charge_collected_usd": "Volumetric charge", + } +) + +( + ggplot(tariff_components_plot, aes(x="group", y="collected_usd", fill="component")) + + geom_col(position="fill", width=0.65) + + labs( + title="Proportion of Fixed vs Volumetric Collected Charges by Group", + x="Customer group", + y="Share of annual collected charge", + fill="Charge component", + ) + + theme_minimal() + + theme(figure_size=(8, 4), legend_position="bottom") +) +``` + +### Interpretation Checklist + +1. Compare each group's share of annual load vs share of load in `marginal_distribution_cost > 0` hours. +2. Compare tariff collection mechanics (flat customer + flat volumetric) against benchmark residual allocation policies (volumetric, peak, per-customer). +3. Confirm whether supply marginal costs are active; if they are zero, distribution/peak logic dominates residual allocation. +4. Re-run with alternative `distribution_cost_params.json` values (`distr_peak_hrs`, `nc_ratio_baseline`) to test sensitivity of peak BAT. +5. Re-run with non-dummy Cambium costs to test how non-zero supply/capacity costs shift BAT components. diff --git a/reports/ri_hp_rates/notebooks/resstock_hourly_loads_by_hp_flag.qmd b/reports/ri_hp_rates/notebooks/resstock_hourly_loads_by_hp_flag.qmd new file mode 100644 index 0000000..731a6cd --- /dev/null +++ b/reports/ri_hp_rates/notebooks/resstock_hourly_loads_by_hp_flag.qmd @@ -0,0 +1,402 @@ +--- +title: RI ResStock Hourly Loads by HP Flag (2022 Release) +format: + html: + page-layout: full +toc: true +execute: + warning: false + message: false +--- + +This notebook plots RI ResStock hourly loads split by heat-pump status using a Polars lazy/streaming workflow. + +```{python} +from pathlib import Path + +import polars as pl +from plotnine import ( + aes, + coord_flip, + geom_area, + geom_col, + geom_line, + geom_ribbon, + ggplot, + labs, + scale_color_manual, + scale_fill_manual, + theme, + theme_minimal, +) +import sys + +for _root in (Path.cwd(), *Path.cwd().parents): + _rate_utils = _root / "rate-utils" + if (_rate_utils / "plotting_utils.py").is_file(): + sys.path.insert(0, str(_rate_utils)) + break +else: + raise FileNotFoundError("Could not locate rate-utils/plotting_utils.py from current working directory.") + +from plotting_utils import force_timezone_est_polars +``` + +# Parameters +```{python} +STATE = "RI" +RELEASE = "res_2022_amy2018_1.1" +UPGRADE = "00" +CUSTOMER_COUNT = 451_381 + +DATA_ROOT_CANDIDATES = [ + Path("/metadata.sb/nrel/resstock"), + Path("/data.sb/nrel/resstock"), +] + +GROUP_COLORS = { + "HP": "#FC9706", + "Electric (non-HP heating)": "#2A9D8F", + "Non-HP": "#023047", +} + + +def choose_data_root(candidates: list[Path]) -> Path: + for root in candidates: + if root.exists(): + return root + raise FileNotFoundError( + "No data root found. Checked: " + ", ".join(str(p) for p in candidates) + ) + + +def resolve_metadata_file(base: Path) -> Path: + candidates = [ + base / "metadata-sb.parquet", + base / "metadata-sb-with-utilities.parquet", + base / "metadata.parquet", + ] + for path in candidates: + if path.exists(): + return path + raise FileNotFoundError( + "No metadata parquet found. Checked: " + ", ".join(str(p) for p in candidates) + ) + + +def build_hp_flag_expr(schema_cols: list[str]) -> pl.Expr: + if "postprocess_group.has_hp" in schema_cols: + return pl.col("postprocess_group.has_hp") + + if "in.hvac_heating_and_fuel_type" in schema_cols: + return ( + pl.col("in.hvac_heating_and_fuel_type") + .str.to_lowercase() + .str.contains("hp") + .fill_null(False) + ) + + if "in.hvac_heating_type_and_fuel" in schema_cols: + return ( + pl.col("in.hvac_heating_type_and_fuel") + .str.to_lowercase() + .str.contains("hp") + .fill_null(False) + ) + + raise ValueError( + "Could not determine HP flag from metadata columns. " + "Expected `postprocess_group.has_hp`, `in.hvac_heating_and_fuel_type`, " + "or `in.hvac_heating_type_and_fuel`." + ) + + +data_root = choose_data_root(DATA_ROOT_CANDIDATES) +resstock_root = data_root / RELEASE +metadata_dir = resstock_root / "metadata" / f"state={STATE}" / f"upgrade={UPGRADE}" +loads_dir = ( + resstock_root + / "load_curve_hourly" + # / f"state={STATE}" + # / f"upgrade={UPGRADE}" +) +loads_glob = str( + loads_dir + / "*.parquet" +) +``` + +# Metadata Processing +```{python} +metadata_path = resolve_metadata_file(metadata_dir) +meta_schema = pl.scan_parquet(str(metadata_path)).collect_schema().names() + +if "bldg_id" not in meta_schema: + raise ValueError(f"metadata is missing bldg_id: {metadata_path}") + +if "in.hvac_heating_and_fuel_type" in meta_schema: + heating_type_col = "in.hvac_heating_and_fuel_type" +elif "in.hvac_heating_type_and_fuel" in meta_schema: + heating_type_col = "in.hvac_heating_type_and_fuel" +else: + raise ValueError( + "Could not determine heating-type column from metadata. " + "Expected `in.hvac_heating_and_fuel_type` or `in.hvac_heating_type_and_fuel`." + ) + +weight_expr = ( + pl.col("weight") + if "weight" in meta_schema + else pl.lit(1.0) +) + +metadata_base_lf = ( + pl.scan_parquet(str(metadata_path)) + .select( + pl.col("bldg_id"), + pl.col(heating_type_col).fill_null("Unknown").alias("heating_type"), + build_hp_flag_expr(meta_schema).alias("hp_flag"), + weight_expr.alias("raw_weight"), + ) + .with_columns(pl.col("raw_weight").fill_null(1.0)) +) + +metadata_df = metadata_base_lf.collect(engine="streaming") +raw_weight_sum = metadata_df["raw_weight"].sum() +if raw_weight_sum is None or raw_weight_sum <= 0: + raise ValueError("raw_weight_sum must be positive before scaling to CUSTOMER_COUNT.") + +weight_scale = CUSTOMER_COUNT / raw_weight_sum + +metadata_scaled = metadata_df.with_columns( + (pl.col("raw_weight") * pl.lit(weight_scale)).alias("weight") +) +metadata_lf = ( + metadata_scaled + .lazy() + .with_columns( + pl.when(pl.col("hp_flag")) + .then(pl.lit("HP")) + .when(pl.col("heating_type").str.to_lowercase().str.contains("electric")) + .then(pl.lit("Electric (non-HP heating)")) + .otherwise(pl.lit("Non-HP")) + .alias("hp_group") + ) + .select("bldg_id", "hp_group", "weight") +) + +heating_type_summary = ( + metadata_scaled + .lazy() + .group_by("heating_type") + .agg(pl.col("weight").sum().alias("weighted_homes")) + .with_columns( + (pl.col("weighted_homes") / pl.col("weighted_homes").sum()).alias("share_of_homes") + ) + .sort("weighted_homes", descending=True) + .collect(engine="streaming") +) +``` + +# Load Processing +```{python} + +# load_schema = pl.scan_parquet(loads_dir).collect_schema().names() +# load_col_preference = [ +# "out.electricity.net.energy_consumption", +# # "out.site_energy.net.energy_consumption", +# # "out.electricity.total.energy_consumption", +# # "out.site_energy.total.energy_consumption", +# ] +# load_col = next((c for c in load_col_preference if c in load_schema), None) +# if load_col is None: +# raise ValueError( +# "Expected one of load columns not found: " + ", ".join(load_col_preference) +# ) +load_col = "out.electricity.net.energy_consumption" +``` +# Load Processing +```{python} +UPGRADE = int(UPGRADE) +base_loads_lf = ( + pl.scan_parquet(loads_dir) + .filter((pl.col("state") == STATE ) & (pl.col("upgrade") == UPGRADE)) + .select( + pl.col("bldg_id"), + pl.col("timestamp"), + pl.col(load_col).alias("load_kwh"), + ) + .join(metadata_lf, on="bldg_id", how="inner") +) +``` +# collect loads +```{python} +hourly_timeseries = ( + base_loads_lf + .with_columns((pl.col("load_kwh") * pl.col("weight")).alias("weighted_load_kwh")) + .group_by(["timestamp", "hp_group"]) + .agg(pl.col("weighted_load_kwh").sum().alias("weighted_hourly_load_kwh")) + .sort(["timestamp", "hp_group"]) + .collect(engine="streaming") +) + +hourly_timeseries = force_timezone_est_polars(hourly_timeseries, timestamp_col="timestamp") + +print(f"Data root: {data_root}") +print(f"Metadata: {metadata_path}") +print(f"Heating type column: {heating_type_col}") +print(f"Load column: {load_col}") +print(f"Raw weight sum: {raw_weight_sum:,.3f}") +print(f"Scaled weight sum target: {CUSTOMER_COUNT:,.0f}") +print(f"Rows in hourly_timeseries: {hourly_timeseries.height:,}") +``` + +## Heating Type Distribution Across Houses + +```{python} +heating_type_summary +``` + +```{python} +heating_type_plot_pd = heating_type_summary.to_pandas() + +( + ggplot( + heating_type_plot_pd, + aes(x="reorder(heating_type, weighted_homes)", y="weighted_homes"), + ) + + geom_col(fill="#457b9d") + + coord_flip() + + theme_minimal() + + theme(figure_size=(11, 6)) + + labs( + title=f"{STATE} Distribution of `{heating_type_col}`", + subtitle=f"Release: {RELEASE}, upgrade={UPGRADE}", + x="Heating type", + y="Weighted homes", + ) +) +``` + +## Annual Hourly Timeseries (Weighted) + +```{python} +hourly_ts_pd = hourly_timeseries.to_pandas() +plot_ts = ( + ggplot(hourly_ts_pd, aes(x="timestamp", y="weighted_hourly_load_kwh", color="hp_group")) + + geom_line(alpha=0.75, size=0.35) + + scale_color_manual(values=GROUP_COLORS) + + theme_minimal() + + labs( + title=f"{STATE} ResStock Hourly Load by HP Flag", + subtitle=f"Release: {RELEASE}, upgrade={UPGRADE}", + x="Timestamp", + y="Weighted hourly load (kWh)", + color="Group", + ) +) +plot_ts +``` + +## Stacked Aggregated Load Curves (Non-HP + Electric Non-HP + HP) + +```{python} +stacked = ( + hourly_timeseries + .with_columns(pl.col("timestamp").dt.truncate("1d").alias("timestamp")) + .group_by(["timestamp", "hp_group"]) + .agg(pl.col("weighted_hourly_load_kwh").mean().alias("weighted_hourly_load_kwh")) + .pivot( + index="timestamp", + on="hp_group", + values="weighted_hourly_load_kwh", + aggregate_function="sum", + ) + .sort("timestamp") +) +required_groups = ["HP", "Electric (non-HP heating)", "Non-HP"] +for group_name in required_groups: + if group_name not in stacked.columns: + stacked = stacked.with_columns(pl.lit(0.0).alias(group_name)) + +stacked = ( + stacked + .with_columns( + pl.col("HP").fill_null(0.0), + pl.col("Electric (non-HP heating)").fill_null(0.0), + pl.col("Non-HP").fill_null(0.0), + ) + .with_columns( + (pl.col("Non-HP") + pl.col("Electric (non-HP heating)")).alias("non_hp_plus_electric_kwh") + ) + .with_columns((pl.col("non_hp_plus_electric_kwh") + pl.col("HP")).alias("hp_top_kwh")) +) +stacked_pd = stacked.to_pandas() + +( + ggplot(stacked_pd, aes(x="timestamp")) + + geom_area(aes(y="Non-HP", fill='"Non-HP"'), alpha=0.85) + + geom_ribbon( + aes( + ymin="Non-HP", + ymax="non_hp_plus_electric_kwh", + fill='"Electric (non-HP heating)"', + ), + alpha=0.95, + ) + + geom_ribbon(aes(ymin="non_hp_plus_electric_kwh", ymax="hp_top_kwh", fill='"HP"'), alpha=0.95) + + geom_line(aes(y="Non-HP"), color="#1f4e79", size=0.35) + + geom_line(aes(y="non_hp_plus_electric_kwh"), color="#2A9D8F", size=0.35) + + geom_line(aes(y="hp_top_kwh"), color="#8a3f00", size=0.45) + + scale_fill_manual(values={"Non-HP": "#6baed6", "Electric (non-HP heating)": "#2A9D8F", "HP": "#fdae6b"}) + + theme_minimal() + + theme(figure_size=(12, 4), legend_position="bottom") + + labs( + title=f"{STATE} Stacked Daily-Average Weighted Load Curves", + subtitle=f"Release: {RELEASE}, upgrade={UPGRADE}", + x="Time", + y="Daily average hourly weighted load (kWh)", + fill="Series", + ) +) +``` + +## Hourly Load Line Plot (HP vs Non-HP) + +```{python} +line_plot_pd = ( + hourly_timeseries + .pivot( + index="timestamp", + on="hp_group", + values="weighted_hourly_load_kwh", + aggregate_function="sum", + ) + .sort("timestamp") + .with_columns( + pl.col("HP").fill_null(0.0), + pl.col("Non-HP").fill_null(0.0), + ) + .to_pandas()[["timestamp", "HP", "Non-HP"]] + .melt( + id_vars=["timestamp"], + var_name="group", + value_name="weighted_hourly_load_kwh", + ) +) + +( + ggplot(line_plot_pd, aes(x="timestamp", y="weighted_hourly_load_kwh", color="group")) + + geom_line(size=0.55, alpha=0.9) + + scale_color_manual(values=GROUP_COLORS) + + theme_minimal() + + theme(figure_size=(12, 4), legend_position="bottom") + + labs( + title=f"{STATE} Hourly Weighted Load Curves: HP vs Non-HP", + subtitle=f"Release: {RELEASE}, upgrade={UPGRADE}", + x="Time", + y="Weighted load (kWh)", + color="Group", + ) +) +``` diff --git a/uv.lock b/uv.lock index a8fc65a..f96e6a1 100644 --- a/uv.lock +++ b/uv.lock @@ -295,6 +295,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/47/7a/613f5506189511231e17b8d1703819b76fd781f4ec73aa832192e2b7f612/buildstock_fetch-1.6.1-py3-none-any.whl", hash = "sha256:41661c55f87b00b7b26f299931364a969fd777cd9afa1b455c7355d594034b31", size = 80528987, upload-time = "2026-01-30T13:25:02.755Z" }, ] +[[package]] +name = "cairo" +version = "0.1.0" +source = { git = "https://github.com/NatLabRockies/CAIRO?rev=main#fb34a27923e72a128ffbd8f1079e7cff4bb45724" } +dependencies = [ + { name = "dask" }, + { name = "natsort" }, + { name = "nrel-pysam" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pyarrow" }, + { name = "pytest" }, +] + [[package]] name = "certifi" version = "2026.1.4" @@ -532,6 +546,24 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "dask" +version = "2026.1.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "cloudpickle" }, + { name = "fsspec" }, + { name = "packaging" }, + { name = "partd" }, + { name = "pyyaml" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bd/52/b0f9172b22778def907db1ff173249e4eb41f054b46a9c83b1528aaf811f/dask-2026.1.2.tar.gz", hash = "sha256:1136683de2750d98ea792670f7434e6c1cfce90cab2cc2f2495a9e60fd25a4fc", size = 10997838, upload-time = "2026-01-30T21:04:20.54Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e5/23/d39ccc4ed76222db31530b0a7d38876fdb7673e23f838e8d8f0ed4651a4f/dask-2026.1.2-py3-none-any.whl", hash = "sha256:46a0cf3b8d87f78a3d2e6b145aea4418a6d6d606fe6a16c79bd8ca2bb862bc91", size = 1482084, upload-time = "2026-01-30T21:04:18.363Z" }, +] + [[package]] name = "debugpy" version = "1.8.17" @@ -732,6 +764,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, ] +[[package]] +name = "fsspec" +version = "2026.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/7c/f60c259dcbf4f0c47cc4ddb8f7720d2dcdc8888c8e5ad84c73ea4531cc5b/fsspec-2026.2.0.tar.gz", hash = "sha256:6544e34b16869f5aacd5b90bdf1a71acb37792ea3ddf6125ee69a22a53fb8bff", size = 313441, upload-time = "2026-02-05T21:50:53.743Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e6/ab/fb21f4c939bb440104cc2b396d3be1d9b7a9fd3c6c2a53d98c45b3d7c954/fsspec-2026.2.0-py3-none-any.whl", hash = "sha256:98de475b5cb3bd66bedd5c4679e87b4fdfe1a3bf4d707b151b3c07e58c9a2437", size = 202505, upload-time = "2026-02-05T21:50:51.819Z" }, +] + [[package]] name = "geopandas" version = "1.1.1" @@ -1010,6 +1051,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/80/be/3578e8afd18c88cdf9cb4cffde75a96d2be38c5a903f1ed0ceec061bd09e/kiwisolver-1.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:4a48a2ce79d65d363597ef7b567ce3d14d68783d2b2263d98db3d9477805ba32", size = 70260, upload-time = "2025-08-10T21:27:36.606Z" }, ] +[[package]] +name = "locket" +version = "1.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2f/83/97b29fe05cb6ae28d2dbd30b81e2e402a3eed5f460c26e9eaa5895ceacf5/locket-1.0.0.tar.gz", hash = "sha256:5c0d4c052a8bbbf750e056a8e65ccd309086f4f0f18a2eac306a8dfa4112a632", size = 4350, upload-time = "2022-04-20T22:04:44.312Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/db/bc/83e112abc66cd466c6b83f99118035867cecd41802f8d044638aa78a106e/locket-1.0.0-py2.py3-none-any.whl", hash = "sha256:b6c819a722f7b6bd955b80781788e4a66a55628b858d347536b7e81325a3a5e3", size = 4398, upload-time = "2022-04-20T22:04:42.23Z" }, +] + [[package]] name = "markdown-it-py" version = "4.0.0" @@ -1220,6 +1270,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/3e/b8ecc67e178919671695f64374a7ba916cf0adbf86efedc6054f38b5b8ae/narwhals-2.14.0-py3-none-any.whl", hash = "sha256:b56796c9a00179bd757d15282c540024e1d5c910b19b8c9944d836566c030acf", size = 430788, upload-time = "2025-12-16T11:29:11.699Z" }, ] +[[package]] +name = "natsort" +version = "8.4.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e2/a9/a0c57aee75f77794adaf35322f8b6404cbd0f89ad45c87197a937764b7d0/natsort-8.4.0.tar.gz", hash = "sha256:45312c4a0e5507593da193dedd04abb1469253b601ecaf63445ad80f0a1ea581", size = 76575, upload-time = "2023-06-20T04:17:19.925Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/82/7a9d0550484a62c6da82858ee9419f3dd1ccc9aa1c26a1e43da3ecd20b0d/natsort-8.4.0-py3-none-any.whl", hash = "sha256:4732914fb471f56b5cce04d7bae6f164a592c7712e1c85f9ef585e197299521c", size = 38268, upload-time = "2023-06-20T04:17:17.522Z" }, +] + [[package]] name = "nbclient" version = "0.10.2" @@ -1259,6 +1318,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" }, ] +[[package]] +name = "nrel-pysam" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/c0/fd31ad86a5bf030b7cc0b85a7ee05eebb6badbd6b39ab1318fa16b82003f/NREL_PySAM-7.1.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5fade062ebc016c355182d8b1cec6ff8666e91e4bb31a57aeacab51ba19d6fa7", size = 35479725, upload-time = "2025-08-11T16:26:33.652Z" }, + { url = "https://files.pythonhosted.org/packages/44/fe/a301f0f0e4d66f0ff5c2198f506925eb0de1301da5bac7debea3db5de263/NREL_PySAM-7.1.0-cp312-cp312-macosx_12_0_x86_64.whl", hash = "sha256:33ab57a6c33752426ef1ef33b88bc4765d62f7cbcd900cb9d80bef6c5cc6c59f", size = 36258468, upload-time = "2025-08-11T16:40:50.321Z" }, + { url = "https://files.pythonhosted.org/packages/69/1d/f04d4e88ebc56d789795cb5c532feed1034d69860fa294b57f4d2435d37d/NREL_PySAM-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:011c303535a86f6f574a05801ba93321ebc714d1a711f2bd80a3e61e2adc2b6d", size = 32853532, upload-time = "2025-08-11T16:40:58.372Z" }, + { url = "https://files.pythonhosted.org/packages/33/3f/b83a791cb0b930739c4572b91016f47443d3f21f787960aa786210fc5189/NREL_PySAM-7.1.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:2e03066c5f48a33ccb8f0d2257da60fe8864a2701f510a7c347fb274790078fa", size = 35476841, upload-time = "2025-08-11T16:26:41.147Z" }, + { url = "https://files.pythonhosted.org/packages/1a/4c/f91306e5046e161adb79a808255380afecff587b911f0f5a8e6866048caf/NREL_PySAM-7.1.0-cp313-cp313-macosx_12_0_x86_64.whl", hash = "sha256:ec394d7be641b305cdc8e29777d34c1f3993904e7cf56666bd40264b53d61e94", size = 36278249, upload-time = "2025-08-11T16:41:03.312Z" }, + { url = "https://files.pythonhosted.org/packages/65/5b/50d7bab99f4608846d0376c131365ff1eaa41557a74f4657632caeef68bf/NREL_PySAM-7.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:ce4f7560db91e8b635a553ab2a809ca9f67b49d5110629eeafc1bce8b14dac80", size = 32854147, upload-time = "2025-08-11T16:41:14.654Z" }, + { url = "https://files.pythonhosted.org/packages/a0/47/7ee0e7684e6cb10474ca1a62866fb53e51bcafd7ecbcf2f4f2a277fa0559/nrel_pysam-7.1.0-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:9887172608b10327f4a0a0ec412fb523d2d9a733e31085cb8ffda776b39d1135", size = 45198587, upload-time = "2025-08-11T16:26:37.347Z" }, + { url = "https://files.pythonhosted.org/packages/05/e9/4877239dec2b52323d995eac565607c4dc4e7bcfda8b97dfbd001d04d77e/nrel_pysam-7.1.0-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:0ae907e2903fdd90dea45e73b04017986799ba11456c5cc45d80fb6c291f5c66", size = 47254264, upload-time = "2025-08-11T16:40:54.418Z" }, + { url = "https://files.pythonhosted.org/packages/58/75/cb75294901ece360b537e5ca4a91fc5d829d2efe32760bc1da418b4e3375/nrel_pysam-7.1.0-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:74d3c090711795b755d5f390d54e64f9e42b2bee5f33a5ecbe59e15ded3c41dd", size = 45196490, upload-time = "2025-08-11T16:26:44.909Z" }, + { url = "https://files.pythonhosted.org/packages/81/58/c860ed724993aca7748770a351dc5eaa9612c81435d40cfcf115362b3fb7/nrel_pysam-7.1.0-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:0290f10db930367d688dc486aceb160a05cc285a0fb4df7096a78398356a00a4", size = 47251341, upload-time = "2025-08-11T16:41:06.753Z" }, +] + [[package]] name = "numpy" version = "2.3.4" @@ -1387,6 +1463,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/16/32/f8e3c85d1d5250232a5d3477a2a28cc291968ff175caeadaf3cc19ce0e4a/parso-0.8.5-py2.py3-none-any.whl", hash = "sha256:646204b5ee239c396d040b90f9e272e9a8017c630092bf59980beb62fd033887", size = 106668, upload-time = "2025-08-23T15:15:25.663Z" }, ] +[[package]] +name = "partd" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "locket" }, + { name = "toolz" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b2/3a/3f06f34820a31257ddcabdfafc2672c5816be79c7e353b02c1f318daa7d4/partd-1.4.2.tar.gz", hash = "sha256:d022c33afbdc8405c226621b015e8067888173d85f7f5ecebb3cafed9a20f02c", size = 21029, upload-time = "2024-05-06T19:51:41.945Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/e7/40fb618334dcdf7c5a316c0e7343c5cd82d3d866edc100d98e29bc945ecd/partd-1.4.2-py3-none-any.whl", hash = "sha256:978e4ac767ec4ba5b86c6eaa52e5a2a3bc748a2ca839e8cc798f1cc6ce6efb0f", size = 18905, upload-time = "2024-05-06T19:51:39.271Z" }, +] + [[package]] name = "patsy" version = "1.0.2" @@ -2043,6 +2132,7 @@ dependencies = [ { name = "beautifulsoup4" }, { name = "boto3" }, { name = "buildstock-fetch" }, + { name = "cairo" }, { name = "geopandas" }, { name = "ipykernel" }, { name = "nbclient" }, @@ -2076,6 +2166,7 @@ requires-dist = [ { name = "beautifulsoup4", specifier = ">=4.14.2" }, { name = "boto3", specifier = ">=1.7.84" }, { name = "buildstock-fetch", specifier = ">=1.6.1" }, + { name = "cairo", git = "https://github.com/NatLabRockies/CAIRO?rev=main" }, { name = "geopandas", specifier = ">=1.0.0" }, { name = "ipykernel", specifier = ">=6.30.1" }, { name = "nbclient", specifier = ">=0.10.2" }, @@ -2513,6 +2604,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/77/b8/0135fadc89e73be292b473cb820b4f5a08197779206b33191e801feeae40/tomli-2.3.0-py3-none-any.whl", hash = "sha256:e95b1af3c5b07d9e643909b5abbec77cd9f1217e6d0bca72b0234736b9fb1f1b", size = 14408, upload-time = "2025-10-08T22:01:46.04Z" }, ] +[[package]] +name = "toolz" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/11/d6/114b492226588d6ff54579d95847662fc69196bdeec318eb45393b24c192/toolz-1.1.0.tar.gz", hash = "sha256:27a5c770d068c110d9ed9323f24f1543e83b2f300a687b7891c1a6d56b697b5b", size = 52613, upload-time = "2025-10-17T04:03:21.661Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/12/5911ae3eeec47800503a238d971e51722ccea5feb8569b735184d5fcdbc0/toolz-1.1.0-py3-none-any.whl", hash = "sha256:15ccc861ac51c53696de0a5d6d4607f99c210739caf987b5d2054f3efed429d8", size = 58093, upload-time = "2025-10-17T04:03:20.435Z" }, +] + [[package]] name = "tornado" version = "6.5.2"