Skip to content
9 changes: 6 additions & 3 deletions payu/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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.
Expand Down
69 changes: 69 additions & 0 deletions payu/models/access_esm1p6.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import re
import shutil
import sys
import warnings

# Extensions
import f90nml
Expand Down Expand Up @@ -333,3 +334,71 @@ def set_model_output_paths(self):

def collate(self):
pass

def read_start_date(self, nl_path):
"""Read the start date from the namelist file"""
try:
namelist = f90nml.read(nl_path)
model_basis_time = namelist.get('nlstcall', {}).get('model_basis_time', None)
if model_basis_time is None:
warnings.warn(f"model_basis_time not found in {nl_path}")
return None
return datetime(*model_basis_time)
except Exception as e:
warnings.warn(f"Could not read file {nl_path}: {e}")
return None
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies @Qian-HuiChen, I'm think my previous suggestion for reading the start date from the work/atmosphere/namelists file might not be the best one! It should work perfectly as is, but I'm thinking there might be existing methods which could be used to reduce duplication.

Each UM restart directory contains a calendar yaml file um.res.yaml, which holds a copy of the restart date (see /g/data/vk83/prerelease/configurations/inputs/access-esm1p6/modern/pre-industrial/restart/2026.02.20/atmosphere for an example).

I'd forgotten that we'd previously added a method to the um.py driver, get_restart_datetime which reads the date from this file, and uses it for the date-based restart pruning.

I'm wondering if it would make sense to reuse this method to get the start date, as it could be pointed to the copy of um.res.yaml in the atmosphere/work directory.

One issue is that the um.res.yaml is technically not required to run the model, and it's possible for it to be missing. This would nearly never be the case though, but there would have to be a safety check/warning in case it is missing.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pointing this out! It is better to reuse the get_restart_datetime than writing a new one.
I have changed the code to read out the starting date from work/atmosphere/um.res.yaml.


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:
try:
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())
print(f"Found secs_per_period: {secs_per_period}")
if 'Atm_Step: Timestep' in line:
timestep = int(line.split()[-1])
except ValueError:
continue

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

warnings.warn(
f"""Could not find all required entries in file {log_path}
to calculate run time"""
)
return None


def get_cur_expt_time(self):
"""Get the current experiment time from file"""
nl_path = os.path.join(self.expt.work_path, 'atmosphere', 'namelists')
log_path = os.path.join(self.expt.work_path, 'atmosphere', 'atm.fort6.pe0')

if not os.path.exists(nl_path) or not os.path.exists(log_path):
warnings.warn(f"Could not find required files: {nl_path} or {log_path}")
return None

start_date = self.read_start_date(nl_path)
runtime_sec = self.convert_timestep(log_path)
if start_date is None or runtime_sec is None:
return None

# Both ESM1.6 and datetime package use Gregorian calendar,
# so we can use datetime here for date calculations.
cur_expt_time = start_date + timedelta(seconds=runtime_sec)
return cur_expt_time.isoformat()




24 changes: 24 additions & 0 deletions payu/models/accessom2.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@

import os
import shutil
import json
import warnings

from payu.models.model import Model

Expand Down Expand Up @@ -94,3 +96,25 @@ 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."""
try:
log_path = os.path.join(self.expt.work_path, 'atmosphere', 'log',
'matmxx.pe00000.log')

# Read out the latest `cur_exp-datetime` from the log file
if os.path.exists(log_path):
with open(log_path, 'r') as f:
for line in reversed(f.readlines()):
if 'cur_exp-datetime' in line:
cur_expt_time = json.loads(line)['cur_exp-datetime']
return cur_expt_time

warnings.warn(f"Log file {log_path} does not exist or does not contain current model time.")
return None

except KeyError as e:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What raises the KeyError here? If 'cur_exp-datetime' is in a string but not a top-level key in the json?

Could also guard against errors with json.loads(line) where line isn't valid json.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was thinking if line cannot be parsed as json or 'cur_exp-datetime' is not the top key.
I changed it to:

 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')

and let the error bubble up and then the top level will catch the error.

warnings.warn('Error getting current experiment time: {}'.format(e))
return None

21 changes: 21 additions & 0 deletions payu/models/cesm_cmeps.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,27 @@ 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."""
try:
log_path = os.path.join(self.expt.work_path, 'log', 'med.log')

# Read out the latest `cur_exp-datetime` from the log file
if os.path.exists(log_path):
with open(log_path, 'r') as f:
for line in reversed(f.readlines()):
if line.startswith(" memory_write: model date"):
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like its currently hardcoded to write this every model day:

https://github.com/access-nri/cmeps/blob/988c698ed6973c9d3c3cd7a14f497468ba94519f/mediator/med_phases_profile_mod.F90#L96-L98

as long as

med_phases_profile

is run.

So this seems fairly robust that "memory_write: model date" would always be available

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for checking this in the model code 👍

