Skip to content

Commit 3d4ea1f

Browse files
Merge pull request #125 from softwareengineerprogrammer/technical-results-graphs
Technical results graphs
2 parents f42277d + be020db commit 3d4ea1f

11 files changed

+246
-99
lines changed

docs/Fervo_Project_Cape-5.md.jinja

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ on Phases I and II of [Fervo Energy's Cape Station](https://capestation.com/).
77

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

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

1212
[Click here](https://gtp.scientificwebservices.com/geophires/?geophires-example-id=Fervo_Project_Cape-5) to
1313
interactively explore the case study in the GEOPHIRES web interface.
@@ -181,6 +181,18 @@ See [GEOPHIRES output parameters documentation](parameters.html#economic-paramet
181181
{# @formatter:on #}
182182
183183
184+
#### Power Production Curve
185+
{# TODO #}
186+
{#![caption](_images/fervo_project_cape-5-production-temperature.png)#}
187+
188+
![caption](_images/fervo_project_cape-5-net-power-production.png)
189+
190+
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:
191+
192+
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.
193+
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.
194+
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.
195+
184196
### Sensitivity Analysis
185197

186198
The following charts show the sensitivity of key metrics to various inputs.
70.7 KB
Loading
114 KB
Loading
78.1 KB
Loading
Binary file not shown.

src/geophires_docs/__init__.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,9 @@
22

33
import os
44
from pathlib import Path
5+
from typing import Any
6+
7+
from geophires_x_client import GeophiresInputParameters
58

69

710
def _get_file_path(file_name) -> Path:
@@ -27,3 +30,48 @@ def _get_fpc5_result_file_path(project_root: Path | None = None) -> Path:
2730
_PROJECT_ROOT: Path = _get_project_root()
2831
_FPC5_INPUT_FILE_PATH: Path = _get_fpc5_input_file_path()
2932
_FPC5_RESULT_FILE_PATH: Path = _get_fpc5_result_file_path()
33+
34+
35+
def _get_logger(_name_: str) -> Any:
36+
# TODO consolidate _get_logger methods into a commonly accessible utility
37+
38+
# sh = logging.StreamHandler(sys.stdout)
39+
# sh.setLevel(logging.INFO)
40+
# sh.setFormatter(logging.Formatter(fmt='[%(asctime)s][%(levelname)s] %(message)s', datefmt='%Y-%m-%d %H:%M:%S'))
41+
#
42+
# ret = logging.getLogger(__name__)
43+
# ret.addHandler(sh)
44+
# return ret
45+
46+
# noinspection PyMethodMayBeStatic
47+
class _PrintLogger:
48+
def info(self, msg):
49+
print(f'[INFO] {msg}')
50+
51+
def error(self, msg):
52+
print(f'[ERROR] {msg}')
53+
54+
return _PrintLogger()
55+
56+
57+
def _get_input_parameters_dict( # TODO consolidate with FervoProjectCape5TestCase._get_input_parameters
58+
_params: GeophiresInputParameters, include_parameter_comments: bool = False, include_line_comments: bool = False
59+
) -> dict[str, Any]:
60+
comment_idx = 0
61+
ret: dict[str, Any] = {}
62+
for line in _params.as_text().split('\n'):
63+
parts = line.strip().split(', ') # TODO generalize for array-type params
64+
field = parts[0].strip()
65+
if len(parts) >= 2 and not field.startswith('#'):
66+
fieldValue = parts[1].strip()
67+
if include_parameter_comments and len(parts) > 2:
68+
fieldValue += ', ' + (', '.join(parts[2:])).strip()
69+
ret[field] = fieldValue.strip()
70+
71+
if include_line_comments and field.startswith('#'):
72+
ret[f'_COMMENT-{comment_idx}'] = line.strip()
73+
comment_idx += 1
74+
75+
# TODO preserve newlines
76+
77+
return ret

src/geophires_docs/generate_fervo_project_cape_5_docs.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ def generate_fervo_project_cape_5_docs():
4545
)
4646
result = GeophiresXResult(_FPC5_RESULT_FILE_PATH)
4747

48-
singh_et_al_base_simulation:tuple[GeophiresInputParameters,GeophiresXResult] = get_singh_et_al_base_simulation_result(input_params)
48+
singh_et_al_base_simulation: tuple[GeophiresInputParameters,GeophiresXResult] = get_singh_et_al_base_simulation_result(input_params)
4949

5050
generate_fervo_project_cape_5_graphs(
5151
(input_params, result),

src/geophires_docs/generate_fervo_project_cape_5_graphs.py

Lines changed: 169 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,75 @@
11
from __future__ import annotations
22

3+
import json
34
from pathlib import Path
45

56
import numpy as np
67
from matplotlib import pyplot as plt
8+
from pint.facets.plain import PlainQuantity
79

810
from geophires_docs import _FPC5_INPUT_FILE_PATH
911
from geophires_docs import _FPC5_RESULT_FILE_PATH
1012
from geophires_docs import _PROJECT_ROOT
13+
from geophires_docs import _get_input_parameters_dict
14+
from geophires_docs import _get_logger
1115
from geophires_x_client import GeophiresInputParameters
16+
from geophires_x_client import GeophiresXClient
1217
from geophires_x_client import GeophiresXResult
1318
from geophires_x_client import ImmutableGeophiresInputParameters
1419

20+
_log = _get_logger(__name__)
21+
22+
23+
def _get_full_net_production_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
24+
return _get_full_profile(input_and_result, 'Net Electricity Production')
25+
26+
27+
def _get_full_production_temperature_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
28+
return _get_full_profile(
29+
input_and_result,
30+
#'Produced Temperature'
31+
'Reservoir Temperature History',
32+
)
33+
34+
35+
def _get_full_thermal_drawdown_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult]):
36+
return _get_full_profile(input_and_result, 'Thermal Drawdown')
37+
38+
39+
def _get_full_profile(input_and_result: tuple[GeophiresInputParameters, GeophiresXResult], profile_key: str):
40+
input_params: GeophiresInputParameters = input_and_result[0]
41+
result = GeophiresXClient().get_geophires_result(input_params)
42+
43+
with open(result.json_output_file_path, encoding='utf-8') as f:
44+
full_result_obj = json.load(f)
45+
46+
net_gen_obj = full_result_obj[profile_key]
47+
net_gen_obj_unit = net_gen_obj['CurrentUnits'].replace('CELSIUS', 'degC')
48+
profile = [PlainQuantity(it, net_gen_obj_unit) for it in net_gen_obj['value']]
49+
return profile
50+
1551

1652
def generate_net_power_graph(
17-
result: GeophiresXResult, output_dir: Path, filename='fervo_project_cape-5-net-power-production.png'
53+
# result: GeophiresXResult,
54+
input_and_result: tuple[GeophiresInputParameters, GeophiresXResult],
55+
output_dir: Path,
56+
filename: str = 'fervo_project_cape-5-net-power-production.png',
1857
) -> str:
1958
"""
2059
Generate a graph of time vs net power production and save it to the output directory.
21-
22-
Args:
23-
result: The GEOPHIRES result object
24-
output_dir: Directory to save the graph image
25-
26-
Returns:
27-
The filename of the generated graph
2860
"""
29-
print('Generating net power production graph...')
61+
_log.info('Generating net power production graph...')
3062

31-
# Extract data from power generation profile
32-
profile = result.power_generation_profile
33-
headers = profile[0]
34-
data = profile[1:]
63+
profile = _get_full_net_production_profile(input_and_result)
64+
time_steps_per_year = int(_get_input_parameters_dict(input_and_result[0])['Time steps per year'])
3565

36-
# Find the indices for YEAR and NET POWER columns
37-
year_idx = headers.index('YEAR')
38-
net_power_idx = headers.index('NET POWER (MW)')
66+
# profile is a list of PlainQuantity values with time_steps_per_year datapoints per year
67+
# Convert to numpy arrays for plotting
68+
net_power = np.array([p.magnitude for p in profile])
3969

40-
# Extract years and net power values
41-
years = np.array([row[year_idx] for row in data])
42-
net_power = np.array([row[net_power_idx] for row in data])
70+
# Generate time values: each datapoint represents 1/time_steps_per_year of a year
71+
# Starting from year 1 (first operational year)
72+
years = np.array([(i + 1) / time_steps_per_year for i in range(len(profile))])
4373

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

5080
# Set labels and title
51-
ax.set_xlabel('Time (Years)', fontsize=12)
81+
ax.set_xlabel('Time (Years since COD)', fontsize=12)
5282
ax.set_ylabel('Net Power Production (MW)', fontsize=12)
5383
ax.set_title('Net Power Production Over Project Lifetime', fontsize=14)
5484

5585
# Set axis limits
5686
ax.set_xlim(years.min(), years.max())
87+
ax.set_ylim(490, 610)
88+
89+
# Add horizontal reference lines
90+
ax.axhline(y=500, color='#e69500', linestyle='--', linewidth=1.5, alpha=0.8)
91+
ax.text(
92+
years.max() * 0.98, 498, 'PPA Minimum Production Requirement', ha='right', va='top', fontsize=9, color='#e69500'
93+
)
94+
95+
ax.axhline(y=600, color='#33a02c', linestyle='--', linewidth=1.5, alpha=0.8)
96+
ax.text(
97+
years.max() * 0.98,
98+
602,
99+
'Gross Maximum (Combined nameplate capacity of individual ORCs)',
100+
ha='right',
101+
va='bottom',
102+
fontsize=9,
103+
color='#33a02c',
104+
)
57105

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

69-
print(f'✓ Generated {save_path}')
117+
_log.info(f'✓ Generated {save_path}')
70118
return filename
71119

72120

73-
def generate_production_temperature_graph(
74-
result: GeophiresXResult, output_dir: Path, filename='fervo_project_cape-5-production-temperature.png'
121+
def generate_production_temperature_and_drawdown_graph(
122+
input_and_result: tuple[GeophiresInputParameters, GeophiresXResult],
123+
output_dir: Path,
124+
filename: str = 'fervo_project_cape-5-production-temperature.png',
75125
) -> str:
76126
"""
77-
Generate a graph of time vs production temperature and save it to the output directory.
127+
Generate a graph of time vs production temperature with a horizontal line
128+
showing the temperature threshold at which maximum drawdown is reached.
129+
"""
130+
_log.info('Generating production temperature graph...')
131+
132+
temp_profile = _get_full_production_temperature_profile(input_and_result)
133+
input_params_dict = _get_input_parameters_dict(input_and_result[0])
134+
time_steps_per_year = int(input_params_dict['Time steps per year'])
135+
136+
# Get maximum drawdown from input parameters (as a decimal, e.g., 0.03 for 3%)
137+
max_drawdown_str = str(input_params_dict.get('Maximum Drawdown'))
138+
# Handle case where value might have a comment after it
139+
max_drawdown = float(max_drawdown_str.split(',')[0].strip())
140+
141+
# Convert to numpy arrays
142+
temperatures_celsius = np.array([p.magnitude for p in temp_profile])
78143

79-
Args:
80-
result: The GEOPHIRES result object
81-
output_dir: Directory to save the graph image
144+
# Calculate the temperature at maximum drawdown threshold
145+
# Drawdown = (T_initial - T_threshold) / T_initial
146+
# So: T_threshold = T_initial * (1 - max_drawdown)
147+
initial_temp = temperatures_celsius[0]
148+
max_drawdown_temp = initial_temp * (1 - max_drawdown)
82149

83-
Returns:
84-
The filename of the generated graph
150+
# Generate time values
151+
years = np.array([(i + 1) / time_steps_per_year for i in range(len(temp_profile))])
152+
153+
# Colors
154+
COLOR_TEMPERATURE = '#e63333'
155+
COLOR_THRESHOLD = '#e69500'
156+
157+
# Create the figure
158+
fig, ax = plt.subplots(figsize=(10, 6))
159+
160+
# Plot temperature
161+
ax.plot(years, temperatures_celsius, color=COLOR_TEMPERATURE, linewidth=2, label='Production Temperature')
162+
ax.set_xlabel('Time (Years since COD)', fontsize=12)
163+
ax.set_ylabel('Production Temperature (°C)', fontsize=12)
164+
ax.set_xlim(years.min(), years.max())
165+
166+
# Add horizontal line for maximum drawdown threshold
167+
ax.axhline(y=max_drawdown_temp, color=COLOR_THRESHOLD, linestyle='--', linewidth=1.5, alpha=0.8)
168+
max_drawdown_pct = max_drawdown * 100
169+
ax.text(
170+
years.max() * 0.98,
171+
max_drawdown_temp - 0.5,
172+
f'Redrilling Threshold ({max_drawdown_pct:.1f}% drawdown = {max_drawdown_temp:.1f}°C)',
173+
ha='right',
174+
va='top',
175+
fontsize=9,
176+
color=COLOR_THRESHOLD,
177+
)
178+
179+
# Title
180+
ax.set_title('Production Temperature Over Project Lifetime', fontsize=14)
181+
182+
# Add grid
183+
ax.grid(True, linestyle='--', alpha=0.7)
184+
185+
# Legend
186+
ax.legend(loc='best')
187+
188+
# Ensure the output directory exists
189+
output_dir.mkdir(parents=True, exist_ok=True)
190+
191+
# Save the figure
192+
save_path = output_dir / filename
193+
plt.savefig(save_path, dpi=150, bbox_inches='tight')
194+
plt.close(fig)
195+
196+
_log.info(f'✓ Generated {save_path}')
197+
return filename
198+
199+
200+
def generate_production_temperature_graph(
201+
result: GeophiresXResult, output_dir: Path, filename: str = 'fervo_project_cape-5-production-temperature.png'
202+
) -> str:
203+
"""
204+
Generate a graph of time vs production temperature and save it to the output directory.
85205
"""
86-
print('Generating production temperature graph...')
206+
_log.info('Generating production temperature graph...')
87207

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

134-
print(f'✓ Generated {save_path}')
254+
_log.info(f'✓ Generated {save_path}')
135255
return filename
136256

137257

@@ -141,21 +261,24 @@ def generate_fervo_project_cape_5_graphs(
141261
output_dir: Path,
142262
) -> None:
143263
# base_case_input_params: GeophiresInputParameters = base_case[0]
144-
# result:GeophiresXResult = base_case[1]
264+
# base_case_result: GeophiresXResult = base_case[1]
145265

146-
# generate_net_power_graph(result, output_dir)
147-
# generate_production_temperature_graph(result, output_dir)
266+
generate_net_power_graph(base_case, output_dir)
267+
generate_production_temperature_and_drawdown_graph(base_case, output_dir)
148268

149-
singh_et_al_base_simulation_result: GeophiresXResult = singh_et_al_base_simulation[1]
269+
if singh_et_al_base_simulation is not None:
270+
singh_et_al_base_simulation_result: GeophiresXResult = singh_et_al_base_simulation[1]
150271

151-
# generate_net_power_graph(
152-
# singh_et_al_base_simulation_result, output_dir, filename='singh_et_al_base_simulation-net-power-production.png'
153-
# )
154-
generate_production_temperature_graph(
155-
singh_et_al_base_simulation_result,
156-
output_dir,
157-
filename='singh_et_al_base_simulation-production-temperature.png',
158-
)
272+
# generate_net_power_graph(
273+
# singh_et_al_base_simulation_result, output_dir,
274+
# filename='singh_et_al_base_simulation-net-power-production.png'
275+
# )
276+
277+
generate_production_temperature_graph(
278+
singh_et_al_base_simulation_result,
279+
output_dir,
280+
filename='singh_et_al_base_simulation-production-temperature.png',
281+
)
159282

160283

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

167290
result_ = GeophiresXResult(_FPC5_RESULT_FILE_PATH)
168291

169-
generate_fervo_project_cape_5_graphs(input_params_, result_, images_dir)
292+
generate_fervo_project_cape_5_graphs(
293+
(input_params_, result_), None, images_dir # TODO configure (for local development)
294+
)

0 commit comments

Comments
 (0)