|
| 1 | +import warnings |
| 2 | +from pathlib import Path |
| 3 | +from datetime import datetime |
| 4 | + |
| 5 | +import attrs |
| 6 | +import numpy as np |
| 7 | +import pandas as pd |
| 8 | +from attrs import field, define |
| 9 | + |
| 10 | +from h2integrate.preprocess import eia, geospatial |
| 11 | +from h2integrate.core.utilities import merge_shared_inputs |
| 12 | +from h2integrate.core.file_utils import get_path |
| 13 | +from h2integrate.core.validators import range_val |
| 14 | +from h2integrate.feedstocks.feedstocks import FeedstockCostModel |
| 15 | +from h2integrate.core.model_baseclasses import BaseConfig |
| 16 | + |
| 17 | + |
| 18 | +HOURS_PER_YEAR = 8760 |
| 19 | +SECONDS_PER_HOUR = 3600 |
| 20 | +CURRENT_YEAR = datetime.now().year |
| 21 | + |
| 22 | +default_price = pd.DataFrame( |
| 23 | + np.zeros(8760, dtype=float).reshape(-1, 1), |
| 24 | + columns=["price"], |
| 25 | + index=pd.date_range("2001-01-01", "2001-12-31 23:00:00", freq="h"), |
| 26 | +) |
| 27 | + |
| 28 | + |
| 29 | +@define |
| 30 | +class EIANaturalGasFeedstockConfig(BaseConfig): |
| 31 | + """EIA Industrial Natural Gas Pricing API configuration and downloader for the US and all 50 US |
| 32 | + states, in $/MCF, converted to $/MMBtu. Please see |
| 33 | + https://www.eia.gov/opendata/browser/natural-gas/pri/sum for further details about data |
| 34 | + availability. |
| 35 | +
|
| 36 | + Args: |
| 37 | + state (str): Full name of the state or two-letter state abbreviation, such as |
| 38 | + "United States" or "US". Only the "US" or all 50 states will produce valid results. |
| 39 | + resource_year (int): The YYYY-format year whose data should be retrieved. Must be between |
| 40 | + 2001 and the current year, inclusive of endpoints. |
| 41 | + cost_year (int): dollar-year for costs. Defaults to the current year. |
| 42 | + monthly (Path): True, if monthly data is desired, False if annual data is desired. |
| 43 | + price_category (str): One of "wellhead", "imports", "citygate", "residential", "commercial", |
| 44 | + "industrial", "electrical_power", or "exports". Note that not all categories will return |
| 45 | + state-level data. |
| 46 | + api_key_file (Path, optional): Full file name of the file where the API key is located. If |
| 47 | + no file name is provided, then the environment variables ``EIA_API_KEY`` is used. |
| 48 | + latitude (float | None): WGS-84 y-coordinate of the site. Will not be used if |
| 49 | + :py:attr:`state` has been provided. |
| 50 | + longitude (float | None): WGS-84 x-coordinate of the site. Will not be used if |
| 51 | + :py:attr:`state` has been provided. |
| 52 | + site_name (str, optional): Name of the site from a multi-site private configuration. When |
| 53 | + provided, the :py:class:`EIANaturalGasFeedstockCostModel` will populate the |
| 54 | + :py:attr:`latitude` and :py:attr:`longitude` from the plant site configuration. |
| 55 | + filename (str, optional): Filename for where to save the data or where the data may |
| 56 | + already be located. If the file exists, the columns "period", "state", and "price" must |
| 57 | + exist, otherwise the file will not be used. "period" should be of the form YYYY or |
| 58 | + YYYY-MM, and state should be either the full state name or the two-letter abbreviation. |
| 59 | + annual_cost (float, optional): fixed cost associated with the feedstock in USD/year. |
| 60 | + Defaults to 0.0. |
| 61 | + start_up_cost (float, optional): one-time capital cost associated with the feedstock in USD. |
| 62 | + Defaults to 0.0. |
| 63 | + """ |
| 64 | + |
| 65 | + resource_year: int = field(validator=attrs.validators.in_(range(2001, CURRENT_YEAR + 1))) |
| 66 | + monthly: bool = field(validator=attrs.validators.instance_of(bool)) |
| 67 | + price_category: str = field( |
| 68 | + converter=str.lower, validator=attrs.validators.in_(eia.EIA_NG_FACET) |
| 69 | + ) |
| 70 | + api_key_file: str | None = field(default=None, converter=attrs.converters.optional(get_path)) |
| 71 | + state: str = field( |
| 72 | + default=None, |
| 73 | + converter=attrs.converters.optional( |
| 74 | + attrs.converters.pipe(geospatial.convert_state_value, geospatial.convert_state_to_code) |
| 75 | + ), |
| 76 | + validator=attrs.validators.optional( |
| 77 | + attrs.validators.in_([*geospatial.US_STATE_MAP, *geospatial.US_STATE_MAP.values()]) |
| 78 | + ), |
| 79 | + ) |
| 80 | + latitude: float | None = field( |
| 81 | + default=None, validator=attrs.validators.optional(range_val(-90.0, 90.0)) |
| 82 | + ) |
| 83 | + longitude: float | None = field( |
| 84 | + default=None, validator=attrs.validators.optional(range_val(-180.0, 180.0)) |
| 85 | + ) |
| 86 | + site_name: str = field( |
| 87 | + default=None, validator=attrs.validators.optional(attrs.validators.instance_of(str)) |
| 88 | + ) |
| 89 | + cost_year: int = field(default=CURRENT_YEAR) |
| 90 | + annual_cost: float = field(default=0.0, converter=float) |
| 91 | + start_up_cost: float = field(default=0.0, converter=float) |
| 92 | + filename: str = field(default=None) |
| 93 | + |
| 94 | + commodity: str = field(default="natural_gas", init=False) |
| 95 | + commodity_rate_units: str = field(default="MMBtu/h", init=False) |
| 96 | + commodity_amount_units: str = field(default="MMBtu", init=False) |
| 97 | + price: pd.DataFrame = field( |
| 98 | + default=default_price, init=False, validator=attrs.validators.instance_of(pd.DataFrame) |
| 99 | + ) |
| 100 | + |
| 101 | + def __attrs_post_init__(self): |
| 102 | + """Creates the EIA natural gas facet series code based on validated user inputs, sets the |
| 103 | + :py:attr:`commodity_amount_units` if not given a value, and fetches the EIA natural gas |
| 104 | + price. |
| 105 | + """ |
| 106 | + if self.filename is not None: |
| 107 | + try: |
| 108 | + self.filename = get_path(self.filename) |
| 109 | + except FileNotFoundError: |
| 110 | + self.filename = Path(self.filename).resolve() |
| 111 | + |
| 112 | + if self.state is None: |
| 113 | + if self.latitude is None or self.longitude is None: |
| 114 | + msg = ( |
| 115 | + "The EIA natural gas feedstock model require one of `state` or" |
| 116 | + " `latitude` and `longitude`." |
| 117 | + ) |
| 118 | + raise ValueError(msg) |
| 119 | + |
| 120 | + self.state = geospatial.get_state_from_coords( |
| 121 | + latitude=self.latitude, longitude=self.longitude |
| 122 | + ) |
| 123 | + |
| 124 | + |
| 125 | +class EIANaturalGasFeedstockCostModel(FeedstockCostModel): |
| 126 | + """Feedstock cost model based on the EIA natural gas price API results that uses |
| 127 | + annual or monthly data to model an hourly time step for a single year to model the |
| 128 | + price of natural gas used in the model. |
| 129 | +
|
| 130 | + The model will pull the single site data if it is made available to pass the ``latitude`` and |
| 131 | + ``longitude`` from the site configuration, otherwise the ``state`` will need to be provided |
| 132 | + in the configuration. |
| 133 | + """ |
| 134 | + |
| 135 | + def setup(self): |
| 136 | + """Defines the inputs and outputs of the model and converts the |
| 137 | + :py:attr:`EIANaturalGasFeedstockConfig.price` to an hourly timeseries for the |
| 138 | + ``plant_life``. |
| 139 | +
|
| 140 | + Populates the site latitude and longitude with either the configuration's provided site |
| 141 | + name or the first site in plant configuration's ``sites`` dictionary when a ``state`` is |
| 142 | + not directly provided. |
| 143 | + """ |
| 144 | + site_config = {} |
| 145 | + sites = self.options["plant_config"].get("sites", {}) |
| 146 | + cost_config = merge_shared_inputs(self.options["tech_config"]["model_inputs"], "cost") |
| 147 | + |
| 148 | + if cost_config.get("state") is None: |
| 149 | + if (site_name := cost_config.get("site_name")) is not None and sites: |
| 150 | + site_config = sites.get(site_name, {}) |
| 151 | + if site_name is None and sites: |
| 152 | + site_config = sites[[*sites][0]] |
| 153 | + |
| 154 | + self.config = EIANaturalGasFeedstockConfig.from_dict( |
| 155 | + cost_config | site_config, additional_cls_name=self.__class__.__name__, strict=False |
| 156 | + ) |
| 157 | + |
| 158 | + price = eia.get_eia_ng_data( |
| 159 | + api_key_file=self.config.api_key_file, |
| 160 | + resource_year=self.config.resource_year, |
| 161 | + price_category=self.config.price_category, |
| 162 | + state=self.config.state, |
| 163 | + monthly=self.config.monthly, |
| 164 | + filename=self.config.filename, |
| 165 | + ) |
| 166 | + price = eia.convert_to_hourly(price) |
| 167 | + self.config.price = price |
| 168 | + super().setup() |
| 169 | + |
| 170 | + def compute(self, inputs, outputs): |
| 171 | + if not np.isclose(inputs["price"], self.config.price, rtol=1e-6): |
| 172 | + warn_msg = "The NG price has changed from EIA price. This may be intended." |
| 173 | + warnings.warn(warn_msg, UserWarning) |
| 174 | + super().compute(inputs, outputs) |
0 commit comments