cur_expt_time = line.split()[4]
return cur_expt_time

warn(f"Log file {log_path} does not exist or does not contain current model time.")
return None

except KeyError as e:
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be an IndexError for line.split()[4]

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You are right! It is good to correctly specify the error.

warn('Error getting current experiment time: {}'.format(e))
return None



class Runconfig:
Expand Down
28 changes: 28 additions & 0 deletions payu/models/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -449,3 +449,31 @@ 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("Getting current experiment time is not yet implemented.")
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could leave this print statement out or change it to a warning?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After further consideration, I agree that a persistent warning is too intrusive, especially since payu status remains functional to display all other info, even if the experiment time isn't available.

In the latest commit, I have moved these messages to logger.debug(). This suppresses the 'noise' for standard users while ensuring the status output remains clean (see below). If a developer needs to troubleshoot why a specific model's time isn't fetched, they can simply run with the debug logging level enabled.

========================================
Run: 0
  Job ID:            100000.gadi-pbs
  Run ID:            aaaaa0000000
  Stage:             model-run
  Job File:          /scratch/tm70/...
========================================

What do you think?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that could work. We don't have command-line flags currently that enables debug logging easily however. I think there was some talk about that in this issue: #673.
As this PR already has a number of changes, an option could be to use warnings now and then change it in a future PR?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. I change this back to a print statement. When I use warnings.warn, it includes the file path and line numbers in the output. I want to avoid these technical "noise" in payu status display, so I left it as a print statement.

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 string (e.g., 1900-01-01T01:00:00)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the latest changes, the docstring can be updated to specify that it returns a cftime.datetime rather than string

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch! Thanks :)

