Skip to content

Implement daily temperature-based gas price adjustments #65

Open
SoLaraS2 wants to merge 117 commits into
mainfrom
ko/temp_based_natgas_prices
Open

Implement daily temperature-based gas price adjustments #65
SoLaraS2 wants to merge 117 commits into
mainfrom
ko/temp_based_natgas_prices

Conversation

@SoLaraS2

@SoLaraS2 SoLaraS2 commented Apr 28, 2026

Copy link
Copy Markdown
Collaborator

Summary

This PR calculates daily gas price multipliers based on a linear regression fitting heating and cooling degree days on daily deviations from annual regional gas prices (the parameters of which are in inputs/fuelprices/gasreg_price_adj_regression_parameters.csv). The user can choose between these daily regional multipliers, the existing national wintertime markup, and no adjustment.

Technical details

  • Added gasreg hierarchy level, which represents the regions at which the degree day / price deviation regressions were fitted. These are mostly just census divisions, with the exceptions being "California" and "Northwest", which are components of the "Pacific" cendiv, and "Southwest" and "Mountain", which are components of the "Mountain" cendiv.
  • Daily gasreg-level gas price multipliers are calculated in reeds/input_processing/fuelcostprep.py by calculating state-level daily degree days for the weather years of the given run and aggregating them via population-weighted average. To create multipliers at the r level, the gasreg-level multipliers are copied to their constituent zones. To create multipliers at the cendiv level, the gasreg-level multipliers are aggregated via population-weighted average.
  • Added get_daily_gas_price_multipliers function to hourly_writetimeseries.py to get a GAMS compatible csv with representative hourly NG price multipliers. In this function, the multipliers for each gasreg are renormalized so that the average across the representative periods is 1, ensuring the year-round average gas price doesn't change.

Additional changes

  • Added a new switch GSw_GasPriceAdjMethod which controls whether the daily price multipliers are applied, the national wintertime markup is applied, or no adjustment is applied.
  • In main, the wintertime markup of 1.054 (szn_adj_gas_winter in scalars.csv) mentioned in the model documentation is only being applied when GSw_GasCurve = 1. A separate wintertime multiplier of 1.04 (gasprice_ref_frac_adder in scalars.csv) is being applied when GSw_GasCurve = 3. No multiplier is applied when GSw_GasCurve = 0 or 2. In this PR, the gasprice_ref_frac_adder multiplier is deleted and the szn_adj_gas multiplier is applied in every GSw_GasCurve scenario (assuming GSw_GasPriceAdjMethod = 1).
  • In main, the wintertime markup only increases the wintertime price but doesn't make corresponding adjustments to maintain the pre-markup year-round average price. As a result, the year-round natural gas price is being erroneously inflated. In this PR, the seasonal multipliers are renormalized so that wintertime prices are higher than nonwinter prices while the year-round average price remains unchanged.

Switches added/removed/changed

  • Added GSw_GasPriceAdjMethod (0 = no adjustment, 1 = national wintertime markup, 2 = daily adjustments based on regional temperatures)

Validation, testing, and comparison report(s)

Results comparing main and this branch using different GSw_GasPriceAdjMethod and GSw_GasCurve values are below.

GSw_GasCurve = 1 - More gas is built due to the fact that the wintertime markup is now renormalized so that the year-round average gas price remains unchanged (previously we only increased the wintertime gas price without a commensurate decrease in the non-wintertime price, which increased the year-round average price). The non-adjustment and seasonal adjustment cases see up to a ~30 TWh increase in gas generation, while the daily price adjustment case sees up to a ~60 TWh increase.

GSw_GasCurve = 2 - Differences range from no change to more gas buildout. The non-adjustment case results in no changes, which makes sense because no multiplier was being applied initially. The seasonal adjustment case has slightly lower gas generation in early years and slightly higher gas and wind generation replacing solar in later years. The daily adjustment case sees up to a ~50 TWh increase in gas generation.

GSw_GasCurve = 3 - Differences are fairly minimal across the board. The largest change is in the non-adjustment case, where 2040 sees a ~6 TWh change in generation, which is mostly a shift from gas-cc to gas-ct. The other cases just have changes on the scale of rounding errors.

GSw_GasCurve = 0 - Differences range from no change to more gas buildout. The non-adjustment case results in no changes, which makes sense because no multiplier was being applied initially. The seasonal adjustment case sees up to a ~20 TWh increase in gas generation, and the daily adjustment case sees up to a ~70 TWh increase.

Checklist for author

Details to double-check

General information to guide review

  • Zero impact on results of default case
  • No large data file(s) added/modified
  • No substantive impact on runtime for full-US reference case
  • No substantive impact on folder size for full-US reference case
  • No change to process flow (runbatch.py, d_solve_iterate.py)
  • No change to code organization
  • No change to package requirements (environment.yml or Project.toml)

