diff --git a/src/geophires_x_client/geophires_x_result.py b/src/geophires_x_client/geophires_x_result.py index 00bc218b..9f9f364f 100644 --- a/src/geophires_x_client/geophires_x_result.py +++ b/src/geophires_x_client/geophires_x_result.py @@ -381,9 +381,9 @@ def __init__(self, output_file_path, logger_name=None): self._logger = _get_logger(logger_name) self.output_file_path = output_file_path - f = open(self.output_file_path) - self._lines = list(f.readlines()) - f.close() + with open(self.output_file_path, encoding='utf-8') as f: + self._lines = list(f.readlines()) + self._lines_by_category = self._get_lines_by_category() # TODO generic-er result value map @@ -393,19 +393,26 @@ def __init__(self, output_file_path, logger_name=None): fields = category_fields[1] self.result[category] = {} + category_lines = self._lines_by_category.get(category, []) + for field in fields: if isinstance(field, _EqualSignDelimitedField): - self.result[category][field.field_name] = self._get_equal_sign_delimited_field(field.field_name) + self.result[category][field.field_name] = self._get_equal_sign_delimited_field( + field.field_name, search_lines=category_lines + ) elif isinstance(field, _UnlabeledStringField): self.result[category][field.field_name] = self._get_unlabeled_string_field( - field.field_name, field.marker_prefixes + field.field_name, field.marker_prefixes, search_lines=category_lines ) else: is_string_field = isinstance(field, _StringValueField) field_name = field.field_name if is_string_field else field indent = 4 if category != 'Simulation Metadata' else 1 self.result[category][field_name] = self._get_result_field( - field_name, is_string_value_field=is_string_field, min_indentation_spaces=indent + field_name, + is_string_value_field=is_string_field, + min_indentation_spaces=indent, + search_lines=category_lines, ) try: @@ -446,6 +453,35 @@ def __init__(self, output_file_path, logger_name=None): if self._get_end_use_option() is not None: self.result['metadata']['End-Use Option'] = self._get_end_use_option().name + def _get_lines_by_category(self) -> dict[str, list[str]]: + """ + Parses the raw output file lines into a dictionary where keys are + category headers and values are the lines belonging to that category. + """ + lines_by_category = {} + current_category = None + known_headers = list(self._RESULT_FIELDS_BY_CATEGORY.keys()) + + for line in self._lines: + + def get_header_content(h_: str) -> str: + if h_ == 'Simulation Metadata': + return h_ + return f'***{h_}***' + + # Check if the line is a category header + found_header = next((h for h in known_headers if get_header_content(h) == line.strip()), None) + + if found_header: + current_category = found_header + if current_category not in lines_by_category: + lines_by_category[current_category] = [] + elif current_category: + # Append the line to the current category if one has been found + lines_by_category[current_category].append(line) + + return lines_by_category + @property def direct_use_heat_breakeven_price_USD_per_MMBTU(self): summary = self.result['SUMMARY OF RESULTS'] @@ -552,9 +588,18 @@ def _json_fields(self) -> MappingProxyType: except FileNotFoundError: return {} - def _get_result_field(self, field_name: str, is_string_value_field: bool = False, min_indentation_spaces: int = 4): + def _get_result_field( + self, + field_name: str, + is_string_value_field: bool = False, + min_indentation_spaces: int = 4, + search_lines: list[str] | None = None, + ): + if search_lines is None: + search_lines = self._lines + # TODO make this less fragile with proper regex - matching_lines = set(filter(lambda line: f'{min_indentation_spaces * " "}{field_name}: ' in line, self._lines)) + matching_lines = set(filter(lambda line: f'{min_indentation_spaces * " "}{field_name}: ' in line, search_lines)) if len(matching_lines) == 0: self._logger.debug(f'Field not found: {field_name}') @@ -592,14 +637,17 @@ def normalize_spaces(matched_line): return {'value': self._parse_number(str_val, field=f'field "{field_name}"'), 'unit': unit} - def _get_equal_sign_delimited_field(self, field_name): + def _get_equal_sign_delimited_field(self, field_name, search_lines: list[str] | None = None): + if search_lines is None: + search_lines = self._lines + metadata_markers = ( f' {field_name} = ', # Previous versions of GEOPHIRES erroneously included an extra space after the field name so we include # the pattern for it for backwards compatibility with existing .out files. f' {field_name} = ', ) - matching_lines = set(filter(lambda line: any(m in line for m in metadata_markers), self._lines)) + matching_lines = set(filter(lambda line: any(m in line for m in metadata_markers), search_lines)) if len(matching_lines) == 0: self._logger.debug(f'Equal sign-delimited field not found: {field_name}') @@ -619,8 +667,13 @@ def _get_equal_sign_delimited_field(self, field_name): self._logger.error(f'Unexpected error extracting equal sign-delimited field {field_name}') # Shouldn't happen return None - def _get_unlabeled_string_field(self, field_name: str, marker_prefixes: list[str]): - matching_lines = set(filter(lambda line: any(m in line for m in marker_prefixes), self._lines)) + def _get_unlabeled_string_field( + self, field_name: str, marker_prefixes: list[str], search_lines: list[str] | None = None + ): + if search_lines is None: + search_lines = self._lines + + matching_lines = set(filter(lambda line: any(m in line for m in marker_prefixes), search_lines)) if len(matching_lines) == 0: self._logger.debug(f'Unlabeled string field not found: {field_name}') diff --git a/tests/example1_addons.csv b/tests/example1_addons.csv index 4c6b596b..0599d06c 100644 --- a/tests/example1_addons.csv +++ b/tests/example1_addons.csv @@ -71,7 +71,6 @@ RESERVOIR SIMULATION RESULTS,Production Wellbore Heat Transmission Model,,Ramey RESERVOIR SIMULATION RESULTS,Average Production Well Temperature Drop,,3.0,degC RESERVOIR SIMULATION RESULTS,Average Injection Well Pump Pressure Drop,,217.9,kPa RESERVOIR SIMULATION RESULTS,Average Production Well Pump Pressure Drop,,1112.0,kPa -RESERVOIR SIMULATION RESULTS,Average Net Electricity Production,,5.39,MW CAPITAL COSTS (M$),Drilling and completion costs,,17.38,MUSD CAPITAL COSTS (M$),Drilling and completion costs per well,,4.35,MUSD CAPITAL COSTS (M$),Stimulation costs,,3.02,MUSD @@ -79,13 +78,11 @@ CAPITAL COSTS (M$),Surface power plant costs,,20.8,MUSD CAPITAL COSTS (M$),Field gathering system costs,,2.3,MUSD CAPITAL COSTS (M$),Total surface equipment costs,,23.1,MUSD CAPITAL COSTS (M$),Exploration costs,,4.49,MUSD -CAPITAL COSTS (M$),Total Add-on CAPEX,,70.0,MUSD CAPITAL COSTS (M$),Total capital costs,,25.67,MUSD CAPITAL COSTS (M$),Annualized capital costs,,1.28,MUSD OPERATING AND MAINTENANCE COSTS (M$/yr),Wellfield maintenance costs,,0.39,MUSD/yr OPERATING AND MAINTENANCE COSTS (M$/yr),Power plant maintenance costs,,0.9,MUSD/yr OPERATING AND MAINTENANCE COSTS (M$/yr),Water costs,,0.06,MUSD/yr -OPERATING AND MAINTENANCE COSTS (M$/yr),Total Add-on OPEX,,1.7,MUSD/yr OPERATING AND MAINTENANCE COSTS (M$/yr),Total operating and maintenance costs,,-0.86,MUSD/yr SURFACE EQUIPMENT SIMULATION RESULTS,Initial geofluid availability,,0.11,MW/(kg/s) SURFACE EQUIPMENT SIMULATION RESULTS,Maximum Total Electricity Generation,,5.62,MW diff --git a/tests/examples/example1_addons.out b/tests/examples/example1_addons.out index 89151b40..a29a34fa 100644 --- a/tests/examples/example1_addons.out +++ b/tests/examples/example1_addons.out @@ -5,9 +5,9 @@ Simulation Metadata ---------------------- GEOPHIRES Version: 3.9.44 - Simulation Date: 2025-07-28 - Simulation Time: 14:30 - Calculation Time: 0.858 sec + Simulation Date: 2025-07-29 + Simulation Time: 06:46 + Calculation Time: 0.898 sec ***SUMMARY OF RESULTS*** diff --git a/tests/geophires-result_example-3.csv b/tests/geophires-result_example-3.csv index 59715605..47104012 100644 --- a/tests/geophires-result_example-3.csv +++ b/tests/geophires-result_example-3.csv @@ -51,7 +51,6 @@ RESERVOIR SIMULATION RESULTS,Average Reservoir Heat Extraction,,133.3,MW RESERVOIR SIMULATION RESULTS,Production Wellbore Heat Transmission Model,,Ramey Model, RESERVOIR SIMULATION RESULTS,Average Production Well Temperature Drop,,3.6,degC RESERVOIR SIMULATION RESULTS,Average Injection Well Pump Pressure Drop,,650.2,kPa -RESERVOIR SIMULATION RESULTS,Average Net Electricity Production,,19.7,MW CAPITAL COSTS (M$),Drilling and completion costs,,34.45,MUSD CAPITAL COSTS (M$),Drilling and completion costs per well,,5.74,MUSD CAPITAL COSTS (M$),Stimulation costs,,4.53,MUSD 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 9185ac47..d9a5a6e6 100644 --- a/tests/geophires_x_client_tests/test_geophires_x_result.py +++ b/tests/geophires_x_client_tests/test_geophires_x_result.py @@ -16,6 +16,12 @@ def test_get_sam_cash_flow_row_name_unit_split(self) -> None: actual = GeophiresXResult._get_sam_cash_flow_row_name_unit_split(case[0]) self.assertListEqual(actual, case[1]) + def test_get_lines_by_category(self) -> None: + r: GeophiresXResult = GeophiresXResult(self._get_test_file_path('../examples/example2.out')) + lines_by_cat = r._get_lines_by_category() + res_params_lines = lines_by_cat['RESERVOIR PARAMETERS'] + self.assertGreater(len(res_params_lines), 0) + def test_reservoir_volume_calculation_note(self) -> None: r: GeophiresXResult = GeophiresXResult(self._get_test_file_path('../examples/example2.out')) field_name = 'Reservoir volume calculation note' @@ -37,3 +43,17 @@ def test_sam_economic_model_result_csv(self) -> None: r: GeophiresXResult = GeophiresXResult(self._get_test_file_path('sam-em-csv-test.out')) as_csv = r.as_csv() self.assertIsNotNone(as_csv) + + def test_multicategory_fields_only_in_case_report_category(self) -> None: + r: GeophiresXResult = GeophiresXResult( + self._get_test_file_path('../examples/example_SAM-single-owner-PPA-3.out') + ) + self.assertIsNone(r.result['EXTENDED ECONOMICS']['Total Add-on CAPEX']) + self.assertIsNone(r.result['EXTENDED ECONOMICS']['Total Add-on OPEX']) + + self.assertIn('Total Add-on CAPEX', r.result['CAPITAL COSTS (M$)']) + self.assertIn('Total Add-on OPEX', r.result['OPERATING AND MAINTENANCE COSTS (M$/yr)']) + + self.assertIsNone(r.result['RESERVOIR SIMULATION RESULTS']['Average Net Electricity Production']) + self.assertIsNotNone(r.result['SUMMARY OF RESULTS']['Average Net Electricity Production']) + self.assertIsNotNone(r.result['SURFACE EQUIPMENT SIMULATION RESULTS']['Average Net Electricity Generation']) diff --git a/tests/regenerate-example-result.sh b/tests/regenerate-example-result.sh index be7624a7..faef5cee 100755 --- a/tests/regenerate-example-result.sh +++ b/tests/regenerate-example-result.sh @@ -16,5 +16,5 @@ rm examples/$1.json if [[ $1 == "example1_addons" ]] then echo "Updating CSV..." - python regenerate_example_result_csv.py + python regenerate_example_result_csv.py example1_addons fi diff --git a/tests/regenerate_example_result_csv.py b/tests/regenerate_example_result_csv.py index 0c8319a9..30a86306 100644 --- a/tests/regenerate_example_result_csv.py +++ b/tests/regenerate_example_result_csv.py @@ -1,3 +1,4 @@ +import argparse import os from geophires_x_client import GeophiresXResult @@ -8,5 +9,18 @@ def _get_file_path(file_name: str) -> str: if __name__ == '__main__': - with open(_get_file_path('example1_addons.csv'), 'w', encoding='utf-8') as csvfile: - csvfile.write(GeophiresXResult(_get_file_path('examples/example1_addons.out')).as_csv()) + parser = argparse.ArgumentParser(description='Regenerate a CSV result file from a GEOPHIRES-X example .out file.') + parser.add_argument( + 'example_name', + type=str, + nargs='?', # Makes the argument optional + default='example1_addons', + help='The base name of the example file (e.g., "example1_addons"). Defaults to "example1_addons".', + ) + args = parser.parse_args() + + example_name = args.example_name + example_relative_path = f'{"examples/" if example_name.startswith("example") else ""}{example_name}.out' + + with open(_get_file_path(f'{example_name}.csv'), 'w', encoding='utf-8') as csvfile: + csvfile.write(GeophiresXResult(_get_file_path(example_relative_path)).as_csv())