"""
for model_type in model_types:
for model in self.expt.models:
if model.model_type == model_type and hasattr(model, 'get_cur_expt_time'):
cur_expt_time = model.get_cur_expt_time()
if cur_expt_time is not None:
return cur_expt_time

return None
42 changes: 42 additions & 0 deletions payu/models/mom6.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@

# Standard library
import os
from datetime import datetime, timedelta

# Extensions
import f90nml
Expand Down Expand Up @@ -130,3 +131,44 @@ def archive(self):
mom6_save_docs_files(self)

super().archive()

def read_start_date(self, input_path):
"""Read the start date from input.nml."""
try:
input_nml = f90nml.read(input_path)
start_date = input_nml.get('ocean_solo_nml', {}).get('date_init', None)
if start_date is None:
warn(f"date_init not found in {input_path}")
return None
return datetime(*start_date)
except Exception as e:
warn(f"Could not read file {input_path}: {e}")
return None

def read_timestep(self, stats_path):
""" Read the current timestep from ocean.stats."""
try:
with open(stats_path, 'r') as f:
for line in reversed(f.readlines()):
timestep = float(line.split(',')[1])
return timestep
except Exception as e:
warn(f"Could not read timestep from {stats_path}: {e}")
return None

def get_cur_expt_time(self):
"""Get the current experiment time from log file."""
input_path = os.path.join(self.expt.work_path, 'input.nml')
stats_path = os.path.join(self.expt.work_path, 'ocean.stats')
if not os.path.exists(input_path) or not os.path.exists(stats_path):
warn(f"Could not find required files: {input_path} or {stats_path}")
return None

start_date = self.read_start_date(input_path)
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.isoformat()
11 changes: 8 additions & 3 deletions payu/status.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,7 @@ def print_line(label: str, key: Any, data: dict[str, Any]) -> None:
print(f" {f'{label}:':<{label_width}} {value}")


def display_job_info(data: dict[str, Any]) -> None:
def display_job_info(data: dict[str, Any], cur_expt_time) -> None:
"""
Display the job information in a human-readable way
"""
Expand All @@ -299,10 +299,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" and cur_expt_time is not None:
print(f" {'Current Expt Time:':<18} {cur_expt_time}")
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"
Expand Down
20 changes: 9 additions & 11 deletions payu/subcommands/status_cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
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,
Expand All @@ -31,21 +32,19 @@ 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)
expt.init_models()
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
expt.init_models()

No need to run this as init_models() is already run as part of Experiment initialisation.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I see. Thanks for pointing this out :)

cur_expt_time = expt.get_model_cur_expt_time()
Copy link
Copy Markdown
Contributor

@anton-seaice anton-seaice Mar 18, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if we skip get_model_cur_expt_time() this step if stage is payu setup:

e.g.

$ payu status
/g/data/tm70/as2285/payu-dev/payu-dev/lib64/python3.11/site-packages/payu/models/cesm_cmeps.py:439: UserWarning: Log file /scratch/tm70/as2285/access-om3/work/1112-update-25km-topo/log/med.log does not exist or does not contain current model time.
========================================
Run: 0
  Job ID:            163373011.gadi-pbs
  Stage:             setup
  Job File:          /scratch/tm70/as2285/access-om3/archive/1112-update-25km-topo/payu_jobs/0/run/163373011.gadi-pbs.json

shows a warning when it's the expected behaviour

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

or job is in the queue still

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah this could be deferred to a later point when we know the the current stage is "model-run"

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for picking this up!
Following the suggestion from @jo-basevi , I have changed payu to run get_model_cur_expt_time() only during model-run stage in the latest commit.

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

Expand All @@ -57,8 +56,7 @@ def runcmd(lab_path, config_path, json_output,
)
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)
Expand All @@ -73,6 +71,6 @@ def runcmd(lab_path, config_path, json_output,
if json_output:
print(json.dumps(data, indent=4))
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should the current experiment time also be written to the json output, or would there not really be any uses for it?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree it will be good to have the current expt time here as well. I have added it in the latest commit.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If adding current experiment time to the json and the formatted display then it might make sense to add it in build_job_info() to reduce duplication and use status == model-run

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While testing this out I just noticed that payu status --json also doesn't print out the model finish time after the run has completed:

pay========================================
Run: 0
  Job ID:            163732205.gadi-pbs
  Run ID:            cd891450d95f0b8454f880b80bbb9fed40c034b4
  Stage:             archive
  Total queue time:  0h 1m 27s
  Model Finish Time: 0001-01-06T00:00:00
  Exit Status:       0 (Success)
  Model Exit Code:   0 (Success)
  Output Log:        /home/565/sw6175/esm1.6/misc/gettime-test/pre-industrial.o163732205
  Error Log:         /home/565/sw6175/esm1.6/misc/gettime-test/pre-industrial.e163732205
  Job File:          /scratch/tm70/sw6175/access-esm/archive/gettime-test-dev-preindustrial+concentrations-d33399d8/payu_jobs/0/run/163732205.gadi-pbs.json
========================================
u(payu-env) [sw6175@gadi-login-01 gettime-test]$ payu status --json
{
    "experiment_uuid": "d33399d8-053d-4eb6-aa5b-2b7e0ff783e5",
    "runs": {
        "0": {
            "run": [
                {
                    "job_id": "163732205.gadi-pbs",
                    "run_id": "cd891450d95f0b8454f880b80bbb9fed40c034b4",
                    "stage": "archive",
                    "exit_status": 0,
                    "model_exit_status": 0,
                    "stdout_file": "/home/565/sw6175/esm1.6/misc/gettime-test/pre-industrial.o163732205",
                    "stderr_file": "/home/565/sw6175/esm1.6/misc/gettime-test/pre-industrial.e163732205",
                    "job_file": "/scratch/tm70/sw6175/access-esm/archive/gettime-test-dev-preindustrial+concentrations-d33399d8/payu_jobs/0/run/163732205.gadi-pbs.json",
                    "start_time": "2026-03-23T16:23:21.697106"
                }
            ]
        }
    }
}

I'm wondering whether it would also work to add the finish time in build_job_info(), though I don't think I properly understand yet how the finish time is set.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

though I don't think I properly understand yet how the finish time is set.

model_finish_time is parsed from restart files by get_model_restart_datetimes() in experiment.py when stage is changed to archive.

whether it would also work to add the finish time in build_job_info()

Sounds good! I have implemented this in the latest commit. Now payu status --json should return something like:

{
    "experiment_uuid": "eaxxxx7",
    "runs": {
        "5": {
            "run": [
                {
                    ::
                    "stage": "archive",
                    ::
                    "start_time": "2026-03-27Txxx",
                    "model_finish_time": "0012-02-11T00:00:00"
                }
            ]
        }
    }
}

I also confirm that model_finish_time is not written twice in job file.

else:
display_job_info(data)
display_job_info(data, cur_expt_time=cur_expt_time)

runscript = runcmd
5 changes: 4 additions & 1 deletion payu/telemetry.py
Original file line number Diff line number Diff line change
Expand Up @@ -495,7 +495,8 @@ def update_run_job_file(
extra_info: Optional[dict[str, Any]] = None,
manifests: Optional[dict[str, Any]] = None,
model_restart_datetimes: Optional[dict[str, Any]] = None,
timings: Optional[dict[str, Any]] = None
timings: Optional[dict[str, Any]] = None,
cur_expt_time: Optional[str] = None
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
timings: Optional[dict[str, Any]] = None,
cur_expt_time: Optional[str] = None
timings: Optional[dict[str, Any]] = None

This code can safely be removed now I think

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doneeee.

) -> None:
"""Update the payu-run job file with the current stage and any extra info
if defined
Expand Down Expand Up @@ -530,6 +531,8 @@ def update_run_job_file(
run_info.update(extra_info)
if timings:
run_info.update(get_timings_isoformat(timings))
if cur_expt_time:
run_info.update({"cur_expt_time": cur_expt_time})

update_job_file(file_path=file_path, data=run_info)

Expand Down
Loading
Loading