Did you use LLM tools (chatbot or copilot) in the preparation of this PR? If so, describe how

  • LLMs used for timestamp compatibility between different outputs to ensure consistency in the final csv outputted
  • LLMs used to run through the files and find relevant predefined functions I (Lara) may have originally missed
  • LLMs used to debug h5 file handling

Tag points of contact here if you would like additional review of the relevant parts of the model

Comment thread reeds/io.py Outdated
Comment thread inputs/zones/state_groups.csv Outdated
Comment thread reeds/io.py Outdated
Comment thread input_processing/hourly_writetimeseries.py Outdated
@kodiobika kodiobika self-requested a review April 29, 2026 15:14
@kodiobika kodiobika marked this pull request as draft April 29, 2026 15:14

@kodiobika kodiobika left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for your work on this! It's looking great so far -- mostly just some stylistic suggestions

Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
Comment thread input_processing/fuelcostprep.py Outdated
@kodiobika kodiobika marked this pull request as ready for review July 8, 2026 16:30
@kodiobika

kodiobika commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

@patrickbrown4 @wesleyjcole I'm in the middle of creating new comparison reports, but the code is ready for re-review whenever you get a chance, thanks!

@kodiobika kodiobika requested review from kodiobika and removed request for kodiobika July 8, 2026 16:34
@kodiobika kodiobika dismissed their stale review July 8, 2026 16:36

Am assignee

@kodiobika kodiobika removed their request for review July 8, 2026 16:37
@kodiobika

Copy link
Copy Markdown
Contributor

@patrickbrown4 @wesleyjcole I'm in the middle of creating new comparison reports, but the code is ready for re-review whenever you get a chance, thanks!

Oop actually there's a bug in report.gms but everything else is ready for review

@kodiobika

Copy link
Copy Markdown
Contributor

@patrickbrown4 @wesleyjcole I'm in the middle of creating new comparison reports, but the code is ready for re-review whenever you get a chance, thanks!

Oop actually there's a bug in report.gms but everything else is ready for review

Should be resolved now

@patrickbrown4 patrickbrown4 self-requested a review July 9, 2026 18:31

@patrickbrown4 patrickbrown4 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revisions! A couple small comments but otherwise looks good to me.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I could remake this figure for the 90 zones, but we'll probably end up wanting to redo the layout (both of the figure and the discussion in the docs) to illustrate the different possible resolutions, so I think it can wait for a followup PR


The natural gas fuel prices also include a seasonal price adjustor, making winter prices higher than the natural gas prices seen during the other seasons of the year.
The natural gas fuel prices also include time-based price adjustors.
One option is a seasonal price adjustor, which makes winter prices higher than the natural gas prices seen during the other seasons of the year CONUS-wide.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
One option is a seasonal price adjustor, which makes winter prices higher than the natural gas prices seen during the other seasons of the year CONUS-wide.
The default option is a seasonal price adjustor, which makes winter prices higher than the natural gas prices seen during the other seasons of the year CONUS-wide.

Comment on lines +530 to +531
*scale back to $ / mmbtu and apply annual consumption-weighted gas price multipliers
gas_consumption(cendiv,gb,t) = sum{h, GASUSED.l(cendiv,gb,h,t) } ;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since gas_scale is used here, and from my read of eq_gasused, it looks like we need to multiply by gas_scale to get the units in MMBtu:

repgasquant(cendiv,t)$[(Sw_GasCurve = 0 or Sw_GasCurve = 3)$tmodel_new(t)] =
sum{(gb,h), GASUSED.l(cendiv,gb,h,t) * hours(h) } * gas_scale/ 1e9 ;

@kodiobika kodiobika Jul 10, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh that's weird, I actually took the units from the description of GASUSED in c_model.gms (MMBtu/hour). I think this implies that the units of either GASUSED or repgasquant must be labelled incorrectly, right? Regarding gas_consumption, my understanding is the units end up not mattering (except for documentation purposes in report_params.csv) since we only use it to

  • check if consumption is non-zero for a given gas bin (in the gb$[gas_consumption(cendiv,gb,t)] conditional) and
  • calculate consumption weights for each hour (GASUSED.l(cendiv,gb,h,t) / gas_consumption(cendiv,gb,t))

