Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
14 changes: 13 additions & 1 deletion docs/Fervo_Project_Cape-5.md.jinja
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ on Phases I and II of [Fervo Energy's Cape Station](https://capestation.com/).

[^author]: Author: Jonathan Pezzino (GitHub: [softwareengineerprogrammer](https://github.com/softwareengineerprogrammer))

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

[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.
Expand Down Expand Up @@ -181,6 +181,18 @@ See [GEOPHIRES output parameters documentation](parameters.html#economic-paramet
{# @formatter:on #}


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

![caption](_images/fervo_project_cape-5-net-power-production.png)

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:

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.

### Sensitivity Analysis

The following charts show the sensitivity of key metrics to various inputs.
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
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.
48 changes: 48 additions & 0 deletions src/geophires_docs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@

import os
from pathlib import Path
from typing import Any

from geophires_x_client import GeophiresInputParameters


def _get_file_path(file_name) -> Path:
Expand All @@ -27,3 +30,48 @@ def _get_fpc5_result_file_path(project_root: Path | None = None) -> Path:
_PROJECT_ROOT: Path = _get_project_root()
_FPC5_INPUT_FILE_PATH: Path = _get_fpc5_input_file_path()
_FPC5_RESULT_FILE_PATH: Path = _get_fpc5_result_file_path()


def _get_logger(_name_: str) -> Any:
# TODO consolidate _get_logger methods into a commonly accessible utility

# sh = logging.StreamHandler(sys.stdout)
# sh.setLevel(logging.INFO)
# sh.setFormatter(logging.Formatter(fmt='[%(asctime)s][%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'))
#
# ret = logging.getLogger(__name__)
# ret.addHandler(sh)
# return ret

# noinspection PyMethodMayBeStatic
class _PrintLogger:
def info(self, msg):
print(f'[INFO] {msg}')

def error(self, msg):
print(f'[ERROR] {msg}')

return _PrintLogger()


def _get_input_parameters_dict( # TODO consolidate with FervoProjectCape5TestCase._get_input_parameters
_params: GeophiresInputParameters, include_parameter_comments: bool = False, include_line_comments: bool = False
) -> dict[str, Any]:
comment_idx = 0
ret: dict[str, Any] = {}
for line in _params.as_text().split('\n'):
parts = line.strip().split(', ') # TODO generalize for array-type params
field = parts[0].strip()
if len(parts) >= 2 and not field.startswith('#'):
fieldValue = parts[1].strip()
if include_parameter_comments and len(parts) > 2:
fieldValue += ', ' + (', '.join(parts[2:])).strip()
ret[field] = fieldValue.strip()

if include_line_comments and field.startswith('#'):
ret[f'_COMMENT-{comment_idx}'] = line.strip()
comment_idx += 1

# TODO preserve newlines

return ret
2 changes: 1 addition & 1 deletion src/geophires_docs/generate_fervo_project_cape_5_docs.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def generate_fervo_project_cape_5_docs():
)
result = GeophiresXResult(_FPC5_RESULT_FILE_PATH)

singh_et_al_base_simulation:tuple[GeophiresInputParameters,GeophiresXResult] = get_singh_et_al_base_simulation_result(input_params)
singh_et_al_base_simulation: tuple[GeophiresInputParameters,GeophiresXResult] = get_singh_et_al_base_simulation_result(input_params)

generate_fervo_project_cape_5_graphs(
(input_params, result),
Expand Down
213 changes: 169 additions & 44 deletions src/geophires_docs/generate_fervo_project_cape_5_graphs.py
Original file line number Diff line number Diff line change
@@ -1,45 +1,75 @@
from __future__ import annotations

import json
from pathlib import Path

import numpy as np
from matplotlib import pyplot as plt
from pint.facets.plain import PlainQuantity

from geophires_docs import _FPC5_INPUT_FILE_PATH
from geophires_docs import _FPC5_RESULT_FILE_PATH
from geophires_docs import _PROJECT_ROOT
from geophires_docs import _get_input_parameters_dict
from geophires_docs import _get_logger
from geophires_x_client import GeophiresInputParameters
from geophires_x_client import GeophiresXClient
from geophires_x_client import GeophiresXResult
from geophires_x_client import ImmutableGeophiresInputParameters

_log = _get_logger(__name__)


def _get_full_net_production_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
return _get_full_profile(input_and_result, 'Net 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',
)


def _get_full_thermal_drawdown_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
return _get_full_profile(input_and_result, 'Thermal Drawdown')


def _get_full_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult], profile_key: str):
input_params: GeophiresInputParameters = input_and_result[0]
result = GeophiresXClient().get_geophires_result(input_params)

with open(result.json_output_file_path, encoding='utf-8') as f:
full_result_obj = json.load(f)

net_gen_obj = full_result_obj[profile_key]
net_gen_obj_unit = net_gen_obj['CurrentUnits'].replace('CELSIUS', 'degC')
profile = [PlainQuantity(it, net_gen_obj_unit) for it in net_gen_obj['value']]
return profile


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

Args:
result: The GEOPHIRES result object
output_dir: Directory to save the graph image

Returns:
The filename of the generated graph
"""
print('Generating net power production graph...')
_log.info('Generating net power production graph...')

# Extract data from power generation profile
profile = result.power_generation_profile
headers = profile[0]
data = profile[1:]
profile = _get_full_net_production_profile(input_and_result)
time_steps_per_year = int(_get_input_parameters_dict(input_and_result[0])['Time steps per year'])

# Find the indices for YEAR and NET POWER columns
year_idx = headers.index('YEAR')
net_power_idx = headers.index('NET POWER (MW)')
# 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])

# Extract years and net power values
years = np.array([row[year_idx] for row in data])
net_power = np.array([row[net_power_idx] for row in data])
# 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))])

# Create the figure
fig, ax = plt.subplots(figsize=(10, 6))
Expand All @@ -48,12 +78,30 @@ def generate_net_power_graph(
ax.plot(years, net_power, color='#3399e6', linewidth=2, marker='o', markersize=4)

# Set labels and title
ax.set_xlabel('Time (Years)', fontsize=12)
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)

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

# Add horizontal reference lines
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.axhline(y=600, color='#33a02c', linestyle='--', linewidth=1.5, alpha=0.8)
ax.text(
years.max() * 0.98,
602,
'Gross Maximum (Combined nameplate capacity of individual ORCs)',
ha='right',
va='bottom',
fontsize=9,
color='#33a02c',
)

# Add grid for better readability
ax.grid(True, linestyle='--', alpha=0.7)
Expand All @@ -66,24 +114,96 @@ def generate_net_power_graph(
plt.savefig(save_path, dpi=150, bbox_inches='tight')
plt.close(fig)

print(f'✓ Generated {save_path}')
_log.info(f'✓ Generated {save_path}')
return filename


def generate_production_temperature_graph(
result: GeophiresXResult, output_dir: Path, filename='fervo_project_cape-5-production-temperature.png'
def generate_production_temperature_and_drawdown_graph(
input_and_result: tuple[GeophiresInputParameters, GeophiresXResult],
output_dir: Path,
filename: str = 'fervo_project_cape-5-production-temperature.png',
) -> str:
"""
Generate a graph of time vs production temperature and save it to the output directory.
Generate a graph of time vs production temperature with a horizontal line
showing the temperature threshold at which maximum drawdown is reached.
"""
_log.info('Generating production temperature graph...')

temp_profile = _get_full_production_temperature_profile(input_and_result)
input_params_dict = _get_input_parameters_dict(input_and_result[0])
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())

# Convert to numpy arrays
temperatures_celsius = np.array([p.magnitude for p in temp_profile])

Args:
result: The GEOPHIRES result object
output_dir: Directory to save the graph image
# Calculate the temperature at maximum drawdown threshold
# Drawdown = (T_initial - T_threshold) / T_initial
# So: T_threshold = T_initial * (1 - max_drawdown)
initial_temp = temperatures_celsius[0]
max_drawdown_temp = initial_temp * (1 - max_drawdown)

Returns:
The filename of the generated graph
# Generate time values
years = np.array([(i + 1) / time_steps_per_year for i in range(len(temp_profile))])

# Colors
COLOR_TEMPERATURE = '#e63333'
COLOR_THRESHOLD = '#e69500'

# 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_ylabel('Production Temperature (°C)', fontsize=12)
ax.set_xlim(years.min(), years.max())

# 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,
f'Redrilling Threshold ({max_drawdown_pct:.1f}% drawdown = {max_drawdown_temp:.1f}°C)',
ha='right',
va='top',
fontsize=9,
color=COLOR_THRESHOLD,
)

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

# Add grid
ax.grid(True, linestyle='--', alpha=0.7)

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

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

# Save the figure
save_path = output_dir / filename
plt.savefig(save_path, dpi=150, bbox_inches='tight')
plt.close(fig)

_log.info(f'✓ Generated {save_path}')
return filename


def generate_production_temperature_graph(
result: GeophiresXResult, output_dir: Path, filename: str = 'fervo_project_cape-5-production-temperature.png'
) -> str:
"""
Generate a graph of time vs production temperature and save it to the output directory.
"""
print('Generating production temperature graph...')
_log.info('Generating production temperature graph...')

# Extract data from power generation profile
profile = result.power_generation_profile
Expand Down Expand Up @@ -131,7 +251,7 @@ def generate_production_temperature_graph(
plt.savefig(save_path, dpi=150, bbox_inches='tight')
plt.close(fig)

print(f'✓ Generated {save_path}')
_log.info(f'✓ Generated {save_path}')
return filename


Expand All @@ -141,21 +261,24 @@ def generate_fervo_project_cape_5_graphs(
output_dir: Path,
) -> None:
# base_case_input_params: GeophiresInputParameters = base_case[0]
# result:GeophiresXResult = base_case[1]
# base_case_result: GeophiresXResult = base_case[1]

# generate_net_power_graph(result, output_dir)
# generate_production_temperature_graph(result, output_dir)
generate_net_power_graph(base_case, output_dir)
generate_production_temperature_and_drawdown_graph(base_case, output_dir)

singh_et_al_base_simulation_result: GeophiresXResult = singh_et_al_base_simulation[1]
if singh_et_al_base_simulation is not None:
singh_et_al_base_simulation_result: GeophiresXResult = singh_et_al_base_simulation[1]

# generate_net_power_graph(
# singh_et_al_base_simulation_result, output_dir, filename='singh_et_al_base_simulation-net-power-production.png'
# )
generate_production_temperature_graph(
singh_et_al_base_simulation_result,
output_dir,
filename='singh_et_al_base_simulation-production-temperature.png',
)
# generate_net_power_graph(
# singh_et_al_base_simulation_result, output_dir,
# filename='singh_et_al_base_simulation-net-power-production.png'
# )

generate_production_temperature_graph(
singh_et_al_base_simulation_result,
output_dir,
filename='singh_et_al_base_simulation-production-temperature.png',
)


if __name__ == '__main__':
Expand All @@ -166,4 +289,6 @@ def generate_fervo_project_cape_5_graphs(

result_ = GeophiresXResult(_FPC5_RESULT_FILE_PATH)

generate_fervo_project_cape_5_graphs(input_params_, result_, images_dir)
generate_fervo_project_cape_5_graphs(
(input_params_, result_), None, images_dir # TODO configure (for local development)
)
Loading