diff --git a/docs/Fervo_Project_Cape-5.md.jinja b/docs/Fervo_Project_Cape-5.md.jinja
index 52ce627c3..402892e39 100644
--- a/docs/Fervo_Project_Cape-5.md.jinja
+++ b/docs/Fervo_Project_Cape-5.md.jinja
@@ -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
+
+
+
[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
@@ -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
@@ -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
+
+
+
+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 #}
-{##}
+#### Power Generation Profile
-
+
-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
diff --git a/docs/_images/fervo_project_cape-5-power-production.png b/docs/_images/fervo_project_cape-5-power-production.png
new file mode 100644
index 000000000..53df12c91
Binary files /dev/null and b/docs/_images/fervo_project_cape-5-power-production.png differ
diff --git a/docs/_images/fervo_project_cape-5-production-temperature-drawdown.png b/docs/_images/fervo_project_cape-5-production-temperature-drawdown.png
deleted file mode 100644
index 5d624eedf..000000000
Binary files a/docs/_images/fervo_project_cape-5-production-temperature-drawdown.png and /dev/null differ
diff --git a/docs/_images/fervo_project_cape-5-production-temperature.png b/docs/_images/fervo_project_cape-5-production-temperature.png
index d0931cd65..8a8077812 100644
Binary files a/docs/_images/fervo_project_cape-5-production-temperature.png and b/docs/_images/fervo_project_cape-5-production-temperature.png differ
diff --git a/src/geophires_docs/generate_fervo_project_cape_5_graphs.py b/src/geophires_docs/generate_fervo_project_cape_5_graphs.py
index 8bf156127..576967c36 100644
--- a/src/geophires_docs/generate_fervo_project_cape_5_graphs.py
+++ b/src/geophires_docs/generate_fervo_project_cape_5_graphs.py
@@ -2,6 +2,7 @@
import json
from pathlib import Path
+from typing import Any
import numpy as np
from matplotlib import pyplot as plt
@@ -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]):
@@ -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',
@@ -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)
@@ -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])
@@ -147,28 +188,51 @@ 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',
@@ -176,6 +240,9 @@ def generate_production_temperature_and_drawdown_graph(
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)
@@ -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:
diff --git a/src/geophires_x/SurfacePlant.py b/src/geophires_x/SurfacePlant.py
index b12f9ed5a..f3cb8b6d9 100644
--- a/src/geophires_x/SurfacePlant.py
+++ b/src/geophires_x/SurfacePlant.py
@@ -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",
diff --git a/src/geophires_x/WellBores.py b/src/geophires_x/WellBores.py
index c4cddeb07..45526cf0f 100644
--- a/src/geophires_x/WellBores.py
+++ b/src/geophires_x/WellBores.py
@@ -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,
diff --git a/tests/geophires_docs_tests/__init__.py b/tests/geophires_docs_tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/geophires_docs_tests/test_generate_fervo_project_cape_5_graphs.py b/tests/geophires_docs_tests/test_generate_fervo_project_cape_5_graphs.py
new file mode 100644
index 000000000..216a4ac80
--- /dev/null
+++ b/tests/geophires_docs_tests/test_generate_fervo_project_cape_5_graphs.py
@@ -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)
+ )
diff --git a/tests/geophires_x_client_tests/test_geophires_x_result.py b/tests/geophires_x_client_tests/test_geophires_x_result.py
index 0bf5fab2c..d02767ab1 100644
--- a/tests/geophires_x_client_tests/test_geophires_x_result.py
+++ b/tests/geophires_x_client_tests/test_geophires_x_result.py
@@ -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
@@ -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']))
diff --git a/tests/test_geophires_x.py b/tests/test_geophires_x.py
index e80c99290..a65f932f2 100644
--- a/tests/test_geophires_x.py
+++ b/tests/test_geophires_x.py
@@ -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'),
)
)