Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
781fb2d
fix Produced Temperature missing JSON output bug (erroneous output pa…
softwareengineerprogrammer Jan 17, 2026
42d1ceb
use correct Produced Temperature output now that underlying JSON outp…
softwareengineerprogrammer Jan 17, 2026
091a60b
include production temperature graph in results - WIP to add section …
softwareengineerprogrammer Jan 17, 2026
87d9617
prep to include total generation in power production graph
softwareengineerprogrammer Jan 17, 2026
eb05e0d
Fix duplicate output keys for total generation/production in SurfaceP…
softwareengineerprogrammer Jan 17, 2026
4238ffa
include total electricity production in graph
softwareengineerprogrammer Jan 17, 2026
da0082a
add _get_redrilling_event_indexes in preparation to show in productio…
softwareengineerprogrammer Jan 17, 2026
a9ca534
align graph year indexing with cash flow: COD = Year 1
softwareengineerprogrammer Jan 17, 2026
105a62e
indicate redrilling events (fractional years) in production temperatu…
softwareengineerprogrammer Jan 17, 2026
45596a8
plotting order to render temperature curve on top
softwareengineerprogrammer Jan 17, 2026
21d5f5e
update documentation for production temperature graph
softwareengineerprogrammer Jan 17, 2026
20423a7
link to Production Temperature results from calibration methodology s…
softwareengineerprogrammer Jan 17, 2026
9dd3836
revert accidental partial deletion of references and footnotes sectio…
softwareengineerprogrammer Jan 17, 2026
1761b66
'Profile' instead of 'Curve'
softwareengineerprogrammer Jan 17, 2026
4bf30c6
exclude .json from example file matching
softwareengineerprogrammer Jan 17, 2026
62eede4
include power production profile graph in intro section
softwareengineerprogrammer Jan 17, 2026
8c20c04
fix results section link (if link text is results it overrides the re…
softwareengineerprogrammer Jan 17, 2026
ea94a32
fix production temperature section link
softwareengineerprogrammer Jan 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 23 additions & 9 deletions docs/Fervo_Project_Cape-5.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,14 @@ on Phases I and II of [Fervo Energy's Cape Station](https://capestation.com/).

Key case study results include LCOE = {{ '$' ~ lcoe_usd_per_mwh ~ '/MWh' }} and IRR = {{ irr_pct ~ '%' }}. ([Jump to Results section](#results)).

.. raw:: html

<img src="_images/fervo_project_cape-5-power-production.png" class="no-active" style="max-width: 50%"
title="See the Results section for details."
alt="Power Production Profile graph" />

[Click here](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=Fervo_Project_Cape-5) to
interactively explore the case study in the GEOPHIRES web interface.
interactively explore the case study example in the GEOPHIRES web interface.

### Modeling Overview: A Conservative, Second-of-a-Kind Analog

Expand Down Expand Up @@ -120,6 +126,9 @@ While the initial and final (Year 15) temperatures are consistent, the productio

Despite these structural differences, the comparison validates the basis for the case study's reservoir engineering parameters, as the aggregate heat extraction and year-15 endpoint align closely with the numerical simulation baseline.

The calibration simulation above represents a 15-year unmitigated thermal decline without redrilling.
In the full case study results, the model includes redrilling events that restore production temperature when drawdown thresholds are reached,
resulting in the cyclical profile shown in the [Production Temperature section](#production-temperature-profile) below.

## Results

Expand Down Expand Up @@ -180,18 +189,23 @@ See [GEOPHIRES output parameters documentation](parameters.html#economic-paramet
| Total wells drilled over project lifetime | {{ total_wells_including_redrilling }} | 320 | Total wells permitted by environmental assessment (BLM, 2024). |
{# @formatter:on #}

#### Production Temperature Profile

<img src="_images/fervo_project_cape-5-production-temperature.png" class="no-active" style="max-width: 66%" />

The production temperature profile exhibits distinctive cyclical behavior driven by the interaction between wellbore physics and reservoir thermal evolution:

1. **Thermal Conditioning (Years 1–6)**: The initial rise in production temperature, peaking at approximately 203°C, is driven by the thermal conditioning of the production wellbores. As hot geofluid continuously flows through the wells, the wellbore casing and surrounding rock heat up, reducing conductive heat loss as predicted by the Ramey wellbore model.
2. **Reservoir Drawdown (Years 6–8)**: Following the conditioning peak, temperature declines as the cold front from injection wells reaches the production zone (thermal breakthrough), reducing the produced fluid enthalpy.
3. **Redrilling (End of Years 8, 16, 24)**: The model triggers a redrilling event when the *next* time step's temperature would fall below the threshold defined by the `Maximum Drawdown` parameter (shown as the dashed orange line). As visible in the graph, the production temperature never actually reaches the threshold; redrilling preemptively restores the wellfield before that occurs. The cost of these events is amortized as an operational expense over the project lifetime.

#### Power Production Curve
{# TODO #}
{#![caption](_images/fervo_project_cape-5-production-temperature.png)#}
#### Power Generation Profile

![caption](_images/fervo_project_cape-5-net-power-production.png)
<img src="_images/fervo_project_cape-5-power-production.png" class="no-active" style="max-width: 66%" />

The project's generation profile (as seen in the graph above) exhibits distinctive cyclical behavior driven by the interaction between wellbore physics, reservoir thermal evolution, and economic constraints:
Power generation is a direct function of production temperature, so the power production profile mirrors the thermal behavior described above. The graph shows both total (gross) electricity generation and net electricity generation after parasitic losses. The gap between the two curves represents the energy consumed by the circulation pumps.

1. Thermal Conditioning (Years 1-5): The initial rise in net power production, peaking at approximately 540 MW, is driven by the thermal conditioning of the production wellbores. As hot geofluid continuously flows through the wells, the wellbore casing and surrounding rock heat up, reducing conductive heat loss as predicted by the Ramey wellbore model.
1. Reservoir Drawdown (Years 5-8): Following the conditioning peak, power output declines as the cold front from injection wells reaches the production zone (thermal breakthrough), reducing the produced fluid enthalpy.
1. Redrilling (Years 8, 16, 24): To ensure the facility meets its 500 MW net PPA obligation, the model triggers redrilling events when production drops near the contractual minimum (corresponding to production temperature declining below the threshold defined by the `Maximum Drawdown` parameter value, as a percentage of the initial production temperature). These events simulate the redrilling of the entire wellfield to restore temperature and output. Their cost is amortized as an operational cost over the project lifetime.
The horizontal reference lines indicate the 500 MW net PPA minimum production requirement and the 600 MW nameplate capacity (combined capacity of the individual ORC units).

### Sensitivity Analysis

Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file not shown.
Binary file modified docs/_images/fervo_project_cape-5-production-temperature.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
129 changes: 98 additions & 31 deletions src/geophires_docs/generate_fervo_project_cape_5_graphs.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import json
from pathlib import Path
from typing import Any

import numpy as np
from matplotlib import pyplot as plt
Expand All @@ -24,12 +25,12 @@ def _get_full_net_production_profile(input_and_result: tuple[GeophiresInputParam
return _get_full_profile(input_and_result, 'Net Electricity Production')


def _get_full_total_electricity_generation_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
return _get_full_profile(input_and_result, 'Total Electricity Production')


def _get_full_production_temperature_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
return _get_full_profile(
input_and_result,
#'Produced Temperature'
'Reservoir Temperature History',
)
return _get_full_profile(input_and_result, 'Produced Temperature')


def _get_full_thermal_drawdown_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
Expand All @@ -49,55 +50,94 @@ def _get_full_profile(input_and_result: tuple[GeophiresInputParameters, Geophire
return profile


def generate_net_power_graph(
def _get_redrilling_event_indexes(
input_and_result: tuple[GeophiresInputParameters, GeophiresXResult], threshold_degc: float = 1.0
) -> list[float]:
"""
Detect redrilling events from a production temperature profile.

A redrilling event is identified when a datapoint's temperature is more than
`threshold_degc` higher than the previous datapoint (indicating a sudden temperature
recovery from drilling new wells).

TODO include redrilling events in GEOPHIRES results so they don't need to be calculated here

:param threshold_degc: Temperature increase threshold to detect redrilling (default 1.0°C)

:return: List of fractional year positions where redrilling events occur (COD = Year 1)
"""
temperatures_celsius: list[float] = [
it.to('degC').magnitude for it in _get_full_production_temperature_profile(input_and_result)
]

input_params_dict: dict[str, Any] = _get_input_parameters_dict(input_and_result[0])
time_steps_per_year: int = int(input_params_dict['Time steps per year'])

redrilling_positions = []

for i in range(1, len(temperatures_celsius)):
temp_increase = temperatures_celsius[i] - temperatures_celsius[i - 1]
if temp_increase >= threshold_degc:
# The temperature jump is detected at index i, but the redrilling event
# occurred at the previous datapoint (i-1) when the minimum was reached.
# Convert to fractional year position (COD = Year 1)
year_position = 1 + (i - 1) / time_steps_per_year
redrilling_positions.append(year_position)

return redrilling_positions


def generate_power_production_graph(
# result: GeophiresXResult,
input_and_result: tuple[GeophiresInputParameters, GeophiresXResult],
output_dir: Path,
filename: str = 'fervo_project_cape-5-net-power-production.png',
filename: str = 'fervo_project_cape-5-power-production.png',
) -> str:
"""
Generate a graph of time vs net power production and save it to the output directory.
"""
_log.info('Generating net power production graph...')
_log.info('Generating power production graph...')

profile = _get_full_net_production_profile(input_and_result)
total_generation_profile = _get_full_total_electricity_generation_profile(input_and_result)
time_steps_per_year = int(_get_input_parameters_dict(input_and_result[0])['Time steps per year'])

# profile is a list of PlainQuantity values with time_steps_per_year datapoints per year
# Convert to numpy arrays for plotting
net_power = np.array([p.magnitude for p in profile])
total_power = np.array([p.magnitude for p in total_generation_profile])

# Generate time values: each datapoint represents 1/time_steps_per_year of a year
# Starting from year 1 (first operational year)
years = np.array([(i + 1) / time_steps_per_year for i in range(len(profile))])
# Cash flow year convention: COD = Year 1, so first datapoint is at year 1
years = np.array([1 + i / time_steps_per_year for i in range(len(profile))])

# Create the figure
fig, ax = plt.subplots(figsize=(10, 6))

# Plot the data
ax.plot(years, net_power, color='#3399e6', linewidth=2, marker='o', markersize=4)
ax.plot(years, total_power, color='#9933e6', linewidth=2, label='Total Electricity Production (gross generation)')
ax.plot(years, net_power, color='#3399e6', linewidth=2, label='Net Electricity Production (after parasitic losses)')

# Set labels and title
ax.set_xlabel('Time (Years since COD)', fontsize=12)
ax.set_ylabel('Net Power Production (MW)', fontsize=12)
ax.set_title('Net Power Production Over Project Lifetime', fontsize=14)
ax.set_xlabel('Year', fontsize=12)
ax.set_ylabel('Power Production (MW)', fontsize=12)
ax.set_title('Power Production Over Project Lifetime', fontsize=14)

# Set axis limits
ax.set_xlim(years.min(), years.max())
ax.set_ylim(490, 610)
ax.set_ylim(480, 630)

# Add horizontal reference lines
hline_x = 1.5
ax.axhline(y=500, color='#e69500', linestyle='--', linewidth=1.5, alpha=0.8)
ax.text(
years.max() * 0.98, 498, 'PPA Minimum Production Requirement', ha='right', va='top', fontsize=9, color='#e69500'
)
ax.text(hline_x, 498, 'PPA Minimum Production Requirement', ha='left', va='top', fontsize=9, color='#e69500')

ax.axhline(y=600, color='#33a02c', linestyle='--', linewidth=1.5, alpha=0.8)
ax.text(
years.max() * 0.98,
hline_x,
602,
'Gross Maximum (Combined nameplate capacity of individual ORCs)',
ha='right',
'Nameplate capacity (combined capacity of individual ORCs)',
ha='left',
va='bottom',
fontsize=9,
color='#33a02c',
Expand All @@ -106,6 +146,9 @@ def generate_net_power_graph(
# Add grid for better readability
ax.grid(True, linestyle='--', alpha=0.7)

# Add legend
ax.legend(loc='best')

# Ensure the output directory exists
output_dir.mkdir(parents=True, exist_ok=True)

Expand Down Expand Up @@ -134,9 +177,7 @@ def generate_production_temperature_and_drawdown_graph(
time_steps_per_year = int(input_params_dict['Time steps per year'])

# Get maximum drawdown from input parameters (as a decimal, e.g., 0.03 for 3%)
max_drawdown_str = str(input_params_dict.get('Maximum Drawdown'))
# Handle case where value might have a comment after it
max_drawdown = float(max_drawdown_str.split(',')[0].strip())
max_drawdown = float(input_params_dict.get('Maximum Drawdown'))

# Convert to numpy arrays
temperatures_celsius = np.array([p.magnitude for p in temp_profile])
Expand All @@ -147,35 +188,61 @@ def generate_production_temperature_and_drawdown_graph(
initial_temp = temperatures_celsius[0]
max_drawdown_temp = initial_temp * (1 - max_drawdown)

# Generate time values
years = np.array([(i + 1) / time_steps_per_year for i in range(len(temp_profile))])
# Generate time values: Cash flow year convention: COD = Year 1
years = np.array([1 + i / time_steps_per_year for i in range(len(temp_profile))])

# Get redrilling event years
redrilling_years = _get_redrilling_event_indexes(input_and_result)

# Colors
COLOR_TEMPERATURE = '#e63333'
COLOR_THRESHOLD = '#e69500'
COLOR_REDRILLING = '#3366cc'

# Create the figure
fig, ax = plt.subplots(figsize=(10, 6))

# Plot temperature
ax.plot(years, temperatures_celsius, color=COLOR_TEMPERATURE, linewidth=2, label='Production Temperature')
ax.set_xlabel('Time (Years since COD)', fontsize=12)
ax.set_xlabel('Year', fontsize=12)
ax.set_ylabel('Production Temperature (°C)', fontsize=12)
ax.set_xlim(years.min(), years.max())
ax.set_ylim(195, 205)

# Enable minor ticks on x-axis
ax.minorticks_on()
ax.tick_params(axis='x', which='minor', bottom=True)
ax.tick_params(axis='y', which='minor', left=False)

# Add vertical lines for redrilling events
for i, redrill_year in enumerate(redrilling_years):
ax.axvline(x=redrill_year, color=COLOR_REDRILLING, linestyle=':', linewidth=1.5, alpha=0.7)
# Only add label for the first redrilling event to avoid legend clutter
if i == 0:
ax.text(
redrill_year + 0.3,
ax.get_ylim()[0] + 0.75,
f'Redrilling Events (n={len(redrilling_years)})',
ha='left',
va='top',
fontsize=9,
color=COLOR_REDRILLING,
)

# Add horizontal line for maximum drawdown threshold
ax.axhline(y=max_drawdown_temp, color=COLOR_THRESHOLD, linestyle='--', linewidth=1.5, alpha=0.8)
max_drawdown_pct = max_drawdown * 100
ax.text(
years.max() * 0.98,
max_drawdown_temp - 0.5,
max_drawdown_temp - 0.25,
f'Redrilling Threshold ({max_drawdown_pct:.1f}% drawdown = {max_drawdown_temp:.1f}°C)',
ha='right',
va='top',
fontsize=9,
color=COLOR_THRESHOLD,
)

# Plot temperature last so it renders over threshold and redrilling lines
ax.plot(years, temperatures_celsius, color=COLOR_TEMPERATURE, linewidth=2, label='Production Temperature')

# Title
ax.set_title('Production Temperature Over Project Lifetime', fontsize=14)

Expand Down Expand Up @@ -263,7 +330,7 @@ def generate_fervo_project_cape_5_graphs(
# base_case_input_params: GeophiresInputParameters = base_case[0]
# base_case_result: GeophiresXResult = base_case[1]

generate_net_power_graph(base_case, output_dir)
generate_power_production_graph(base_case, output_dir)
generate_production_temperature_and_drawdown_graph(base_case, output_dir)

if singh_et_al_base_simulation is not None:
Expand Down
3 changes: 2 additions & 1 deletion src/geophires_x/SurfacePlant.py
Original file line number Diff line number Diff line change
Expand Up @@ -538,10 +538,11 @@ def __init__(self, model: Model):
CurrentUnits=EnergyFrequencyUnit.KWPERYEAR
)
self.ElectricityProduced = self.OutputParameterDict[self.ElectricityProduced.Name] = OutputParameter(
Name="Total Electricity Generation",
Name="Total Electricity Production",
UnitType=Units.POWER,
PreferredUnits=PowerUnit.MW,
CurrentUnits=PowerUnit.MW
# TODO tooltip text - should reference that this is gross production
)
self.NetElectricityProduced = self.OutputParameterDict[self.NetElectricityProduced.Name] = OutputParameter(
Name="Net Electricity Production",
Expand Down
2 changes: 1 addition & 1 deletion src/geophires_x/WellBores.py
Original file line number Diff line number Diff line change
Expand Up @@ -1240,7 +1240,7 @@ def __init__(self, model: Model):
PreferredUnits=PressureUnit.KPASCAL,
CurrentUnits=PressureUnit.KPASCAL
)
self.NonverticalProducedTemperature = self.OutputParameterDict[self.ProducedTemperature.Name] = OutputParameter(
self.NonverticalProducedTemperature = self.OutputParameterDict[self.NonverticalProducedTemperature.Name] = OutputParameter(
Name="Nonvertical Produced Temperature",
value=[0.0],
UnitType=Units.TEMPERATURE,
Expand Down
Empty file.
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
from __future__ import annotations

from base_test_case import BaseTestCase
from geophires_docs.generate_fervo_project_cape_5_graphs import _get_redrilling_event_indexes
from geophires_x_client import GeophiresInputParameters
from geophires_x_client import GeophiresXClient
from geophires_x_client import GeophiresXResult
from geophires_x_client import ImmutableGeophiresInputParameters


class FervoProjectCape5GraphsTestCase(BaseTestCase):

def test_get_redrilling_event_indexes(self) -> None:
input_params: GeophiresInputParameters = ImmutableGeophiresInputParameters(
from_file_path=self._get_test_file_path('../examples/Fervo_Project_Cape-5.txt')
)
r: GeophiresXResult = GeophiresXClient().get_geophires_result(input_params)

redrilling_indexes = _get_redrilling_event_indexes((input_params, r))
self.assertEqual(
r.result['ENGINEERING PARAMETERS']['Number of times redrilling']['value'], len(redrilling_indexes)
)
19 changes: 19 additions & 0 deletions tests/geophires_x_client_tests/test_geophires_x_result.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
from __future__ import annotations

import json
from typing import Any

from geophires_x_client import GeophiresXClient
from geophires_x_client import GeophiresXResult
from geophires_x_client import ImmutableGeophiresInputParameters
from tests.base_test_case import BaseTestCase


Expand Down Expand Up @@ -67,3 +74,15 @@ def test_ags_clgs_style_output(self) -> None:
def test_sutra_reservoir_model_in_summary(self) -> None:
r: GeophiresXResult = GeophiresXResult(self._get_test_file_path('../examples/SUTRAExample1.out'))
self.assertEqual('SUTRA Model', r.result['SUMMARY OF RESULTS']['Reservoir Model'])

def test_produced_temperature_json_output(self) -> None:
r: GeophiresXResult = GeophiresXClient().get_geophires_result(
ImmutableGeophiresInputParameters(from_file_path=self._get_test_file_path('client_test_input_1.txt'))
)
with open(r.json_output_file_path, encoding='utf-8') as f:
r_json_obj: dict[str, Any] = json.load(f)

prod_temp_key: str = 'Produced Temperature'
self.assertIn(prod_temp_key, r_json_obj)
self.assertGreater(len(r_json_obj[prod_temp_key]['value']), 100)
self.assertTrue(all(it > 0 for it in r_json_obj[prod_temp_key]['value']))
3 changes: 2 additions & 1 deletion tests/test_geophires_x.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,7 +184,8 @@ def get_output_file_for_example(example_file: str):
)
# TOUGH not enabled for testing - see https://github.com/NREL/GEOPHIRES-X/issues/318
and not example_file_path_.startswith(('example6.txt', 'example7.txt'))
and '.out' not in example_file_path_,
and '.out' not in example_file_path_
and '.json' not in example_file_path_,
self._list_test_files_dir(test_files_dir='examples'),
)
)
Expand Down