diff --git a/payu/experiment.py b/payu/experiment.py index b6ed2139..e47a351c 100644 --- a/payu/experiment.py +++ b/payu/experiment.py @@ -69,7 +69,7 @@ def wrapper(self, *args, **kwargs): class Experiment(object): - def __init__(self, lab, reproduce=False, force=False, metadata_off=False): + def __init__(self, lab, reproduce=False, force=False, metadata_off=False, config_path=None): self.init_timings() self.lab = lab # Check laboratory directories are writable @@ -82,14 +82,14 @@ def __init__(self, lab, reproduce=False, force=False, metadata_off=False): self.force = force # Initialise experiment metadata - uuid and experiment name - self.metadata = Metadata(Path(lab.archive_path), disabled=metadata_off) + self.metadata = Metadata(Path(lab.archive_path), disabled=metadata_off, config_path=config_path) self.metadata.setup() # TODO: replace with dict, check versions via key-value pairs self.modules = set() # TODO: __init__ should not be a config dumping ground! - self.config = read_config() + self.config = read_config(config_path) # Payu experiment type self.debug = self.config.get('debug', False) @@ -857,6 +857,9 @@ def get_model_restart_datetimes(self): pass return datetimes + def get_model_cur_expt_time(self): + return self.model.get_cur_expt_time() + def archiving(self): """ Determine whether to run archive step based on config.yaml settings. diff --git a/payu/models/access.py b/payu/models/access.py index ef686237..3cbaea4f 100644 --- a/payu/models/access.py +++ b/payu/models/access.py @@ -283,6 +283,11 @@ def get_restart_datetime(self, restart_path): return self.get_restart_datetime_using_submodel(restart_path, model_types) + def get_cur_expt_time(self): + """ Use UM submodel to get the current experiment time.""" + model_types = ['um'] + return self.get_cur_expt_time_using_submodel(model_types) + def set_model_pathnames(self): pass diff --git a/payu/models/access_esm1p6.py b/payu/models/access_esm1p6.py index 5d4ba199..9c7c6fdd 100644 --- a/payu/models/access_esm1p6.py +++ b/payu/models/access_esm1p6.py @@ -319,6 +319,11 @@ def get_restart_datetime(self, restart_path): return self.get_restart_datetime_using_submodel(restart_path, model_types) + def get_cur_expt_time(self): + """ Use UM submodel to get the current experiment time.""" + model_types = ['um'] + return self.get_cur_expt_time_using_submodel(model_types) + def set_model_pathnames(self): pass @@ -332,4 +337,4 @@ def set_model_output_paths(self): pass def collate(self): - pass + pass \ No newline at end of file diff --git a/payu/models/accessom2.py b/payu/models/accessom2.py index ddb246f2..67ce79c5 100644 --- a/payu/models/accessom2.py +++ b/payu/models/accessom2.py @@ -12,9 +12,14 @@ import os import shutil +import json +import warnings +import cftime +import logging from payu.models.model import Model +logger = logging.getLogger(__name__) class AccessOm2(Model): @@ -94,3 +99,23 @@ def get_restart_datetime(self, restart_path): return self.get_restart_datetime_using_submodel(restart_path, model_types) + + def get_cur_expt_time(self): + """Get the current experiment time from file work/atmosphere/log/matmxx.pe00000.log. + --- + output: + cftime.datetime or None if it cannot be determined. + """ + log_path = os.path.join(self.expt.work_path, 'atmosphere', 'log', + 'matmxx.pe00000.log') + + with open(log_path, 'r') as f: + for line in reversed(f.readlines()): + if 'cur_exp-datetime' in line: + line_json = json.loads(line) + time_str = line_json.get('cur_exp-datetime', None) + if time_str is not None: + return cftime.datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%S') + + raise ValueError(f"Key 'cur_exp-datetime' not found in {log_path}") + diff --git a/payu/models/cesm_cmeps.py b/payu/models/cesm_cmeps.py index 8f4629b2..4b5e2061 100644 --- a/payu/models/cesm_cmeps.py +++ b/payu/models/cesm_cmeps.py @@ -16,12 +16,15 @@ import shutil import cftime from warnings import warn +import logging from payu.fsops import make_symlink from payu.models.model import Model from payu.models.fms import fms_collate from payu.models.mom6 import mom6_add_parameter_files, mom6_save_docs_files +logger = logging.getLogger(__name__) + NUOPC_CONFIG = "nuopc.runconfig" NUOPC_RUNSEQ = "nuopc.runseq" @@ -422,7 +425,22 @@ def get_components(self): "Access-OM3 comprises a data runoff model, but the runoff model in nuopc.runconfig is set " f"to {self.components['rof']}." ) - + + def get_cur_expt_time(self): + """Get the current experiment time from file work/log/med.log. + --- + output: + cftime.datetime or None if it cannot be determined. + """ + log_path = os.path.join(self.expt.work_path, 'log', 'med.log') + + with open(log_path, 'r') as f: + for line in reversed(f.readlines()): + if line.startswith(" memory_write: model date"): + time_str = line.split()[4] + return cftime.datetime.strptime(time_str, '%Y-%m-%dT%H:%M:%S') + + return None class Runconfig: """ Simple class for parsing and editing nuopc.runconfig """ diff --git a/payu/models/model.py b/payu/models/model.py index d9d9868b..67d71fb8 100644 --- a/payu/models/model.py +++ b/payu/models/model.py @@ -13,7 +13,6 @@ from payu import envmod from payu.fsops import required_libs - class Model(object): """Abstract model class.""" @@ -449,3 +448,29 @@ def get_restart_datetime_using_submodel(self, restart_path, model_types): f'{self.model_type} date-based restart pruning requires one of ' 'these sub-models to determine restart dates.' ) + + def get_cur_expt_time(self): + """For model not implemented experiment time calculate/read-out, + leaves a warning and returns None.""" + print("Current experiment time not implemented for this model.") + return None + + def get_cur_expt_time_using_submodel(self, model_types): + """ + Use a specified submodel's get_cur_expt_time method + + Parameters + ---------- + model_types: List of submodels in order of priority. Use first + submodel in model_types that is present in the experiment. + + Returns: + -------- + Current experiment time in cftime.datetime + """ + for model_type in model_types: + for model in self.expt.models: + if model.model_type == model_type: + cur_expt_time = model.get_cur_expt_time() + if cur_expt_time is not None: + return cur_expt_time diff --git a/payu/models/mom6.py b/payu/models/mom6.py index 506fab16..fad82c5d 100644 --- a/payu/models/mom6.py +++ b/payu/models/mom6.py @@ -10,6 +10,9 @@ # Standard library import os +from datetime import datetime, timedelta +import cftime +from collections import deque # Extensions import f90nml @@ -130,3 +133,39 @@ def archive(self): mom6_save_docs_files(self) super().archive() + + def read_start_date(self, input_path, calendar): + """Read the start date from input.nml.""" + input_nml = f90nml.read(input_path) + start_date_list = input_nml.get('ocean_solo_nml', {}).get('date_init', None) + if start_date_list is None: + raise ValueError(f"Key 'date_init' not found in {input_path}") + return cftime.datetime(*start_date_list, calendar=calendar) + + def read_timestep(self, stats_path): + """ Read the current timestep from ocean.stats.""" + with open(stats_path, 'r') as f: + line = deque(f, maxlen=1)[0] + timestep = float(line.split(',')[1]) + return timestep + + def get_cur_expt_time(self): + """Get the current experiment time from log file. + --- + output: + cftime.datetime or None if it cannot be determined. + """ + ocean_solo_path = os.path.join(self.expt.work_path, 'INPUT', 'ocean_solo.res') + calendar = self.get_calendar(ocean_solo_path) + + input_path = os.path.join(self.expt.work_path, 'input.nml') + start_date = self.read_start_date(input_path, calendar) + + stats_path = os.path.join(self.expt.work_path, 'ocean.stats') + timestep = self.read_timestep(stats_path) + + if start_date is None or timestep is None: + return None + + cur_expt_time = start_date + timedelta(days=timestep) + return cur_expt_time \ No newline at end of file diff --git a/payu/models/mom_mixin.py b/payu/models/mom_mixin.py index e6194483..1b7868da 100644 --- a/payu/models/mom_mixin.py +++ b/payu/models/mom_mixin.py @@ -10,6 +10,19 @@ class MomMixin: + def get_calendar(self, ocean_solo_path): + """Get the calendar type from the ocean_solo.res file.""" + with open(ocean_solo_path, 'r') as ocean_solo: + line = ocean_solo.readlines()[0] + calendar_int = int(line.split()[0]) + + cftime_calendars = { + 1: "360_day", + 2: "julian", + 3: "proleptic_gregorian", + 4: "noleap" + } + return cftime_calendars[calendar_int] def get_restart_datetime(self, restart_path): """Given a restart path, parse the restart files and @@ -21,19 +34,11 @@ def get_restart_datetime(self, restart_path): 'Cannot find ocean_solo.res file, which is required for ' 'date-based restart pruning') - with open(ocean_solo_path, 'r') as ocean_solo: - lines = ocean_solo.readlines() + calendar = self.get_calendar(ocean_solo_path) - calendar_int = int(lines[0].split()[0]) - cftime_calendars = { - 1: "360_day", - 2: "julian", - 3: "proleptic_gregorian", - 4: "noleap" - } - calendar = cftime_calendars[calendar_int] - - last_date_line = lines[-1].split() + with open(ocean_solo_path, 'r') as ocean_solo: + lines = ocean_solo.readlines()[-1] + last_date_line = lines.split() date_values = [int(i) for i in last_date_line[:6]] year, month, day, hour, minute, second = date_values return cftime.datetime(year, month, day, hour, minute, second, diff --git a/payu/models/um.py b/payu/models/um.py index 8d88851d..17ab90e3 100644 --- a/payu/models/um.py +++ b/payu/models/um.py @@ -253,6 +253,44 @@ def get_restart_datetime(self, restart_path): # Payu UM always uses proleptic Gregorian calendar return cal.date_to_cftime(restart_date, UM_CFTIME_CALENDAR) + def convert_timestep(self, log_path): + """ Convert the timestep to experiment runtime + based on the information in log file""" + timestep = None + step_per_period = 0 + secs_per_period = 0 + + with open(log_path, 'r') as f: + for line in f: + if 'STEPS_PER_PERIODim' in line: + step_per_period = int(line.split('=')[-1].strip()) + if 'SECS_PER_PERIODim' in line: + secs_per_period = int(line.split('=')[-1].strip()) + if 'Atm_Step: Timestep' in line: + timestep = int(line.split()[-1]) + + if timestep is not None and secs_per_period > 0 and step_per_period > 0: + secs_per_step = secs_per_period / step_per_period + runtime_sec = timestep * secs_per_step + return runtime_sec + + raise ValueError( + f"Could not find all required entries in file {log_path}" + f" to calculate run time" + ) + + def get_cur_expt_time(self): + """Get the current experiment time from file""" + log_path = os.path.join(self.expt.work_path, 'atmosphere', 'atm.fort6.pe0') + + start_date_dir = os.path.join(self.expt.work_path, 'atmosphere') + start_date = self.get_restart_datetime(start_date_dir) + runtime_sec = self.convert_timestep(log_path) + if start_date is None or runtime_sec is None: + raise ValueError("Could not determine current experiment time.") + cur_expt_time = start_date + datetime.timedelta(seconds=runtime_sec) + return cur_expt_time + def date_to_um_dump_date(date): """ diff --git a/payu/status.py b/payu/status.py index 8dc3898c..845cf19a 100644 --- a/payu/status.py +++ b/payu/status.py @@ -8,6 +8,8 @@ from typing import Any, Optional import warnings from datetime import datetime +import json +import logging from payu.schedulers import Scheduler from payu.telemetry import ( @@ -16,6 +18,7 @@ remove_job_file ) +logger = logging.getLogger(__name__) def find_file_match(pattern: str, path: Path) -> Optional[Path]: """Find a file matching the pattern in the given path""" @@ -132,7 +135,8 @@ def build_job_info( archive_path: Path, control_path: Path, run_number: Optional[int] = None, - all_runs: Optional[bool] = False + all_runs: Optional[bool] = False, + expt=None ) -> Optional[dict[str, Any]]: """ Generate a dictionary of jobs information (exit status, stage), @@ -176,6 +180,10 @@ def build_job_info( "start_time": data.get("timings", {}).get("payu_start_time"), } + if data.get("stage") == "archive": + # For archive stage, add the model finish time if available + run_info["model_finish_time"] = data.get("model_finish_time", None) + run_num = data["payu_current_run"] runs.setdefault(run_num, {"run": []})["run"].append(run_info) @@ -197,6 +205,16 @@ def build_job_info( for run_num, run_jobs in status_data["runs"].items(): run_jobs["run"] = [run_jobs["run"][-1]] + if run_info.get("stage") == "model-run" and expt is not None: + try: + cur_expt_time = expt.get_model_cur_expt_time() + if cur_expt_time is not None: + run_info["cur_expt_time"] = cur_expt_time.isoformat() + except (FileNotFoundError, IndexError, OSError, json.JSONDecodeError, ValueError, NotImplementedError) as e: + logger.debug(f"Cannot parse current experiment time: {e}") + except Exception as e: + logger.warning(f"Unexpected error while parsing current experiment time: {e}") + return status_data @@ -299,10 +317,15 @@ def display_job_info(data: dict[str, Any]) -> None: #read out qtime and stime from the job file job_file = run_info.get("job_file") job_id = run_info.get("job_id") - all_job_info = read_job_file(Path(job_file)).get("scheduler_job_info", {}) - job_info = all_job_info.get("Jobs", {}).get(job_id, {}) + all_job_info = read_job_file(Path(job_file)) + job_info = all_job_info.get("scheduler_job_info", {}).get("Jobs", {}).get(job_id, {}) display_wait_time(job_info.get("qtime", None), job_info.get("stime", None)) + if run_info.get("stage") == "model-run": + print_line("Current Expt Time", "cur_expt_time", run_info) + if run_info.get("stage") == "archive": + model_finish_time = all_job_info.get("model_finish_time", None) + print(f" {'Model Finish Time:':<18} {model_finish_time}") exit_status = run_info.get("exit_status") if exit_status is not None: status_str = "Success" if exit_status == 0 else "Failed" diff --git a/payu/subcommands/status_cmd.py b/payu/subcommands/status_cmd.py index 9ce00db0..01223366 100644 --- a/payu/subcommands/status_cmd.py +++ b/payu/subcommands/status_cmd.py @@ -3,12 +3,12 @@ import os from pathlib import Path import warnings - import json from payu.fsops import read_config from payu.metadata import MetadataWarning, Metadata from payu.laboratory import Laboratory +from payu.experiment import Experiment import payu.subcommands.args as args from payu.status import ( build_job_info, @@ -31,21 +31,17 @@ def runcmd(lab_path, config_path, json_output, # Suppress output to os.devnull with redirect_stdout(open(os.devnull, 'w')): # Determine archive path - lab = Laboratory(lab_path) + lab = Laboratory(config_path=config_path, lab_path=lab_path) warnings.filterwarnings("error", category=MetadataWarning) try: - metadata = Metadata(Path(lab.archive_path), - config_path=config_path) - metadata.setup() + expt = Experiment(lab, config_path=config_path) except MetadataWarning as e: raise RuntimeError( "Metadata is not setup - can't determine archive path" ) - archive_path = Path(lab.archive_path) / metadata.experiment_name - # Determine control path - config = read_config(config_path) - control_path = Path(config['control_path']) + archive_path = Path(expt.archive_path) + control_path = Path(expt.control_path) run_number = int(run_number) if run_number is not None else None @@ -53,12 +49,12 @@ def runcmd(lab_path, config_path, json_output, control_path=control_path, archive_path=archive_path, run_number=run_number, - all_runs=all_runs + all_runs=all_runs, + expt=expt ) if update_jobs: # Get the scheduler - scheduler_name = config.get('scheduler', DEFAULT_SCHEDULER_CONFIG) - scheduler = scheduler_index[scheduler_name]() + scheduler = expt.scheduler # Update the job files in data with the latest information # from the scheduler update_all_job_files(data, scheduler) @@ -67,7 +63,8 @@ def runcmd(lab_path, config_path, json_output, archive_path=archive_path, control_path=control_path, run_number=run_number, - all_runs=all_runs + all_runs=all_runs, + expt=expt ) if json_output: diff --git a/test/models/access-om3/test_access_om3.py b/test/models/access-om3/test_access_om3.py index b1050b6b..eeb5624c 100644 --- a/test/models/access-om3/test_access_om3.py +++ b/test/models/access-om3/test_access_om3.py @@ -581,4 +581,63 @@ def test_collect_restart_files_incorrect_parallel(): # Check a representative missing file appears in the message assert "access-om3.cice.r.1900-01-01-00000.nc" in str(e.value) - teardown_cmeps_config() \ No newline at end of file + teardown_cmeps_config() + +def test_get_cur_expt_time(): + """ Test if get_cur_expt_time correctly parses the model date from the log file. """ + cmeps_config(1) + + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + model = expt.models[0] + + log_path = os.path.join(model.work_path, "log", "med.log") + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, "w") as f: + f.write(" memory_write: model date = 1900-01-02T00:00:00 \n") + + cur_expt_time = model.get_cur_expt_time() + + assert cur_expt_time.isoformat() == "1900-01-02T00:00:00" + + teardown_cmeps_config() + os.remove(log_path) + + +def test_get_cur_expt_time_no_log(): + """ Test if get_cur_expt_time raise an error if log file is missing. """ + cmeps_config(1) + + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + model = expt.models[0] + + log_path = os.path.join(model.work_path, "log", "med.log") + + assert not os.path.exists(log_path) + with pytest.raises(FileNotFoundError): + cur_expt_time = model.get_cur_expt_time() + + teardown_cmeps_config() + +def test_get_cur_expt_time_no_date(): + """ Test if get_cur_expt_time returns None if log file does not contain model date. """ + cmeps_config(1) + + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + model = expt.models[0] + + log_path = os.path.join(model.work_path, "log", "med.log") + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, "w") as f: + f.write("This log file does not contain the model date.\n") + + cur_expt_time = model.get_cur_expt_time() + assert cur_expt_time is None + + teardown_cmeps_config() + os.remove(log_path) \ No newline at end of file diff --git a/test/models/test_access.py b/test/models/test_access.py index a5b11b01..b2857060 100644 --- a/test/models/test_access.py +++ b/test/models/test_access.py @@ -22,10 +22,8 @@ from test.models.test_mom_mixin import make_ocean_restart_dir from payu.calendar import GREGORIAN, NOLEAP - verbose = True - INPUT_ICE_FNAME = "input_ice.nml" RESTART_DATE_FNAME = "restart_date.nml" SEC_PER_DAY = 24*60*60 @@ -461,3 +459,64 @@ def test_access_get_um_restart_datetime(um_only_config, remove_restart_dirs): restart_path = list_expt_archive_dirs()[0] parsed_run_dt = expt.model.get_restart_datetime(restart_path) assert parsed_run_dt == date + +def test_get_cur_expt_time(um_only_config): + """ + Check that a debug message is logged when trying to get the current experiment time + for the access model, as this functionality is not yet implemented. + """ + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + # write the um.res.yaml with a known restart date + restart_calendar_path = os.path.join(expt.work_path, 'atmosphere', 'um.res.yaml') + os.makedirs(os.path.dirname(restart_calendar_path), exist_ok=True) + with open(restart_calendar_path, 'w') as f: + f.write("end_date: 1900-01-31 00:00:00\n") + + #write log file with a known timestep and default step length (30 min) + log_path = os.path.join(expt.work_path, 'atmosphere', 'atm.fort6.pe0') + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, 'w') as f: + f.write(f"U_MODEL: STEPS_PER_PERIODim= 48\n") + f.write(f"U_MODEL: SECS_PER_PERIODim= 86400\n") + f.write(f"Atm_Step: Timestep 10\n") + + cur_expt_time = expt.get_model_cur_expt_time() + assert cur_expt_time.isoformat() == "1900-01-31T05:00:00" + +@pytest.mark.parametrize("missing_file", [ + ( + ['um.res.yaml'] + ), + ( + ['atm.fort6.pe0'] + ), + ( + ['um.res.yaml', 'atm.fort6.pe0'] + ) +]) +def test_get_cur_expt_time_missing_files(um_only_config, missing_file): + """ + Check that a debug message is logged when trying to get the current experiment time + for the access model, as this functionality is not yet implemented. + """ + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + restart_calendar_path = os.path.join(expt.work_path, 'atmosphere', 'um.res.yaml') + log_path = os.path.join(expt.work_path, 'atmosphere', 'atm.fort6.pe0') + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(restart_calendar_path, 'w') as f: + f.write("end_date: 1901-03-01 00:00:00\n") + open(log_path, 'a').close() + + if 'um.res.yaml' in missing_file: + os.remove(restart_calendar_path) + if 'atm.fort6.pe0' in missing_file: + os.remove(log_path) + + with pytest.raises(FileNotFoundError): + cur_expt_time = expt.get_model_cur_expt_time() \ No newline at end of file diff --git a/test/models/test_access_esm1p6.py b/test/models/test_access_esm1p6.py index fc3c8b81..b107c9e7 100644 --- a/test/models/test_access_esm1p6.py +++ b/test/models/test_access_esm1p6.py @@ -1,10 +1,12 @@ import copy import os import shutil +import f90nml import pytest import payu +from payu.models.access_esm1p6 import AccessEsm1p6 from test.common import cd, expt_workdir from test.common import tmpdir, ctrldir, labdir, workdir, archive_dir @@ -150,3 +152,66 @@ def test_esm1p6_patch_optional_config_files(um_only_ctrl_dir, set(esm1p6_um_model.optional_config_files) == set(um_standalone_model.optional_config_files).union(expected_files) ) + + +def test_get_cur_expt_time(um_only_ctrl_dir, esm1p6_um_only_config): + """ + Test that the access-esm1.6 driver correctly parses the model_basis_time. + """ + # Initialise ESM1.6 + with cd(ctrldir): + esm1p6_lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + esm1p6_expt = payu.experiment.Experiment(esm1p6_lab, reproduce=False) + + # write the um.res.yaml with a known restart date + restart_calendar_path = os.path.join(esm1p6_expt.work_path, 'atmosphere', 'um.res.yaml') + os.makedirs(os.path.dirname(restart_calendar_path), exist_ok=True) + with open(restart_calendar_path, 'w') as f: + f.write("end_date: 1900-01-31 00:00:00\n") + + #write log file with a known timestep and default step length (30 min) + log_path = os.path.join(esm1p6_expt.work_path, 'atmosphere', 'atm.fort6.pe0') + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, 'w') as f: + f.write(f"U_MODEL: STEPS_PER_PERIODim= 48\n") + f.write(f"U_MODEL: SECS_PER_PERIODim= 86400\n") + f.write(f"Atm_Step: Timestep 10\n") + + cur_expt_time = esm1p6_expt.get_model_cur_expt_time() + assert cur_expt_time.isoformat() == "1900-01-31T05:00:00" + + +@pytest.mark.parametrize("missing_file", [ + ( + ['um.res.yaml'] + ), + ( + ['atm.fort6.pe0'] + ), + ( + ['um.res.yaml', 'atm.fort6.pe0'] + ) +]) +def test_get_cur_expt_time_missing_files(um_only_ctrl_dir, esm1p6_um_only_config, missing_file): + """ + Test that the access-esm1.6 driver correctly handles missing files. + """ + # Initialise ESM1.6 + with cd(ctrldir): + esm1p6_lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + esm1p6_expt = payu.experiment.Experiment(esm1p6_lab, reproduce=False) + + restart_calendar_path = os.path.join(esm1p6_expt.work_path, 'atmosphere', 'um.res.yaml') + log_path = os.path.join(esm1p6_expt.work_path, 'atmosphere', 'atm.fort6.pe0') + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(restart_calendar_path, 'w') as f: + f.write("end_date: 1901-03-01 00:00:00\n") + open(log_path, 'a').close() + + if 'um.res.yaml' in missing_file: + os.remove(restart_calendar_path) + if 'atm.fort6.pe0' in missing_file: + os.remove(log_path) + + with pytest.raises(FileNotFoundError): + cur_expt_time = esm1p6_expt.get_model_cur_expt_time() diff --git a/test/models/test_access_om2.py b/test/models/test_access_om2.py new file mode 100644 index 00000000..d4c75e3e --- /dev/null +++ b/test/models/test_access_om2.py @@ -0,0 +1,107 @@ +import copy +import os +import shutil +import pytest + +import payu + +from test.common import cd, tmpdir, ctrldir, labdir, workdir, write_config, config_path +from test.common import config as config_orig +from test.common import make_inputs, make_exe + +MODEL = 'access-om2' + +def setup_module(module): + """ + Put any test-wide setup code in here, e.g. creating test files + """ + + # Should be taken care of by teardown, in case remnants lying around + try: + shutil.rmtree(tmpdir) + except FileNotFoundError: + pass + + try: + tmpdir.mkdir() + labdir.mkdir() + ctrldir.mkdir() + workdir.mkdir() + # archive_dir.mkdir() + make_inputs() + make_exe() + except Exception as e: + print(e) + + +def setup_config(ncpu): + # Create a config.yaml + + config = copy.deepcopy(config_orig) + config['model'] = MODEL + config['ncpus'] = ncpu + + write_config(config) + +def teardown_config(): + # Teardown + os.remove(config_path) + +def test_get_cur_expt_time(): + """ Test if get_cur_expt_time correctly parses the model date from the log file. """ + setup_config(1) + + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + model = expt.models[0] + + log_path = os.path.join(model.work_path, "atmosphere", "log", "matmxx.pe00000.log") + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, "w") as f: + f.write('{ "cur_exp-datetime" : "1900-01-31T00:00:00" }') + + cur_expt_time = model.get_cur_expt_time() + + assert cur_expt_time.isoformat() == "1900-01-31T00:00:00" + + teardown_config() + os.remove(log_path) + +def test_get_cur_expt_time_no_log(): + """ Test if get_cur_expt_time returns None if log file is missing. """ + setup_config(1) + + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + model = expt.models[0] + + log_path = os.path.join(model.work_path, "atmosphere", "log", "matmxx.pe00000.log") + + assert not os.path.exists(log_path) + with pytest.raises(FileNotFoundError): + cur_expt_time = model.get_cur_expt_time() + + teardown_config() + +def test_get_cur_expt_time_no_date(): + """ Test if get_cur_expt_time raise an error if log file does not contain model date. """ + setup_config(1) + + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + model = expt.models[0] + + log_path = os.path.join(model.work_path, "atmosphere", "log", "matmxx.pe00000.log") + os.makedirs(os.path.dirname(log_path), exist_ok=True) + with open(log_path, "w") as f: + f.write("This log file does not contain the model date.\n") + + with pytest.raises(ValueError, match=f"Key 'cur_exp-datetime' not found in {log_path}"): + cur_expt_time = model.get_cur_expt_time() + assert cur_expt_time is None + + teardown_config() + os.remove(log_path) \ No newline at end of file diff --git a/test/models/test_mom6.py b/test/models/test_mom6.py index 050527da..b9526746 100644 --- a/test/models/test_mom6.py +++ b/test/models/test_mom6.py @@ -310,3 +310,158 @@ def test_setup(): input_nml = f90nml.read(work_input_fpath) assert input_nml['MOM_input_nml']['input_filename'] == 'n' assert input_nml['SIS_input_nml']['input_filename'] == 'n' + + +def test_get_cur_expt_time(): + """ Test that get_model_cur_expt_time() correctly reads the start date from input.nml + and the time from ocean.stats.""" + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + # Write a restart date into input.nml + input_path = os.path.join(expt.work_path, 'input.nml') + nml = f90nml.Namelist() + nml['ocean_solo_nml'] = {'date_init': [1900, 1, 31, 0, 0, 0]} + f90nml.write(nml, input_path, force=True) + + # Write a timestep into ocean.stats + stats_path = os.path.join(expt.work_path, 'ocean.stats') + with open(stats_path, 'w') as f: + f.write("360, 50.000,\n") # timestep, time in days + + # Write a calendar into ocean_solo.res + ocean_solo_path = os.path.join(expt.work_path, 'INPUT', 'ocean_solo.res') + os.makedirs(expt.restart_path, exist_ok=True) + with open(ocean_solo_path, 'w') as f: + f.write("2\n") # Use Julian calendar + + cur_expt_time = expt.get_model_cur_expt_time() + assert cur_expt_time.isoformat() == "1900-03-21T00:00:00" + +@pytest.mark.parametrize("missing_file",[ + ( + ['input.nml'] + ), + ( + ['ocean.stats'] + ), + ( + ['ocean_solo.res'] + ), + ( + ['input.nml', 'ocean.stats', 'ocean_solo.res'] + ) +]) +def test_get_cur_expt_time_missing_files(missing_file): + """ Test that get_model_cur_expt_time() correctly handles missing files.""" + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + # Write a restart date into input.nml + input_path = os.path.join(expt.work_path, 'input.nml') + nml = f90nml.Namelist() + nml['ocean_solo_nml'] = {'date_init': [1900, 1, 31, 0, 0, 0]} + f90nml.write(nml, input_path, force=True) + + # Write a timestep into ocean.stats + stats_path = os.path.join(expt.work_path, 'ocean.stats') + with open(stats_path, 'w') as f: + f.write("360, 50.000,\n") # timestep, time in days + + # Write a calendar into ocean_solo.res + ocean_solo_path = os.path.join(expt.work_path, 'INPUT', 'ocean_solo.res') + os.makedirs(expt.restart_path, exist_ok=True) + with open(ocean_solo_path, 'w') as f: + f.write("2\n") # Use Julian calendar + + for file in missing_file: + if file == 'input.nml' or file == 'ocean.stats': + os.remove(os.path.join(expt.work_path, file)) + elif file == 'ocean_solo.res': + os.remove(os.path.join(expt.work_path, 'INPUT', file)) + + with pytest.raises(FileNotFoundError): + cur_expt_time = expt.get_model_cur_expt_time() + + +def test_read_start_date(): + """ Test that read_start_date() correctly handle errors.""" + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + # Write a restart date into input.nml + input_path = os.path.join(expt.work_path, 'input.nml') + nml = f90nml.Namelist() + nml['top_key'] = {'key': 'value'} + f90nml.write(nml, input_path, force=True) + + # Write a timestep into ocean.stats + stats_path = os.path.join(expt.work_path, 'ocean.stats') + with open(stats_path, 'w') as f: + f.write("360, 50.000,\n") + + # Write a calendar and current model time into ocean_solo.res + ocean_solo_path = os.path.join(expt.work_path, 'INPUT', 'ocean_solo.res') + os.makedirs(os.path.dirname(ocean_solo_path), exist_ok=True) + with open(ocean_solo_path, 'w') as f: + f.write("2\n") # Use Julian calendar + + with pytest.raises(ValueError, match=f"Key 'date_init' not found in {input_path}"): + cur_expt_time = expt.get_model_cur_expt_time() + assert cur_expt_time is None + + +def test_read_timestep(): + """ Test that read_timestep() correctly handle errors.""" + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + # Write a restart date into input.nml + input_path = os.path.join(expt.work_path, 'input.nml') + nml = f90nml.Namelist() + nml['ocean_solo_nml'] = {'date_init': [1900, 1, 31, 0, 0, 0]} + f90nml.write(nml, input_path, force=True) + + # Write a timestep into ocean.stats + stats_path = os.path.join(expt.work_path, 'ocean.stats') + with open(stats_path, 'w') as f: + f.write("0\n") + + # Write a calendar into ocean_solo.res + ocean_solo_path = os.path.join(expt.work_path, 'INPUT', 'ocean_solo.res') + os.makedirs(os.path.dirname(ocean_solo_path), exist_ok=True) + with open(ocean_solo_path, 'w') as f: + f.write("2\n") # Use Julian calendar + + with pytest.raises(IndexError): + cur_expt_time = expt.get_model_cur_expt_time() + +def test_get_calendar(): + """ Test that get_calendar() correctly handle error when ocean_solo.res is empty.""" + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + # Write a restart date into input.nml + input_path = os.path.join(expt.work_path, 'input.nml') + nml = f90nml.Namelist() + nml['ocean_solo_nml'] = {'date_init': [1900, 1, 31, 0, 0, 0]} + f90nml.write(nml, input_path, force=True) + + # Write a timestep into ocean.stats + stats_path = os.path.join(expt.work_path, 'ocean.stats') + with open(stats_path, 'w') as f: + f.write("0\n") + + # Write a calendar into ocean_solo.res + ocean_solo_path = os.path.join(expt.work_path, 'INPUT', 'ocean_solo.res') + os.makedirs(os.path.dirname(ocean_solo_path), exist_ok=True) + with open(ocean_solo_path, 'w') as f: + f.write("\n") # Empty + + with pytest.raises(IndexError): + cur_expt_time = expt.get_model_cur_expt_time() \ No newline at end of file diff --git a/test/models/test_um.py b/test/models/test_um.py index 4d23d5c1..bd4d2256 100644 --- a/test/models/test_um.py +++ b/test/models/test_um.py @@ -130,3 +130,22 @@ def test_um_get_restart_datetime(date): restart_path = list_expt_archive_dirs()[0] parsed_run_dt = expt.model.get_restart_datetime(restart_path) assert parsed_run_dt == date + +def test_convert_timestep(): + """ Test with an invalid log file""" + # Initialise ESM1.6 + with cd(ctrldir): + lab = payu.laboratory.Laboratory(lab_path=str(labdir)) + expt = payu.experiment.Experiment(lab, reproduce=False) + + log_path = os.path.join(expt.work_path, 'atmosphere', 'atm.fort6.pe0') + os.makedirs(os.path.dirname(log_path), exist_ok=True) + + # write invalid content into log file + with open(log_path, 'w') as f: + f.write(f"U_MODEL: STEPS_PER_PERIODim= 48\n") + f.write(f"U_MODEL: SECS_PER_PERIODim= 86400\n") + f.write(f"There is no Timestep\n") + + with pytest.raises(ValueError, match=f"Could not find all required entries in file {log_path}"): + expt.model.convert_timestep(log_path) diff --git a/test/test_status.py b/test/test_status.py index 23d04cba..53d07285 100644 --- a/test/test_status.py +++ b/test/test_status.py @@ -1,6 +1,7 @@ import json import pytest from freezegun import freeze_time +import cftime from payu.status import ( find_file_match, @@ -10,6 +11,7 @@ build_job_info ) +from payu.experiment import Experiment from payu.subcommands.status_cmd import runcmd from payu.git_utils import PayuGitWarning @@ -302,7 +304,8 @@ def expected_archive_job_info(run_number): 'stage': 'archive', 'stderr_file': None, 'stdout_file': None, - 'start_time': f'2025-08-1{run_number}T12:00:00' + 'start_time': f'2025-08-1{run_number}T12:00:00', + 'model_finish_time': None } @@ -606,4 +609,118 @@ def test_status_queue_time(tmp_path, capsys, job_stage, qtime, stime, time_label # Check the output contains the expected queue time output = capsys.readouterr().out assert time_label in output - assert time_message in output \ No newline at end of file + assert time_message in output + +@pytest.mark.parametrize("cur_expt_time", [ + (cftime.datetime(2026, 2, 10, 15, 0, 0)), + (None) +]) +def test_status_cur_expt_time(tmp_path, monkeypatch, capsys, cur_expt_time): + """Test that current experiment time is displayed at the stage of model-run.""" + # Create a temporary lab and config + lab_path = tmp_path / "lab" + archive_path = lab_path / "archive" / "control-exp" + archive_path.mkdir(parents=True, exist_ok=True) + control_path = tmp_path / "control-exp" + control_path.mkdir() + config_path = control_path / "config.yaml" + + # Create a minimal config file + with open(config_path, 'w') as f: + json.dump({'model': 'test'}, f) + + # Create a minimal metadata file + metadata_path = control_path / "metadata.yaml" + with open(metadata_path, 'w') as f: + json.dump({'experiment_uuid': 'test-uuid'}, f) + + # Create a queued job file + job_file = archive_path / "payu_jobs" / "3" / "run" / "test-job-id-3.json" + job_file.parent.mkdir(parents=True, exist_ok=True) + + job_data = { + "scheduler_job_id": "test-job-id-3", + "scheduler_type": "pbs", + "experiment_metadata": {"experiment_uuid": "test-uuid"}, + "payu_current_run": 3, + "stage": "model-run", + "scheduler_job_info":{ + "Jobs": { + "test-job-id-3":{"Job_Name": "double_gyre",} + } + } + } + with open(job_file, 'w') as f: + json.dump(job_data, f) + + # Run the command + monkeypatch.setattr(Experiment, "get_model_cur_expt_time", lambda self: cur_expt_time) + with pytest.warns(PayuGitWarning): + runcmd( + lab_path=str(lab_path), + config_path=str(config_path), + json_output=False, + update_jobs=False, + all_runs=False, + run_number=None + ) + + # Check the output contains the expected cur_expt_time + output = capsys.readouterr().out + if cur_expt_time: + assert "Current Expt Time:" in output + assert cur_expt_time.isoformat() in output + else: + assert "Current Expt Time:" not in output + + +def test_status_model_finish_time(tmp_path, capsys): + """Test that model finish time is displayed for an archived job.""" + # Create a temporary lab and config + lab_path = tmp_path / "lab" + archive_path = lab_path / "archive" / "control-exp" + archive_path.mkdir(parents=True, exist_ok=True) + control_path = tmp_path / "control-exp" + control_path.mkdir() + config_path = control_path / "config.yaml" + + # Create a minimal config file + with open(config_path, 'w') as f: + json.dump({'model': 'test'}, f) + + # Create a minimal metadata file + metadata_path = control_path / "metadata.yaml" + with open(metadata_path, 'w') as f: + json.dump({'experiment_uuid': 'test-uuid'}, f) + + # Create an archived job file + job_file = archive_path / "payu_jobs" / "3" / "run" / "test-job-id-3.json" + job_file.parent.mkdir(parents=True, exist_ok=True) + + job_data = { + "scheduler_job_id": "test-job-id-3", + "scheduler_type": "pbs", + "experiment_metadata": {"experiment_uuid": "test-uuid"}, + "payu_current_run": 3, + "stage": "archive", + "payu_model_run_status": 0, + "model_finish_time": "2026-03-11T15:00:00" + } + with open(job_file, 'w') as f: + json.dump(job_data, f) + + # Run the command + with pytest.warns(PayuGitWarning): + runcmd( + lab_path=str(lab_path), + config_path=str(config_path), + json_output=False, + update_jobs=False, + all_runs=False, + run_number=None + ) + + # Check the output contains the expected model finish time + output = capsys.readouterr().out + assert "Model Finish Time:" in output + assert "2026-03-11T15:00:00" in output \ No newline at end of file