diff --git a/docs/Fervo_Project_Cape-5.md.jinja b/docs/Fervo_Project_Cape-5.md.jinja index a37f5ab1..52ce627c 100644 --- a/docs/Fervo_Project_Cape-5.md.jinja +++ b/docs/Fervo_Project_Cape-5.md.jinja @@ -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. @@ -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. diff --git a/docs/_images/fervo_project_cape-5-net-power-production.png b/docs/_images/fervo_project_cape-5-net-power-production.png new file mode 100644 index 00000000..6d2f4ff2 Binary files /dev/null and b/docs/_images/fervo_project_cape-5-net-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 new file mode 100644 index 00000000..5d624eed Binary files /dev/null and b/docs/_images/fervo_project_cape-5-production-temperature-drawdown.png differ diff --git a/docs/_images/fervo_project_cape-5-production-temperature.png b/docs/_images/fervo_project_cape-5-production-temperature.png new file mode 100644 index 00000000..d0931cd6 Binary files /dev/null and b/docs/_images/fervo_project_cape-5-production-temperature.png differ diff --git a/docs/_images/singh_et_al_base_simulation-net-power-production.png b/docs/_images/singh_et_al_base_simulation-net-power-production.png deleted file mode 100644 index fc3b0a61..00000000 Binary files a/docs/_images/singh_et_al_base_simulation-net-power-production.png and /dev/null differ diff --git a/src/geophires_docs/__init__.py b/src/geophires_docs/__init__.py index 7dd873c6..1851f5e7 100644 --- a/src/geophires_docs/__init__.py +++ b/src/geophires_docs/__init__.py @@ -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: @@ -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 diff --git a/src/geophires_docs/generate_fervo_project_cape_5_docs.py b/src/geophires_docs/generate_fervo_project_cape_5_docs.py index 5be7ab32..1c9f0fcb 100644 --- a/src/geophires_docs/generate_fervo_project_cape_5_docs.py +++ b/src/geophires_docs/generate_fervo_project_cape_5_docs.py @@ -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), 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 38d01a3c..8bf15612 100644 --- a/src/geophires_docs/generate_fervo_project_cape_5_graphs.py +++ b/src/geophires_docs/generate_fervo_project_cape_5_graphs.py @@ -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)) @@ -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) @@ -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 @@ -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 @@ -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__': @@ -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) + ) diff --git a/src/geophires_docs/generate_fervo_project_cape_5_md.py b/src/geophires_docs/generate_fervo_project_cape_5_md.py index 9d57c392..266b1378 100755 --- a/src/geophires_docs/generate_fervo_project_cape_5_md.py +++ b/src/geophires_docs/generate_fervo_project_cape_5_md.py @@ -18,6 +18,8 @@ from geophires_docs import _PROJECT_ROOT from geophires_docs import _get_fpc5_input_file_path from geophires_docs import _get_fpc5_result_file_path +from geophires_docs import _get_input_parameters_dict +from geophires_docs import _get_logger from geophires_docs import _get_project_root from geophires_x.GeoPHIRESUtils import is_int from geophires_x.GeoPHIRESUtils import sig_figs @@ -28,28 +30,7 @@ # Module-level variable to hold the current project root for schema access _current_project_root: Path | None = None - -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 +_log = _get_logger(__name__) def _get_schema() -> dict[str, Any]: @@ -256,7 +237,7 @@ def _q(d: dict[str, Any]) -> PlainQuantity: def get_fpc5_input_parameter_values(input_params: GeophiresInputParameters, result: GeophiresXResult) -> dict[str, Any]: - print('Extracting input parameter values...') + _log.info('Extracting input parameter values...') params = _get_input_parameters_dict(input_params) r: dict[str, dict[str, Any]] = result.result @@ -274,7 +255,7 @@ def get_fpc5_input_parameter_values(input_params: GeophiresInputParameters, resu def get_result_values(result: GeophiresXResult) -> dict[str, Any]: - print('Extracting result values...') + _log.info('Extracting result values...') r: dict[str, dict[str, Any]] = result.result @@ -455,18 +436,18 @@ def generate_fervo_project_cape_5_md( template = env.get_template('Fervo_Project_Cape-5.md.jinja') # Render template - print('Rendering template...') + _log.info('Rendering template...') output = template.render(**template_values) # Write output output_file = docs_dir / 'Fervo_Project_Cape-5.md' output_file.write_text(output, encoding='utf-8') - print(f'✓ Generated {output_file}') - print('\nKey results:') - print(f"\tLCOE: ${template_values['lcoe_usd_per_mwh']}/MWh") - print(f"\tIRR: {template_values['irr_pct']}%") - print(f"\tTotal CAPEX: ${template_values['total_capex_gusd']}B") + _log.info(f'✓ Generated {output_file}') + _log.info('\nKey results:') + _log.info(f"\tLCOE: ${template_values['lcoe_usd_per_mwh']}/MWh") + _log.info(f"\tIRR: {template_values['irr_pct']}%") + _log.info(f"\tTotal CAPEX: ${template_values['total_capex_gusd']}B") def main(project_root: Path | None = None): diff --git a/src/geophires_docs/watch_docs.py b/src/geophires_docs/watch_docs.py index 77584a6e..24f84d61 100755 --- a/src/geophires_docs/watch_docs.py +++ b/src/geophires_docs/watch_docs.py @@ -10,28 +10,9 @@ from pathlib import Path from typing import Any +from geophires_docs import _get_logger -def _get_logger(): - # 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() - - -_log = _get_logger() +_log = _get_logger(__name__) def get_file_states(directory) -> dict[str, Any]: diff --git a/src/geophires_x/SurfacePlantSupercriticalORC.py b/src/geophires_x/SurfacePlantSupercriticalORC.py index e418165d..7f971096 100644 --- a/src/geophires_x/SurfacePlantSupercriticalORC.py +++ b/src/geophires_x/SurfacePlantSupercriticalORC.py @@ -14,7 +14,7 @@ def __init__(self, model: Model): :return: None """ - model.logger.info("Init " + self.__class__.__name__ + ": " + __name__) + model.logger.info(f'Init {self.__class__.__name__}: {__name__}') super().__init__(model) # Initialize all the parameters in the superclass # Set up all the Parameters that will be predefined by this class using the different types of parameter classes. @@ -33,7 +33,7 @@ def __init__(self, model: Model): sclass = self.__class__.__name__ self.MyClass = sclass self.MyPath = __file__ - model.logger.info("Complete " + self.__class__.__name__ + ": " + __name__) + model.logger.info(f"Complete {self.__class__.__name__}: {__name__}") def __str__(self): return "SurfacePlantSupercriticalORC"