diff --git a/docs/_toc.yml b/docs/_toc.yml index ad0f76a7..f80d98f7 100644 --- a/docs/_toc.yml +++ b/docs/_toc.yml @@ -4,9 +4,10 @@ format: jb-book root: index parts: - - caption: Installation + - caption: Installation and Contribution chapters: - file: install + - file: contribute - caption: Running Hercules chapters: - file: running_hercules diff --git a/docs/examples/07_open_cycle_gas_turbine.md b/docs/examples/07_open_cycle_gas_turbine.md index eb342482..6ef14869 100644 --- a/docs/examples/07_open_cycle_gas_turbine.md +++ b/docs/examples/07_open_cycle_gas_turbine.md @@ -48,15 +48,22 @@ No manual setup is required. The example uses only the OCGT component which requ To run the example, execute the following command in the terminal: ```bash +python examples/07_open_cycle_gas_turbine/hercules_runscript.py + +# OR + +cd examples/07_open_cycle_gas_turbine python hercules_runscript.py ``` ## Outputs +The output files `hercules_output.h5` and `hercules_dict.echo` are written to the folder `examples/07_open_cycle_gas_turbine/outputs_07/` and log files are written to the folder `examples/07_open_cycle_gas_turbine/logger_outputs_07/` + To plot the outputs, run: ```bash -python plot_outputs.py +python examples/07_open_cycle_gas_turbine/plot_outputs.py ``` The plot shows: diff --git a/docs/h_dict.md b/docs/h_dict.md index b6e82402..98b35ebf 100644 --- a/docs/h_dict.md +++ b/docs/h_dict.md @@ -16,6 +16,18 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `endtime` | float | Simulation end time in seconds | - | | `step` | int | Current simulation step | 0 | | `time` | float | Current simulation time | starttime | +| **Output File Configuration** | +| `output_dir` | str | Output folder name | "outputs" | +| `output_file` | str | Output HDF5 file name | "hercules_output.h5" | +| `overwrite_outputs` | bool | If True, removes the existing output files in the `output_dir` | True | +| **Logging Configuration** | +| `logging` | dict | Logging configuration | - | +| `logging.logger_name` | str | Name of logger | "hercules" | +| `logging.logger_file` | dict | Log file name | "log_hercules.log" | +| `logging.console_output` | bool | Whether to log to console | True | +| `logging.console_prefix` | str | Logger prefix | "HERCULES" | +| `logging.log_level` | str | Logging level | "INFO" | +| `logging.logging_dir` | str | Logger output dir | "outputs" | | **Plant Configuration** | | `plant` | dict | Plant-level configuration | - | | `plant.interconnect_limit` | float | Maximum power limit in kW | - | @@ -23,7 +35,6 @@ The `h_dict` is a Python dictionary that contains all the configurations for eac | `verbose` | bool | Enable verbose logging | False | | `name` | str | Simulation name | - | | `description` | str | Simulation description | - | -| `output_file` | str | Output HDF5 file path | "outputs/hercules_output.h5" | | `log_every_n` | int | Log every N simulation steps to output log (default: 1) | 1 | | `external_data` | dict | External data configuration | - | | `external_data_file` | str | External data file path (deprecated, use `external_data` instead) | - | diff --git a/examples/07_open_cycle_gas_turbine/hercules_input.yaml b/examples/07_open_cycle_gas_turbine/hercules_input.yaml index 9a887fb8..5700ca37 100644 --- a/examples/07_open_cycle_gas_turbine/hercules_input.yaml +++ b/examples/07_open_cycle_gas_turbine/hercules_input.yaml @@ -14,6 +14,12 @@ endtime_utc: "2020-01-01T06:00:00Z" # 6 hours later verbose: False log_every_n: 1 +output_dir: "outputs_07" +overwrite_outputs: True +logging: + logging_dir: "logger_outputs_07" + log_file: "log_hercules_ex_07.log" + plant: interconnect_limit: 100000 # kW (100 MW) diff --git a/examples/07_open_cycle_gas_turbine/hercules_runscript.py b/examples/07_open_cycle_gas_turbine/hercules_runscript.py index e0d00879..c50c55b6 100644 --- a/examples/07_open_cycle_gas_turbine/hercules_runscript.py +++ b/examples/07_open_cycle_gas_turbine/hercules_runscript.py @@ -15,13 +15,18 @@ - Simulation runs for 6 hours total with 1 minute time steps """ +import os +from pathlib import Path + from hercules.hercules_model import HerculesModel -from hercules.utilities_examples import prepare_output_directory +from hercules.utilities import load_yaml + +os.chdir(os.path.dirname(__file__)) -prepare_output_directory() +hercules_dict = load_yaml(Path(__file__).parent / "hercules_input.yaml") # Initialize the Hercules model -hmodel = HerculesModel("hercules_input.yaml") +hmodel = HerculesModel(hercules_dict) class ControllerOCGT: diff --git a/examples/07_open_cycle_gas_turbine/plot_outputs.py b/examples/07_open_cycle_gas_turbine/plot_outputs.py index 292ec11c..20cbd362 100644 --- a/examples/07_open_cycle_gas_turbine/plot_outputs.py +++ b/examples/07_open_cycle_gas_turbine/plot_outputs.py @@ -4,7 +4,7 @@ from hercules import HerculesOutput # Read the Hercules output file using HerculesOutput -ho = HerculesOutput("outputs/hercules_output.h5") +ho = HerculesOutput("outputs_07/hercules_output.h5") # Print metadata information print("Simulation Metadata:") diff --git a/examples/hercules_input_example.yaml b/examples/hercules_input_example.yaml index 05766179..c3f5fab0 100644 --- a/examples/hercules_input_example.yaml +++ b/examples/hercules_input_example.yaml @@ -14,10 +14,19 @@ verbose: False # Enable verbose console output (True/False) log_every_n: 10 # Log output every N time steps (positive integer, default: 1) # Output file configuration +output_dir: outputs # output folder output_file: outputs/hercules_output.h5 # Output HDF5 file path (automatically adds .h5 extension if missing) +overwrite_outputs: True # if True, remove any files in output_dir prior to running, if False, creates a unique foldername output_use_compression: True # Enable HDF5 compression (True/False, default: True) output_buffer_size: 50000 # Memory buffer size for writing data in rows (default: 50000) +logging: + logger_name: "hercules" + log_file: "log_hercules.log" + console_output: True + console_prefix: "HERCULES" + log_level: "INFO" + logging_dir: "outputs" # folder to write the loggers to # Plant-level configuration plant: interconnect_limit: 201300 # kW - grid interconnection capacity limit diff --git a/hercules/__init__.py b/hercules/__init__.py index 70eabccc..3d72618c 100644 --- a/hercules/__init__.py +++ b/hercules/__init__.py @@ -2,5 +2,10 @@ __version__ = version("nlr-hercules") +from pathlib import Path + from .hercules_model import HerculesModel from .hercules_output import HerculesOutput + +HERCULES_ROOT_DIR = Path(__file__).resolve().parent +HERCULES_EXAMPLE_DIR = HERCULES_ROOT_DIR.parent / "examples" diff --git a/hercules/hercules_model.py b/hercules/hercules_model.py index fcfd7b69..82adfc7c 100644 --- a/hercules/hercules_model.py +++ b/hercules/hercules_model.py @@ -1,6 +1,7 @@ import datetime as dt import json import os +import shutil import sys import time as _time from pathlib import Path @@ -16,13 +17,12 @@ hercules_float_type, interpolate_df, load_hercules_input, + make_unique_folder_name, setup_logging, ) LOGFILE = str(dt.datetime.now()).replace(":", "_").replace(" ", "_").replace(".", "_") -Path("outputs").mkdir(parents=True, exist_ok=True) - class HerculesModel: def __init__(self, input_file): @@ -35,14 +35,44 @@ def __init__(self, input_file): """ + # Load and validate the input file + h_dict = self._load_hercules_input(input_file) + + # set default output directory to cwd / "outputs" + output_dir = Path(h_dict.get("output_dir", "outputs")).absolute() + + # If the output directory exists, and overwrite_outputs is True + # The output folder will be deleted and recreated. + if h_dict.get("overwrite_outputs", True): + if output_dir.exists(): + shutil.rmtree(output_dir) + else: + # Make a unique foldername with the same basename as the output_dir + proposed_dirname = str(output_dir.name) + output_dir = make_unique_folder_name(output_dir.parent, output_dir.name) + # If the proposed directory already existed and a new folder was proposed, + # update the logger directory to have the same unique number + if output_dir.name != proposed_dirname: + unique_folder_id_parts = output_dir.name.split(proposed_dirname) + unique_folder_id = "".join(p for p in unique_folder_id_parts) + if h_dict.get("logging", {}).get("logging_dir", None) is not None: + if isinstance(h_dict["logging"]["logging_dir"], str): + h_dict["logging"].update( + {"logging_dir": Path(h_dict["logging"]["logging_dir"])} + ) + + logging_parent_dir = h_dict["logging"]["logging_dir"].parent + logging_dir_basename = h_dict["logging"]["logging_dir"].name + new_log_fpath = logging_parent_dir / f"{logging_dir_basename}{unique_folder_id}" + + h_dict["logging"].update({"logging_dir": new_log_fpath}) + # Make sure output folder exists - Path("outputs").mkdir(parents=True, exist_ok=True) + Path(output_dir).mkdir(parents=True, exist_ok=True) # Set up logging - self.logger = self._setup_logging() - - # Load and validate the input file - h_dict = self._load_hercules_input(input_file) + logging_inputs = {"logging_dir": output_dir} | h_dict.get("logging", {}) + self.logger = self._setup_logging(**logging_inputs) # Initialize the flattened h_dict self.h_dict_flat = {} @@ -83,8 +113,19 @@ def __init__(self, input_file): # Ensure .h5 extension if not self.output_file.endswith(".h5"): self.output_file = self.output_file.rsplit(".", 1)[0] + ".h5" + + if "/" in self.output_file: + # check if folder was specfied in output_file + if Path(self.output_file).parent != output_dir: + # if folder of output_file does not match output_dir, then + # just use the name of the output file + self.output_file = output_dir / self.output_file.split("/")[-1] + else: + self.output_file = output_dir / self.output_file else: - self.output_file = "outputs/hercules_output.h5" + self.output_file = output_dir / "hercules_output.h5" + + self.output_file = Path(self.output_file).absolute() # Initialize HDF5 output system self.hdf5_file = None @@ -124,25 +165,28 @@ def __init__(self, input_file): # starttime_utc is required and should already be set, but ensure it's still present self.starttime_utc = self.h_dict["starttime_utc"] - def _setup_logging(self, logfile="log_hercules.log", console_output=True): + def _setup_logging(self, log_file_name="log_hercules.log", **kwargs): """Set up logging to file and console. Creates 'outputs' directory and configures file/console logging with timestamps. This method wraps the utilities.setup_logging function for backward compatibility. Args: - logfile (str, optional): Log file name. Defaults to "log_hercules.log". - console_output (bool, optional): Enable console output. Defaults to True. + log_file_name (str, optional): Log file name. Defaults to "log_hercules.log". + **kwargs (dict, optional): Extra arguments passed to setup_logging Returns: logging.Logger: Configured logger instance. """ - return setup_logging( - logger_name="hercules", - log_file=logfile, - console_output=console_output, - console_prefix="HERCULES", - ) + + logging_defaults = { + "logger_name": "hercules", + "log_file": log_file_name, + } + + # Update the defaults with any input kwargs + logging_inputs = logging_defaults | kwargs + return setup_logging(**logging_inputs) def _load_hercules_input(self, filename): """Load and validate Hercules input file. @@ -437,7 +481,8 @@ def _save_h_dict_as_text(self): # to see full dictionary in interpreting log original_stdout = sys.stdout - with open("outputs/h_dict.echo", "w") as f_i: + echo_file_fpath = self.output_file.parent / "h_dict.echo" + with open(echo_file_fpath, "w") as f_i: sys.stdout = f_i # Change the standard output to the file we created. print(self.h_dict) sys.stdout = original_stdout # Reset the standard output to its original value diff --git a/hercules/plant_components/component_base.py b/hercules/plant_components/component_base.py index 0f18ff7d..7d3cbd6b 100644 --- a/hercules/plant_components/component_base.py +++ b/hercules/plant_components/component_base.py @@ -1,6 +1,6 @@ # Base class for plant components in Hercules. - +from pathlib import Path from typing import ClassVar from hercules.utilities import setup_logging @@ -60,13 +60,32 @@ def __init__(self, h_dict, component_name): self.component_type = type(self).__name__ # Set up logging + output_dir = Path(h_dict.get("output_dir", "outputs")).absolute() + # Get the default output folder + logging_inputs = {"logging_dir": output_dir} | { + "logging_dir": h_dict.get("logging", {}).get("logging_dir", output_dir) + } + if h_dict[component_name].get("logging", {}).get("logging_dir", None) is not None: + msg = ( + f"Cannot specify log folder for component {component_name}, " + f"all log files will be saved to the logging_dir {logging_inputs['logging_dir']}" + ) + raise ValueError(msg) + + logging_inputs["logging_dir"] = Path(logging_inputs["logging_dir"]).absolute() + logging_inputs = ( + logging_inputs | h_dict.get("logging", {}) | h_dict[component_name].get("logging", {}) + ) # Check if log_file_name is defined in the h_dict[component_name] if "log_file_name" in h_dict[component_name]: self.log_file_name = h_dict[component_name]["log_file_name"] else: - self.log_file_name = f"outputs/log_{component_name}.log" + self.log_file_name = f"log_{component_name}.log" + + if "log_file" in logging_inputs: + logging_inputs["log_file"] = self.log_file_name - self.logger = self._setup_logging(self.log_file_name) + self.logger = self._setup_logging(self.log_file_name, **logging_inputs) # Parse log_channels from the h_dict if "log_channels" in h_dict[component_name]: @@ -103,7 +122,7 @@ def __init__(self, h_dict, component_name): if self.verbose: self.logger.info(f"Verbose flag = {self.verbose}") - def _setup_logging(self, log_file_name): + def _setup_logging(self, log_file_name, **kwargs): """Set up logging for the component. @@ -112,18 +131,21 @@ def _setup_logging(self, log_file_name): Args: - log_file_name (str): Full path to the log file. + log_file_name (str): Filename of the logger + **kwargs (dict, optional): Extra arguments passed to setup_logging Returns: logging.Logger: Configured logger instance for the component. """ - return setup_logging( - logger_name=self.component_name, - log_file=log_file_name, - console_output=True, - console_prefix=self.component_name.upper(), - use_outputs_dir=False, # log_file_name is already a full path - ) + logging_defaults = { + "logger_name": self.component_name, + "log_file": log_file_name, + "console_prefix": self.component_name.upper(), + } + + # Update the defaults with any input kwargs + logging_inputs = logging_defaults | kwargs + return setup_logging(**logging_inputs) def __del__(self): """Cleanup method to properly close log file handlers.""" diff --git a/hercules/utilities.py b/hercules/utilities.py index d7e7cf96..1ecb0e1c 100644 --- a/hercules/utilities.py +++ b/hercules/utilities.py @@ -68,6 +68,43 @@ def load_yaml(filename, loader=Loader): return yaml.load(fid, loader) +def make_unique_folder_name(parent_folder: str | Path, proposed_dirname: str): + """Generate a folder that does not already exist in a user-defined parent folder. + + Args: + parent_folder (str | Path): directory that a folder is expected to be created in. + proposed_dirname (str): folder name to check for existence and + to use as the base folder description of a new an unique folder name. + + Returns: + str: unique direcoty that does not yet exist in parent_folder. + """ + + # if folder(s) exist with the same base name, make a new unique directory name + # file_base = proposed_fname.split(fext)[0] + existing_files = [f for f in Path(parent_folder).glob(f"**/{proposed_dirname}*") if f.is_dir()] + if len(existing_files) == 0: + return Path(parent_folder) / proposed_dirname + + # get past numbers that were used to make unique folders by matching + # folder names against the file base name followed by a number + past_numbers = [ + int(re.findall(f"{proposed_dirname}[0-9]+", str(fname))[0].split(proposed_dirname)[-1]) + for fname in existing_files + if len(re.findall(f"{proposed_dirname}[0-9]+", str(fname))) > 0 + ] + + if len(past_numbers) > 0: + # if multiple folders have the same basename followed by a number, + # take the maximum unique number and add one + unique_number = int(max(past_numbers) + 1) + return Path(parent_folder) / f"{proposed_dirname}{unique_number}" + else: + # if no folders have the same basename followed by a number, + # but do have the same basename, then add a zero to the folder basename + return Path(parent_folder) / f"{proposed_dirname}0" + + def _validate_utc_datetime_string(dt_str, field_name): """Validate that a datetime string represents UTC time. @@ -218,9 +255,12 @@ def load_hercules_input(filename): other_keys = [ "name", "description", + "logging", "controller", "verbose", + "output_dir", "output_file", + "overwrite_outputs", "log_every_n", "external_data_file", "external_data", @@ -371,7 +411,7 @@ def setup_logging( console_output=True, console_prefix=None, log_level=logging.INFO, - use_outputs_dir=True, + logging_dir=Path("outputs"), ): """Set up logging to file and console with flexible configuration. @@ -388,25 +428,28 @@ def setup_logging( logger_name in uppercase. Defaults to None. log_level (int, optional): Logging level (e.g., logging.INFO, logging.DEBUG). Defaults to logging.INFO. - use_outputs_dir (bool, optional): If True and log_file is a simple filename - (no directory separators), automatically places it in 'outputs' directory. - If False, treats log_file as-is. Defaults to True. + logging_dir (str | Path): Folder to save log file to Returns: logging.Logger: Configured logger instance. """ + + if Path(log_file).suffix != ".log": + log_file += ".log" + # Determine the log file path - if use_outputs_dir and (os.sep not in log_file and "/" not in log_file): + if os.sep not in log_file and "/" not in log_file: # Simple filename - use outputs directory - log_dir = os.path.join(os.getcwd(), "outputs") - os.makedirs(log_dir, exist_ok=True) - log_file_path = os.path.join(log_dir, log_file) + os.makedirs(logging_dir, exist_ok=True) + log_file_path = os.path.join(logging_dir, log_file) else: - # Full path or use_outputs_dir=False - use as-is but ensure directory exists - log_file_path = log_file - log_dir = Path(log_file_path).parent + # Partial path, use as subdirectories within logging_dir + log_file_parts = log_file.split("/") + log_fpath_parts = [logging_dir] + log_file_parts + log_file_path = Path(*log_fpath_parts) + log_dir = Path(log_file_path).parent.absolute() log_dir.mkdir(parents=True, exist_ok=True) # Get the logger @@ -416,7 +459,11 @@ def setup_logging( for handler in logger.handlers[:]: logger.removeHandler(handler) - logger.setLevel(log_level) + if isinstance(log_level, str): + log_level_int = logging.getLevelName(log_level.upper()) + logger.setLevel(log_level_int) + else: + logger.setLevel(log_level) # Add file handler file_handler = logging.FileHandler(log_file_path) diff --git a/hercules/utilities_examples.py b/hercules/utilities_examples.py index ba3cf984..bad21ebf 100644 --- a/hercules/utilities_examples.py +++ b/hercules/utilities_examples.py @@ -3,6 +3,7 @@ import os import shutil import subprocess +import warnings from pathlib import Path @@ -64,7 +65,7 @@ def ensure_example_inputs_exist(): generate_example_inputs() -def prepare_output_directory(output_dir="outputs"): +def prepare_output_directory(output_dir=Path("outputs")): """Remove and recreate an output directory for clean runs. If the output directory exists, it will be deleted and recreated. @@ -74,6 +75,14 @@ def prepare_output_directory(output_dir="outputs"): output_dir (str, optional): Path to the output directory to prepare. Defaults to "outputs". """ + warnings.warn( + "The method `prepare_output_directory() will be depreciated in future versions" + "Please specify the `output_folder` in the hercules input file. " + "To use the functionality of this function in future versions, please set the " + "`overwrite_outputs` flag in the hercules input file to True. ", + DeprecationWarning, + ) + if os.path.exists(output_dir): shutil.rmtree(output_dir) os.makedirs(output_dir) diff --git a/tests/example_regression_tests/example_07_regression_test.py b/tests/example_regression_tests/example_07_regression_test.py new file mode 100644 index 00000000..6ecf1f8b --- /dev/null +++ b/tests/example_regression_tests/example_07_regression_test.py @@ -0,0 +1,197 @@ +import shutil +import tempfile +from copy import deepcopy +from pathlib import Path + +import hercules +from hercules.hercules_model import HerculesModel +from hercules.utilities import load_yaml + +HERCULES_EXAMPLE_DIR = Path(hercules.__file__).parent.parent / "examples" + + +class ControllerOCGT: + """Controller implementing the OCGT schedule described in the module docstring.""" + + def __init__(self, h_dict): + """Initialize the controller. + + Args: + h_dict (dict): The hercules input dictionary. + + """ + self.rated_capacity = h_dict["open_cycle_gas_turbine"]["rated_capacity"] + + def step(self, h_dict): + """Execute one control step. + + Args: + h_dict (dict): The hercules input dictionary. + + Returns: + dict: The updated hercules input dictionary. + + """ + current_time = h_dict["time"] + + # Determine power setpoint based on time + if current_time < 10 * 60: # 10 minutes in seconds + # Before 10 minutes: run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 40 * 60: # 40 minutes in seconds + # Between 10 and 40 minutes: shut down + power_setpoint = 0.0 + elif current_time < 120 * 60: # 120 minutes in seconds + # Between 40 and 120 minutes: signal to run at full capacity + power_setpoint = self.rated_capacity + elif current_time < 180 * 60: # 180 minutes in seconds + # Between 120 and 180 minutes: reduce power to 50% of rated capacity + power_setpoint = 0.5 * self.rated_capacity + elif current_time < 210 * 60: # 210 minutes in seconds + # Between 180 and 210 minutes: reduce power to 10% of rated capacity + power_setpoint = 0.1 * self.rated_capacity + elif current_time < 240 * 60: # 240 minutes in seconds + # Between 210 and 240 minutes: increase power to 100% of rated capacity + power_setpoint = self.rated_capacity + else: + # After 240 minutes: shut down + power_setpoint = 0.0 + + h_dict["open_cycle_gas_turbine"]["power_setpoint"] = power_setpoint + + return h_dict + + +def run_hercules_example(hercules_dict): + + # Initialize the Hercules model + hmodel = HerculesModel(hercules_dict) + + # Instantiate the controller and assign to the Hercules model + hmodel.assign_controller(ControllerOCGT(hmodel.h_dict)) + + # Run the simulation + hmodel.run() + + hmodel.logger.info("Process completed successfully") + + return hmodel + + +def test_specified_output_dir(): + test_n = "01" + # what happens with non-default output dir and all default logging logic + + output_dir = HERCULES_EXAMPLE_DIR / "07_open_cycle_gas_turbine" / f"outputs_test{test_n}" + logger_dir = HERCULES_EXAMPLE_DIR / "07_open_cycle_gas_turbine" / f"outputs_test{test_n}" + + hercules_fpath = HERCULES_EXAMPLE_DIR / "07_open_cycle_gas_turbine" / "hercules_input.yaml" + hercules_dict = load_yaml(hercules_fpath) + hercules_dict["output_dir"] = output_dir + hercules_dict["output_file"] = f"hercules_output_test{test_n}.h5" + hercules_dict["overwrite_outputs"] = True + hercules_dict["logging"] = {} + + run_hercules_example(hercules_dict) + + expected_output_h5_fpath = output_dir / f"hercules_output_test{test_n}.h5" + expected_output_main_log_fpath = logger_dir / "log_hercules.log" + expected_component_log_fpath = logger_dir / "log_open_cycle_gas_turbine.log" + + assert expected_output_h5_fpath.is_file(), "h5 file" + assert expected_output_main_log_fpath.is_file(), "main log file" + assert expected_component_log_fpath.is_file(), "component log file" + + shutil.rmtree(output_dir) + assert not output_dir.is_dir() + + +def test_specified_main_logger_dir(): + test_n = "02" + # what happens with non-default output dir and a different logging dir + + output_dir = HERCULES_EXAMPLE_DIR / "07_open_cycle_gas_turbine" / f"outputs_test{test_n}" + logger_dir = HERCULES_EXAMPLE_DIR / "07_open_cycle_gas_turbine" / f"loggers_{test_n}" + + hercules_fpath = HERCULES_EXAMPLE_DIR / "07_open_cycle_gas_turbine" / "hercules_input.yaml" + hercules_dict = load_yaml(hercules_fpath) + hercules_dict["output_dir"] = output_dir + hercules_dict["output_file"] = f"hercules_output_test{test_n}.h5" + hercules_dict["overwrite_outputs"] = True + hercules_dict["logging"] = { + "logging_dir": logger_dir, + "log_file": f"log_hercules_test{test_n}.log", + } + + run_hercules_example(hercules_dict) + + expected_output_h5_fpath = output_dir / f"hercules_output_test{test_n}.h5" + expected_output_main_log_fpath = logger_dir / f"log_hercules_test{test_n}.log" + expected_component_log_fpath = logger_dir / "log_open_cycle_gas_turbine.log" + + assert expected_output_h5_fpath.is_file(), "h5 file" + assert expected_output_main_log_fpath.is_file(), "main log file" + assert expected_component_log_fpath.is_file(), "component log file" + + shutil.rmtree(output_dir) + assert not output_dir.is_dir() + + shutil.rmtree(logger_dir) + assert not logger_dir.is_dir() + + +def test_dont_overwrite_outputs(): + test_n = "03" + + with tempfile.TemporaryDirectory() as tmpdir: + output_dir = Path(tmpdir, "outputs") + logger_dir = Path(tmpdir, "loggers") + + hercules_fpath = HERCULES_EXAMPLE_DIR / "07_open_cycle_gas_turbine" / "hercules_input.yaml" + hercules_dict = load_yaml(hercules_fpath) + hercules_dict["output_dir"] = output_dir + hercules_dict["output_file"] = f"hercules_output_test{test_n}.h5" + hercules_dict["overwrite_outputs"] = False + hercules_dict["logging"] = { + "logging_dir": logger_dir, + "log_file": f"log_hercules_test{test_n}.log", + } + + # Run once - folders should be named as proposed + run_hercules_example(deepcopy(hercules_dict)) + + expected_output_h5_fpath = output_dir / f"hercules_output_test{test_n}.h5" + expected_output_main_log_fpath = logger_dir / f"log_hercules_test{test_n}.log" + expected_component_log_fpath = logger_dir / "log_open_cycle_gas_turbine.log" + + assert expected_output_h5_fpath.is_file(), "run 1, h5 file" + assert expected_output_main_log_fpath.is_file(), "run 1, main log file" + assert expected_component_log_fpath.is_file(), "run 1, component log file" + + # Run again - folders should be named with proposed name appended with a zero + run_hercules_example(deepcopy(hercules_dict)) + + expected_output_dir = output_dir.parent / f"{output_dir.name}0" + expected_logger_dir = logger_dir.parent / f"{logger_dir.name}0" + expected_output_h5_fpath = expected_output_dir / f"hercules_output_test{test_n}.h5" + + expected_output_main_log_fpath = expected_logger_dir / f"log_hercules_test{test_n}.log" + expected_component_log_fpath = expected_logger_dir / "log_open_cycle_gas_turbine.log" + + assert expected_output_h5_fpath.is_file(), "run 2, h5 file" + assert expected_output_main_log_fpath.is_file(), "run 2, main log file" + assert expected_component_log_fpath.is_file(), "run 2, component log file" + + # Run again - folders should be named with proposed name appended with a 1 + run_hercules_example(deepcopy(hercules_dict)) + + expected_output_dir = output_dir.parent / f"{output_dir.name}1" + expected_logger_dir = logger_dir.parent / f"{logger_dir.name}1" + expected_output_h5_fpath = expected_output_dir / f"hercules_output_test{test_n}.h5" + + expected_output_main_log_fpath = expected_logger_dir / f"log_hercules_test{test_n}.log" + expected_component_log_fpath = expected_logger_dir / "log_open_cycle_gas_turbine.log" + + assert expected_output_h5_fpath.is_file(), "run 3, h5 file" + assert expected_output_main_log_fpath.is_file(), "run 3, main log file" + assert expected_component_log_fpath.is_file(), "run 3, component log file" diff --git a/tests/hercules_model_test.py b/tests/hercules_model_test.py index b135b873..d78a7779 100644 --- a/tests/hercules_model_test.py +++ b/tests/hercules_model_test.py @@ -1,3 +1,5 @@ +from pathlib import Path + import numpy as np import pandas as pd from hercules.hercules_model import HerculesModel @@ -79,7 +81,7 @@ def test_HerculesModel_instantiation(): hmodel = HerculesModel(test_h_dict) # Check default settings - assert hmodel.output_file == "outputs/hercules_output.h5" + assert hmodel.output_file == Path("outputs/hercules_output.h5").absolute() assert hmodel.log_every_n == 1 assert hmodel.external_signals_all == {} @@ -107,7 +109,66 @@ def test_HerculesModel_instantiation(): assert hmodel.external_signals_all["power_reference"][-1] == 1000 # At time 6.0 # Check custom output file - assert hmodel.output_file == "test_output.h5" + assert hmodel.output_file.name == "test_output.h5" + + +def test_specified_outputs_dir(): + """Test that a user-specified output directory can be used for log files and output files""" + import os + import shutil + + import hercules + + HERCULES_ROOT = Path(hercules.__file__).parent.parent + TEST_DIR = HERCULES_ROOT / "tests" + + starting_cwd = str(Path.cwd().absolute()) + + temp_test_folder = HERCULES_ROOT / "tmp_tests" + + # If temp testing folder already exists, remove all existing files + if temp_test_folder.exists(): + shutil.rmtree(temp_test_folder) + Path(temp_test_folder).mkdir(parents=True, exist_ok=True) + + # change cwd to test folder + os.chdir(temp_test_folder) + + test_h_dict = h_dict_solar.copy() + # Enforce new loader policy: remove preset start/end and rely on *_utc + test_h_dict.pop("starttime", None) + test_h_dict.pop("endtime", None) + test_h_dict.pop("time", None) + test_h_dict.pop("step", None) + + outputs_folder_name = "nondefault_output_dirname" + test_h_dict["output_dir"] = outputs_folder_name + test_h_dict["output_file"] = f"{outputs_folder_name}/hercules_test_output.h5" + test_h_dict["solar_farm"]["solar_input_filename"] = str( + Path(TEST_DIR / "test_inputs" / "solar_pysam_data.csv").absolute() + ) + + expected_hercules_log = temp_test_folder / outputs_folder_name / "log_hercules.log" + expected_solar_log_fpath = temp_test_folder / outputs_folder_name / "log_solar_farm.log" + + hmodel = HerculesModel(test_h_dict) + + folders = [f for f in temp_test_folder.iterdir()] + + assert len(folders) == 1 + assert folders[0] == temp_test_folder / outputs_folder_name + assert expected_hercules_log.exists() + assert expected_solar_log_fpath.exists() + assert ( + hmodel.output_file.absolute() + == temp_test_folder / outputs_folder_name / "hercules_test_output.h5" + ) + + # Reset cwd + os.chdir(Path(starting_cwd)) + + # Remove all files and folders from temporary test dir + shutil.rmtree(temp_test_folder) def test_log_data_to_hdf5(): diff --git a/tests/utilities_test.py b/tests/utilities_test.py index cbc5f9d1..9e4f5fed 100644 --- a/tests/utilities_test.py +++ b/tests/utilities_test.py @@ -833,8 +833,9 @@ def test_setup_logging_full_path(): """Test setup_logging with full file path and use_outputs_dir=False.""" with tempfile.TemporaryDirectory() as tmpdir: log_file = str(Path(tmpdir) / "custom_logs" / "test.log") - - logger = setup_logging(logger_name="battery", log_file=log_file, use_outputs_dir=False) + log_dir = Path(log_file).parent + log_fname = Path(log_file).name + logger = setup_logging(logger_name="battery", log_file=log_fname, logging_dir=log_dir) # Verify log file was created at specified path assert Path(log_file).exists()