Does that sound right to you? And assuming it's correct that the units of gas_consumption don't matter, I think we should end up with whatever units for repgasprice we had before (but will double-check), which is labelled as $/mmbtu but now I'm not sure what they actually are

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's probably safest to be sure about the units for gas_consumption, since if it's included as an output parameter (which it will be if it's defined in report_params.csv), someone will probably use it someday.

I missed it before but I think you're right that it's currently off; since GASUSED is in [MMBtu/hour], it should be multiplied by hours when taking the sum.

The gas_scale makes things weird; it's a 1e6 scaling factor but is only to keep the gas variables to a similar scale as the other model variables. So:

  • GASUSED = heat_rate [MMBtu/MWh] * GEN [MW] / gas_scale [1e6] = 1e-6*[MMBtu/hour]
  • repgasquant = GASUSED (1e-6*[MMBtu/hour]) * hours [hour] * gas_scale (1e6) * 1e-9 [quad/MMBtu] = [quad]
  • gas_consumption = GASUSED (1e-6*[MMBtu/hour]) * hours [hour] = 1e-6*[MMBtu]

So if you add * gas_scale to the definition of gas_consumption, you'll get gas_consumption in [MMBtu], as stated in report_params.csv:

  • gas_consumption(cendiv,gb,t) = sum{h, GASUSED.l(cendiv,gb,h,t) * hours(h) * gas_scale } ;
  • [MMBtu] = 1e-6*[MMBtu/hour] * [hour] * 1e6 = [MMBtu]

For repgasprice, note that gasprice is [$/MMBtu] multiplied by gas_scale in b_inputs.gms, so its values are on the order of 1e6. So if you do it as:

  • repgasprice = gasprice (1e6*[$/MMBtu]) * GASUSED (1e-6*[MMBtu/hour]) * hours [hour] / gas_consumption [MMBtu]

(after gas_consumption has been descaled to [MMBtu]), then you should just get [$/MMBtu], without the use of gas_scale in the repgasprice equation.

It does look like the hours is currently in the wrong place in the repgasprice equation; if GASUSED is [MMBtu/hour] and gas_consumption is [MMBtu] then it should be in the numerator instead of the denominator.

At least that's my current take; should take another look on Monday!

"flex_load_out(flex_type,r,allh,t)",MWh,flexible load consumed in each timeslice,,,
forced_outage(i),fraction,average forced outage rate (h-weighted; r-unweighted) by technology during representative periods,,,
planned_outage(i),fraction,average scheduled outage rate (h-weighted) by technology during representative periods,,,
"gas_consumption(cendiv,gb,t)",MMBtu,annual gas used by census division and gas bin,,,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See note on report.gms about double-checking the use of gas_scale to make sure the units are in MMBtu

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for adding these!
I don't have a good system for remembering to do this. Maybe we should add another checkbox to "Details to double-check" in the PR template. (Not here, just thinking out loud.)

Comment thread inputs/scalars.csv
csp_sm_3,1.3,"--fraction-- solar multiple for csp3"
csp_sm_4,1.0,"--fraction-- solar multiple for csp4"
csp_sm_baseline,2.4,"--fraction-- baseline solar multiple to normalize other CSP solar multiples by"
degree_days_base_temperature,18.33,"--temperature in degrees Celsius to use as the baseline temperature for calculating degree days"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could add a note on the source:

Suggested change
degree_days_base_temperature,18.33,"--temperature in degrees Celsius to use as the baseline temperature for calculating degree days"
degree_days_base_temperature,18.33,"--temperature in degrees Celsius to use as the baseline temperature for calculating degree days (65°F from https://www.eia.gov/energyexplained/units-and-calculators/degree-days.php)"

cdd_hourly = (temp_hourly - base_temp).clip(lower=0)
cdd_daily = cdd_hourly.resample('D').mean()

return hdd_daily, cdd_daily

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Slight preference to instead return a {'hdd':hdd_daily, 'cdd':cdd_daily} dict so the outputs are self-documenting and to help avoid mixups

smax{gb$[gas_consumption(cendiv,gb,t)],
gasprice(cendiv,gb,t)
* sum{h, gasprice_adj_cendiv(cendiv,h) * GASUSED.l(cendiv,gb,h,t) / gas_consumption(cendiv,gb,t) / hours(h) }
} / gas_scale ;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If gas_scale is added above, it'd be removed here

*scale back to $ / mmbtu
*scale back to $ / mmbtu and apply annual consumption-weighted gas price multipliers
gas_consumption(cendiv,gb,t) = sum{h, GASUSED.l(cendiv,gb,h,t) } ;
repgasprice(cendiv,t)$[(Sw_GasCurve = 0)$tmodel_new(t)$repgasquant(cendiv,t)] =

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to double check, do these all look right (expected order of magnitude) in the test runs you did? (No need to add a bunch of plots to the PR, a quick scan of the output file is enough)

I've added a reminder to #34 to add an output plot for gas price (eventually, not here)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants