diff --git a/CMakeLists.txt b/CMakeLists.txt index 8d9e5fb..3e76500 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -73,6 +73,7 @@ list(APPEND _state_files state/state_mod.F90 list(APPEND _share_files share/proj_lc_mod.F90 share/datetime_mod.F90 + share/fire_clock_mod.F90 share/emis_mod.F90 share/fuel_mod.F90 share/ros_mod.F90 diff --git a/doc/Configuration.rst b/doc/Configuration.rst index 0dc1186..2b0f7f5 100644 --- a/doc/Configuration.rst +++ b/doc/Configuration.rst @@ -248,10 +248,11 @@ output fields retain their existing single-precision NetCDF representation. last/next update times, and the moisture activation and cadence settings. A resumed run restores this state without reinitialization and requires the same activation and cadence. New checkpoints cannot be written before - moisture initialization. PR #39 time-based checkpoints are accepted when their - positive next-update time establishes initialization and their saved interval - matches ``fmoist_dt``. Legacy step-based files are rejected because their - metadata cannot distinguish initialized state from the old delayed-initialization + moisture initialization. Older checkpoints without explicit initialization + status or time are accepted for time-based cadence when their positive next-update + time establishes initialization and their saved interval matches ``fmoist_dt``. + Legacy step-based files are rejected because their metadata cannot distinguish + initialized state from the old delayed-initialization defect. Prescribed-moisture legacy files remain readable. This compatibility rule does not resolve the separate humidity-convention audit. @@ -315,10 +316,11 @@ output fields retain their existing single-precision NetCDF representation. Restart files retain physical moisture. The reader reconstructs spread parameters from that state using the current damping calculation, so no - new restart field or namelist option is required. Existing PR #39 files - remain readable when their inputs satisfy the same validation. A legacy - checkpoint can contain flame diagnostics calculated with the old damping; - these are replaced at the next fire advance. Readability does not imply + new restart field or namelist option is required. Checkpoints from earlier + versions remain readable when their state and metadata satisfy the restart + compatibility checks. A legacy checkpoint can contain flame diagnostics + calculated with the old damping; these are replaced at the next fire advance. + Readability does not imply numerical equivalence across the extinction correction. Continuity must be assessed with continuous and restarted runs of the same model revision. @@ -436,16 +438,121 @@ atmospheric data. Timing and perimeter validation =============================== -``interval_atm`` must be a positive integer multiple of ``dt`` when using the -standalone forcing reader or NUOPC exchange schedule. For example, a 4 s fire -step and 60 s atmospheric interval are aligned. An 8 s fire step with a 4 s -atmospheric interval is not: the equality-based update trigger would miss the -first update and retain its expired schedule. The interval is an exchange -cadence, not necessarily the atmospheric solver timestep. Direct WRF stepping -and unforced ideal runs may leave ``interval_atm`` unset. Supplied positive -intervals are checked; file-driven and NUOPC entry points also require that the -interval is supplied. Programmatically constructed configurations are checked -before state allocation. +Output, forcing/exchange, restart writes, and the final time must align with +fire steps. Positive ``interval_output``, ``interval_atm``, and +``restart_interval`` values are whole seconds; ``-1`` disables an optional +schedule. At least one positive interval is needed to establish the fire clock. +Output and restart periods need not divide one another. For example, +``dt=0.3``, output every 12 s, forcing every 18 s, and restart writes every 9 s +are supported. An output interval of 13 s with that timestep is rejected. + +The common interval is the greatest common divisor of enabled periods. Its +ratio to ``dt`` must differ from the nearest integer by at most +``min(0.01, 5e-6 * ratio)`` fire steps. The 5 ppm allowance covers timestep +inputs rounded to six significant digits, including ``0.222222`` for 2/9 s; +the 0.01-step bound prevents large intervals from admitting ambiguous alignment. +The resulting reduced rational seconds/steps defines the scheduling clock. +All other event and final times must be exactly reachable on this clock. +No tolerance grows with run duration, and no event fires after a missed step. + +For example, consider these two accepted schedules: + +.. list-table:: Converting event periods to fire-step counts + :header-rows: 1 + :widths: 45 25 30 + + * - Quantity + - ``dt=0.3`` s + - ``dt=0.222222`` s + * - Output / atmosphere / restart periods + - 12 / 18 / 9 s + - 12 / 18 / 8 s + * - Common interval (greatest common divisor) + - 3 s + - 2 s + * - Common interval divided by input ``dt`` + - Approximately 10 steps + - Approximately 9.000009 steps + * - Accepted integer step count + - 10 + - 9 + * - Scheduling timestep (common interval / step count) + - 3/10 s + - 2/9 s + * - Output / atmosphere / restart step intervals + - 40 / 60 / 30 steps + - 54 / 81 / 36 steps + +In the ``dt=0.3`` example, 3 s is the largest whole-second interval that divides +12, 18, and 9 s. It is used to construct the clock; it does not request an +event every 3 s. In the rounded-timestep column, the discrepancy of about +0.000009 step is smaller than the allowed ``5e-6 * 9 = 0.000045`` step. +The scheduler therefore represents each step as exactly 2/9 s, while physics +continues to use the configured ``dt=0.222222`` s. + +For a rejected example, change the ``dt=0.3`` output period to 13 s while +keeping the 18 s atmospheric and 9 s restart periods. Their common interval +becomes 1 s, and ``1 / 0.3`` is about 3.333333 steps. Its distance from the +nearest integer is about 0.333333 step, far above the allowed 0.0000167 step. +The model rejects this schedule because 13 s falls between fire steps. + +Event detection uses integer step counts from the original origin. Calendar +timestamps use integer whole seconds and a subsecond remainder, so elapsed +single-precision multiplication cannot stall output or drift across calendar +boundaries. Physics retains the configured ``dt``; normalization corrects the +schedule within the documented input-rounding allowance. The shared +``grid%clock%Elapsed(itimestep)`` interface returns REAL64 scheduling seconds +from the original origin. The moisture component computes its initialization +and elapsed update time from the configured physics timestep. + +With the 0.3 s clock above, output every 12 s means output at steps 40, 80, +120, and so on. The event check compares the current step with those integers; +it does not need a floating-point elapsed time to equal 12 s exactly. At step +41, the timestamp is constructed from ``41 * 3 / 10``: 12 whole seconds plus +a 3/10 s remainder after the original start time. With the rounded 2/9 s +clock, the first 12 s output occurs at step 54 because ``54 * 2 / 9 = 12``. +If the caller reaches step 55 without processing that event, the scheduler +reports a missed event instead of writing late or skipping a forcing record. + +``interval_atm`` is the exchange cadence, independently of the atmospheric +solver timestep. File-driven and NUOPC entry points require it. Direct WRF +stepping and unforced ideal runs may leave it unset. Supplied positive intervals +are validated. NUOPC uses the exact rational fire clock for ESMF subcycling, +accumulates ``1/atm_step_interval`` of each instantaneous flux, and resets before +the first advance of the next exchange window. The file-driven component +requires consecutive records spaced by ``interval_atm`` and an exact record at +every requested update; missing records and implicit skipping are rejected. + +Standalone stops at the validated final step without overshooting or taking a +partial step. Its endpoint need not be an output, forcing, or restart event. +NUOPC/ESMX run endpoints must additionally be reachable by the host exchange +clock. Direct WRF retains responsibility for its host integration endpoint. + +New checkpoints store the reduced clock ratio and atmospheric interval. +Restart restores the original origin and absolute timestep, then reconstructs +the next events strictly after that step. A checkpoint between events therefore +preserves their original phase. Output/restart intervals may change if they +remain compatible with the saved clock; atmospheric cadence must match in +file-driven runs. Restart writes require whole-second boundaries, consistent +with the existing filenames and namelist date interface. The REAL64 fuel fields +and saved perimeter-activation state are unchanged. + +For example, start a run at 00:00:00 with ``dt=0.3``, output every 12 s, +and checkpoints every 9 s. A checkpoint at 00:00:09 stores the original +00:00:00 origin and absolute step 30. To resume, the new namelist's ``start_*`` +values select 00:00:09, but reading the checkpoint restores that original +origin and step count. The next advance is step 31. The next output is step +40 at 00:00:12, followed by step 80 at 00:00:24. Restarting does not shift +those outputs to 00:00:21 and 00:00:33. Checkpoints continue on their own +9 s schedule, next at step 60 (00:00:18). Both schedules remain measured +from 00:00:00, although their events do not always coincide. + +Idealized checkpoints without ``clock_seconds``, ``clock_steps``, and +``interval_atm`` are accepted when their timestamp agrees with the reconstructed +original clock and their other configuration metadata match. File-driven +checkpoints without ``interval_atm`` are rejected with a specific compatibility +diagnostic. This does not infer unknown forcing or humidity semantics. This +checkpoint interface does not provide NUOPC/WRF-host restart support. For a supplied perimeter, ``fire_ignition_start_time1`` must be nonnegative and align with a fire timestep. Roundoff-sized offsets are normalized to that diff --git a/driver/advance_mod.F90 b/driver/advance_mod.F90 index 352e142..7dbed2a 100644 --- a/driver/advance_mod.F90 +++ b/driver/advance_mod.F90 @@ -29,8 +29,7 @@ subroutine Advance_state (grid, config_flags) call Advance_fire_components (grid, config_flags) - grid%datetime_now = grid%datetime_start - call grid%datetime_now%Add_seconds (grid%itimestep * grid%dt) + call grid%Update_model_clock () call grid%datetime_now%Print_datetime () if (DEBUG_LOCAL) call Print_message (' Leaving Advance_state... ') diff --git a/driver/fire_behavior.F90 b/driver/fire_behavior.F90 index f519af3..65ce7e5 100644 --- a/driver/fire_behavior.F90 +++ b/driver/fire_behavior.F90 @@ -80,7 +80,7 @@ program fire_behavior call grid%Handle_restart (config_flags, initialize = .true.) if (DEBUG_LOCAL) write (OUTPUT_UNIT, *) ' Starting temporal loop...' - do while (grid%datetime_now < grid%datetime_end) + do while (grid%itimestep < grid%final_step) call Advance_state (grid, config_flags) call grid%Check_model_clock () diff --git a/io/namelist_mod.F90 b/io/namelist_mod.F90 index e50265f..a355137 100644 --- a/io/namelist_mod.F90 +++ b/io/namelist_mod.F90 @@ -9,6 +9,7 @@ module namelist_mod use ros_mod, only : ROS_WRFFIRE use fmc_mod, only : FMC_WRFFIRE use emis_mod, only : EMIS_WRFFIRE + use fire_clock_mod, only : fire_clock_t use stderrout_mod, only : Stop_simulation, Print_message use mpi_mod, only : Convert_mpi_comm_to_f08 @@ -159,7 +160,7 @@ module namelist_mod real :: true_lat_2 = 40.363 ! true latitude 2 ! Atm block - integer :: interval_atm = -1 ! Time step [s] of the atm (or frequency to read atm data if offline) + integer :: interval_atm = -1 ! Atmospheric exchange/forcing cadence [s], independent of host solver dt integer :: kde = 1 ! Number of atm vertical levels ! Devel block @@ -169,6 +170,7 @@ module namelist_mod procedure, public :: Broadcast_nml => Broadcast_nml procedure, public :: Check_nml => Check_nml procedure, public :: Check_time_intervals => Check_time_intervals + procedure, public :: Get_fire_clock procedure, public :: Initialization => Init_namelist procedure, public :: Init_devel_block => Init_devel_block procedure, public :: Init_fire_block => Init_fire_block @@ -426,41 +428,35 @@ end subroutine Check_nml subroutine Check_time_intervals (this, require_atm_interval) - use, intrinsic :: ieee_arithmetic, only : ieee_is_finite - use, intrinsic :: iso_fortran_env, only : real64 - implicit none class (namelist_t), intent (in) :: this logical, intent (in), optional :: require_atm_interval - real (real64) :: ratio, tolerance + type (fire_clock_t) :: clock logical :: check_atm - - ! The WRF-data component reads only time/atm; do not require fire settings. - if (.not. ieee_is_finite (this%dt)) call Stop_simulation ('dt must be finite and positive') - if (this%dt <= 0.0) call Stop_simulation ('dt must be finite and positive') - - ! File-driven and NUOPC schedules need interval_atm; direct WRF stepping - ! and unforced ideal runs can leave it unset. Validate a supplied interval. + ! The atmospheric component reads only time/atm, without ignition settings. check_atm = this%interval_atm > 0 if (present (require_atm_interval)) check_atm = check_atm .or. require_atm_interval - if (check_atm) then - if (this%interval_atm <= 0) call Stop_simulation ('interval_atm must be positive for atmospheric updates') - ratio = real (this%interval_atm, real64) / real (this%dt, real64) - ! Four single-precision roundoff units in the dimensionless step count. - ! Cap the tolerance at 1e-4 step so long schedules cannot admit partial steps. - tolerance = min (1.0e-4_real64, 4.0_real64 * epsilon (this%dt) * max (1.0_real64, abs (ratio))) - if (anint (ratio) < 1.0_real64 .or. abs (ratio - anint (ratio)) > tolerance) & - call Stop_simulation ('interval_atm must be an integer multiple of dt') - end if + if (check_atm .and. this%interval_atm <= 0) & + call Stop_simulation ('interval_atm must be positive for atmospheric updates') + if (this%interval_atm /= -1 .and. this%interval_atm <= 0) & + call Stop_simulation ('interval_atm must be positive or -1') + if (this%interval_output /= -1 .and. this%interval_output <= 0) & + call Stop_simulation ('interval_output must be positive or -1') + if (this%restart_interval /= -1 .and. this%restart_interval <= 0) & + call Stop_simulation ('restart_interval must be positive or -1') + clock = this%Get_fire_clock () - ! The equality-based scheduler can stall after a missed update. - ! interval_atm is the exchange interval, not necessarily the host model dt. - ! Intentional record skipping and output/restart alignment need a separate - ! time-scheduling review; bypassing this check would not implement skipping. end subroutine Check_time_intervals + function Get_fire_clock (this) result (clock) + class (namelist_t), intent (in) :: this + type (fire_clock_t) :: clock + + call clock%Init (this%dt, [this%interval_output, this%interval_atm, this%restart_interval]) + end function Get_fire_clock + subroutine Init_atm_block (this, file_name) implicit none diff --git a/io/wrfdata_mod.F90 b/io/wrfdata_mod.F90 index 36fdf73..0fb3dfb 100644 --- a/io/wrfdata_mod.F90 +++ b/io/wrfdata_mod.F90 @@ -182,6 +182,8 @@ function Get_datetime_index (this, datetime) result (return_value) call Get_netcdf_var (trim (this%file_name), 'Times', times) + if (datetime%Get_frac_ss () /= 0.0) call Stop_simulation ('Atmospheric record time must be a whole second') + do n = 1, size (times) if (times(n) == datetime%datetime) then return_value = n @@ -191,7 +193,7 @@ function Get_datetime_index (this, datetime) result (return_value) write (ERROR_UNIT, *) 'Datetime not found in WRF file:' call datetime%Print_datetime () - stop + call Stop_simulation ('Required atmospheric record is missing; record skipping is not supported') end function Get_datetime_index @@ -680,6 +682,25 @@ subroutine Update_atm_state (this, datetime_now, config_flags) end subroutine Update_atm_state + subroutine Check_forcing_schedule (file_name, interval) + use, intrinsic :: iso_fortran_env, only : INT64 + character (len = *), intent (in) :: file_name + integer, intent (in) :: interval + character (len = :), allocatable :: times(:) + type (datetime_t) :: expected + integer :: n + + if (interval <= 0) call Stop_simulation ('interval_atm must be positive for atmospheric updates') + call Get_netcdf_var (file_name, 'Times', times) + do n = 2, size (times) + expected%datetime = times(n-1) + expected%fractional_seconds = 0.0 + call expected%Add_seconds (int (interval, INT64)) + if (expected%datetime /= times(n)) & + call Stop_simulation ('Atmospheric record spacing differs from interval_atm; record skipping is not supported') + end do + end subroutine Check_forcing_schedule + function Wrfdata_t_const (file_name, config_flags) result (return_value) use, intrinsic :: iso_fortran_env, only : REAL32, INT32 @@ -702,6 +723,7 @@ function Wrfdata_t_const (file_name, config_flags) result (return_value) return_value%file_name = trim (file_name) call Is_netcdf_file_present (trim (file_name)) + call Check_forcing_schedule (trim (file_name), config_flags%interval_atm) ! Init projection call Get_netcdf_att (trim (return_value%file_name), 'global', 'CEN_LAT', att_real32) diff --git a/nuopc/fire_behavior_nuopc.F90 b/nuopc/fire_behavior_nuopc.F90 index 6e26c23..c34fcd1 100644 --- a/nuopc/fire_behavior_nuopc.F90 +++ b/nuopc/fire_behavior_nuopc.F90 @@ -158,6 +158,8 @@ subroutine Advertise(model, rc) ! interval_atm for exchange and flux accumulation. call config_flags%Check_time_intervals (require_atm_interval = .true.) call Init_fire_state (grid, config_flags) + if (mod (grid%final_step, grid%atm_step_interval) /= 0) & + call Stop_simulation ('NUOPC final time must align with the atmospheric exchange interval') ! Import/ Export Variables ----------------------------------------------------- @@ -837,7 +839,9 @@ subroutine SetClock(model, rc) rc = ESMF_SUCCESS - call ESMF_TimeIntervalSet (timeStep, s_r8 = real (config_flags%dt, kind = ESMF_KIND_R8), rc = rc) + ! ESMF and CFBM must use the same exact subcycling period, including rounded dt input. + call ESMF_TimeIntervalSet (timeStep, s = grid%clock%seconds / grid%clock%steps, & + sN = mod (grid%clock%seconds, grid%clock%steps), sD = grid%clock%steps, rc = rc) if (ESMF_LogFoundError (rcToCheck = rc, msg = ESMF_LOGERR_PASSTHRU, line = __LINE__, file = __FILE__)) & return @@ -890,8 +894,8 @@ subroutine Advance(model, rc) rc = ESMF_SUCCESS - ! ratio of fire to atmosphere time step - dtratio = config_flags%dt / config_flags%interval_atm + ! Each exchange window averages exactly atm_step_interval fire advances. + dtratio = 1.0 / real (grid%atm_step_interval) ! query for clock, importState and exportState call NUOPC_ModelGet(model, modelClock=clock, importState=importState, & @@ -994,10 +998,9 @@ subroutine Advance(model, rc) if (grid%datetime_now == grid%datetime_start) call grid%Save_state () - If_reset_fluxes: if (grid%datetime_now == grid%datetime_next_atm_update) then + If_reset_fluxes: if (grid%Atm_event_due ()) then call grid%datetime_now%Print_datetime () - call grid%datetime_next_atm_update%Add_seconds (config_flags%interval_atm) ptr_hflx_fire = 0. ptr_evap_fire = 0. diff --git a/physics/level_set_mod.F90 b/physics/level_set_mod.F90 index 6367bf0..e78ddc6 100644 --- a/physics/level_set_mod.F90 +++ b/physics/level_set_mod.F90 @@ -417,8 +417,7 @@ subroutine Check_isolated_negative_lfn (grid, threshold, min_size, max_size) #endif if (detection_sum > 0.0) then - grid%datetime_now = grid%datetime_start - call grid%datetime_now%Add_seconds (grid%itimestep * grid%dt) + call grid%Update_model_clock () call grid%Save_state () write (msg, '(a, i6, a, i6, a, i6, a)') 'Isolated negative LFN cluster (size', int (global_size_value), ') near (i=', & diff --git a/share/datetime_mod.F90 b/share/datetime_mod.F90 index e0a6e64..44aa530 100644 --- a/share/datetime_mod.F90 +++ b/share/datetime_mod.F90 @@ -1,6 +1,6 @@ module datetime_mod - use, intrinsic :: iso_fortran_env, only : OUTPUT_UNIT + use, intrinsic :: iso_fortran_env, only : OUTPUT_UNIT, INT64 implicit none @@ -16,9 +16,11 @@ module datetime_mod character (len = DATETIME_LEN) :: datetime real :: fractional_seconds contains - procedure :: Add_seconds_int, Add_seconds_real + procedure :: Add_seconds_int, Add_seconds_real, Add_seconds_int64 generic, public :: Add_seconds => Add_seconds_int generic, public :: Add_seconds => Add_seconds_real + generic, public :: Add_seconds => Add_seconds_int64 + procedure, public :: Seconds_since procedure, public :: Calc_days_in_month => Calc_days_in_month procedure, public :: Get_datetime_as_ints => Get_datetime_as_ints procedure, public :: Get_dd => Get_dd @@ -51,6 +53,62 @@ module datetime_mod contains + pure subroutine Add_seconds_int64 (this, delta_seconds) + + class (datetime_t), intent (in out) :: this + integer (INT64), intent (in) :: delta_seconds + integer :: yyyy, mm, dd, hh, mins, secs, days_in_this_month + integer (INT64) :: seconds, days + type (datetime_t) :: date + + call this%Get_datetime_as_ints (yyyy, mm, dd, hh, mins, secs) + seconds = int (hh * 3600 + mins * 60 + secs, INT64) + delta_seconds + days = seconds / 86400_INT64 + seconds = mod (seconds, 86400_INT64) + hh = int (seconds / 3600) + mins = int (mod (seconds, 3600_INT64) / 60) + secs = int (mod (seconds, 60_INT64)) + do while (days > 0) + date = datetime_t (yyyy, mm, dd, 0, 0, 0) + days_in_this_month = date%Calc_days_in_month () + if (days <= days_in_this_month - dd) then + dd = dd + int (days) + exit + end if + days = days - (days_in_this_month - dd + 1) + dd = 1 + mm = mm + 1 + if (mm > 12) then + mm = 1 + yyyy = yyyy + 1 + end if + end do + call this%Update_datetime (yyyy, mm, dd, hh, mins, secs) + end subroutine Add_seconds_int64 + + pure function Seconds_since (this, origin) result (seconds) + class (datetime_t), intent (in) :: this + type (datetime_t), intent (in) :: origin + integer (INT64) :: seconds + + ! Whole-second Gregorian dates are the public namelist and file interface. + seconds = (Day_number (this) - Day_number (origin)) * 86400_INT64 + & + (this%Get_hh () - origin%Get_hh ()) * 3600 + & + (this%Get_min () - origin%Get_min ()) * 60 + this%Get_ss () - origin%Get_ss () + end function Seconds_since + + pure function Day_number (date) result (days) + class (datetime_t), intent (in) :: date + integer (INT64) :: days, years + integer :: month + + years = int (date%Get_yyyy (), INT64) - 1 + month = date%Get_mm () + days = 365_INT64 * years + years / 4 - years / 100 + years / 400 + date%Get_dd () - 1 + if (month > 1) days = days + sum (DAYS_IN_MONTHS(1:month-1)) + if (month > 2 .and. date%Is_leap ()) days = days + 1 + end function Day_number + pure subroutine Add_seconds_int (this, delta_seconds) implicit none diff --git a/share/fire_clock_mod.F90 b/share/fire_clock_mod.F90 new file mode 100644 index 0000000..244bf8c --- /dev/null +++ b/share/fire_clock_mod.F90 @@ -0,0 +1,128 @@ +module fire_clock_mod + + use, intrinsic :: iso_fortran_env, only : int64, real64 + use, intrinsic :: ieee_arithmetic, only : ieee_is_finite + use datetime_mod, only : datetime_t + use stderrout_mod, only : Stop_simulation + + implicit none + private + public :: fire_clock_t, Event_due, Next_event_step + + ! Six-significant-digit timestep inputs may differ by up to 5 ppm. Limit + ! normalization to 0.01 step of the common interval to reject ambiguous clocks. + real(real64), parameter :: DT_REL_TOL = 5.0e-6_real64, STEP_TOL_MAX = 0.01_real64 + + type :: fire_clock_t + ! Reduced rational seconds per fire step. Physics retains the input dt; + ! these integers remove accumulated representation error from event times. + integer :: seconds = 0, steps = 0 + contains + procedure :: Init => Init_clock + procedure :: Step_at + procedure :: Datetime_at + procedure :: Elapsed + end type fire_clock_t + +contains + + pure integer function Gcd (a, b) result (value) + integer, intent(in) :: a, b + integer :: other, remainder + + value = a + other = b + do while (other /= 0) + remainder = mod (value, other) + value = other + other = remainder + end do + end function Gcd + + subroutine Init_clock (this, dt, intervals) + class(fire_clock_t), intent(out) :: this + real, intent(in) :: dt + integer, intent(in) :: intervals(:) + integer :: interval, common, divisor + real(real64) :: ratio, tolerance + + if (.not. ieee_is_finite (dt)) call Stop_simulation ('dt must be finite and positive') + if (dt <= 0.0) call Stop_simulation ('dt must be finite and positive') + common = 0 + do interval = 1, size (intervals) + if (intervals(interval) > 0) common = Gcd (common, intervals(interval)) + end do + ! Partial configurations can validate dt without enabling any event. + if (common == 0) return + ratio = real (common, real64) / real (dt, real64) + if (ratio > real (huge (this%steps), real64)) call Stop_simulation ('Fire schedule exceeds timestep counter range') + tolerance = min (STEP_TOL_MAX, DT_REL_TOL * ratio) + if (anint (ratio) < 1.0_real64 .or. abs (ratio - anint (ratio)) > tolerance) & + call Stop_simulation ('Time intervals must be integer multiples of dt within the alignment tolerance') + this%steps = nint (ratio) + divisor = Gcd (common, this%steps) + this%seconds = common / divisor + this%steps = this%steps / divisor + end subroutine Init_clock + + function Step_at (this, seconds, name) result (step) + class(fire_clock_t), intent(in) :: this + integer(int64), intent(in) :: seconds + character(len=*), intent(in) :: name + integer :: step + integer(int64) :: count + + if (this%seconds <= 0 .or. this%steps <= 0) call Stop_simulation ('Fire clock requires a positive event interval') + if (seconds < 0_int64) call Stop_simulation (trim (name)//' precedes the simulation origin') + if (seconds > huge (count) / this%steps) call Stop_simulation (trim (name)//' exceeds clock range') + count = seconds * this%steps + if (mod (count, int (this%seconds, int64)) /= 0) & + call Stop_simulation (trim (name)//' must align with a fire timestep') + count = count / this%seconds + if (count > huge (step)) call Stop_simulation (trim (name)//' exceeds timestep counter range') + step = int (count) + end function Step_at + + pure real(real64) function Elapsed (this, step) result (seconds) + class(fire_clock_t), intent(in) :: this + integer, intent(in) :: step + + seconds = real (int (step, int64) * this%seconds, real64) / this%steps + end function Elapsed + + function Datetime_at (this, origin, step) result (datetime) + class(fire_clock_t), intent(in) :: this + type(datetime_t), intent(in) :: origin + integer(int64), intent(in) :: step + type(datetime_t) :: datetime + integer(int64) :: numerator + + if (step < 0 .or. this%steps <= 0 .or. this%seconds <= 0) call Stop_simulation ('Invalid fire clock step') + if (step > huge (numerator) / this%seconds) call Stop_simulation ('Fire clock exceeds integer range') + numerator = step * this%seconds + datetime = origin + call datetime%Add_seconds (numerator / this%steps) + ! Only the subsecond remainder is converted to real, independently of run length. + datetime%fractional_seconds = real (mod (numerator, int (this%steps, int64)), real64) / this%steps + end function Datetime_at + + pure integer(int64) function Next_event_step (step, interval) result (next) + integer, intent(in) :: step, interval + + next = huge (next) + if (interval > 0) next = (int (step, int64) / interval + 1_int64) * interval + end function Next_event_step + + logical function Event_due (step, next, interval) result (due) + integer, intent(in) :: step, interval + integer(int64), intent(inout) :: next + + due = .false. + if (interval <= 0) return + ! A missed call is an error, not permission to skip atmospheric records. + if (int (step, int64) > next) call Stop_simulation ('Missed scheduled fire event') + due = int (step, int64) == next + if (due) next = next + interval + end function Event_due + +end module fire_clock_mod diff --git a/state/state_mod.F90 b/state/state_mod.F90 index 190bb00..acd7ee7 100644 --- a/state/state_mod.F90 +++ b/state/state_mod.F90 @@ -1,12 +1,13 @@ module state_mod - use, intrinsic :: iso_fortran_env, only : REAL32, REAL64 + use, intrinsic :: iso_fortran_env, only : REAL32, REAL64, INT64 #ifdef DM_PARALLEL use mpi #endif use constants_mod, only : PI use datetime_mod, only : datetime_t + use fire_clock_mod, only : fire_clock_t, Event_due, Next_event_step use fmc_mod, only : fmc_t use fuel_mod, only : fuel_t, FUEL_ANDERSON, Crosswalk_from_scottburgan_to_anderson use geogrid_mod, only : geogrid_t @@ -42,6 +43,9 @@ module state_mod integer :: itimestep = 0 integer :: num_tiles = 1 type (datetime_t) :: datetime_start, datetime_end, datetime_now, datetime_next_output, datetime_next_atm_update + type (fire_clock_t) :: clock + integer :: output_step_interval = -1, atm_step_interval = -1, final_step = 0 + integer (INT64) :: next_output_step = huge (0_INT64), next_atm_step = 0_INT64 real, dimension(:, :), allocatable :: uf ! W-E winds used in fire module real, dimension(:, :), allocatable :: vf ! S-N winds used in fire module @@ -150,6 +154,9 @@ module state_mod procedure, public :: Set_vars_to_default => Set_vars_to_default procedure, public :: Set_mpi_comm_cfbm => Set_mpi_comm_cfbm procedure, public :: Set_time_stamps => Set_time_stamps + procedure, public :: Update_model_clock + procedure, public :: Set_event_phase + procedure, public :: Atm_event_due procedure, public :: Write_restart => Write_restart end type state_fire_t @@ -315,8 +322,7 @@ subroutine Check_model_clock (this) type (datetime_t) :: datetime_check - datetime_check = this%datetime_start - call datetime_check%Add_seconds (this%itimestep * this%dt) + datetime_check = this%clock%Datetime_at (this%datetime_start, int (this%itimestep, INT64)) if (datetime_check /= this%datetime_now) call Stop_simulation ('Model clock is inconsistent with itimestep and dt') end subroutine Check_model_clock @@ -331,10 +337,11 @@ subroutine Handle_output (this, config_flags) logical, parameter :: DEBUG_LOCAL = .false. - if (this%datetime_now == this%datetime_next_output) then + if (Event_due (this%itimestep, this%next_output_step, this%output_step_interval)) then if (DEBUG_LOCAL) call Print_message ('Writing output...') + call this%Update_model_clock () call this%Save_state () - call this%datetime_next_output%Add_seconds (config_flags%interval_output) + this%datetime_next_output = this%clock%Datetime_at (this%datetime_start, this%next_output_step) end if end subroutine Handle_output @@ -352,19 +359,16 @@ subroutine Handle_wrfdata_update (this, wrf, config_flags) if (DEBUG_LOCAL) call Print_message ('Entering Handle_wrfdata_update...') - ! Check_nml requires aligned update times for this equality-based schedule. - ! Intentional skips and output/restart timing need a separate scheduling review. - If_update_atm: if (this%datetime_now == this%datetime_next_atm_update) then + If_update_atm: if (this%Atm_event_due ()) then if (DEBUG_LOCAL) call Print_message (' Updating WRF atm state...') if (DEBUG_LOCAL) call this%datetime_now%Print_datetime () + call this%Update_model_clock () call wrf%Update_atm_state (this%datetime_now, config_flags) if (DEBUG_LOCAL) call Print_message (' Interpolating WRF vars...') call this%Interpolate_vars_atm_to_fire(wrf, config_flags) - call this%datetime_next_atm_update%Add_seconds (config_flags%interval_atm) - end if If_update_atm if (DEBUG_LOCAL) call Print_message ('Leaving Handle_wrfdata_update...') @@ -1189,15 +1193,53 @@ subroutine Set_time_stamps (this, config_flags) config_flags%start_hour, config_flags%start_minute, config_flags%start_second) this%datetime_end = datetime_t (config_flags%end_year, config_flags%end_month, config_flags%end_day, & config_flags%end_hour, config_flags%end_minute, config_flags%end_second) - this%datetime_now = this%datetime_start - - this%datetime_next_output = this%datetime_start - call this%datetime_next_output%Add_seconds (config_flags%interval_output) - + call config_flags%Check_time_intervals () + this%clock = config_flags%Get_fire_clock () + if (this%clock%steps == 0) call Stop_simulation ('Fire clock requires a positive event interval') + this%output_step_interval = -1 + if (config_flags%interval_output > 0) & + this%output_step_interval = this%clock%Step_at (int (config_flags%interval_output, INT64), 'interval_output') + this%atm_step_interval = -1 + if (config_flags%interval_atm > 0) & + this%atm_step_interval = this%clock%Step_at (int (config_flags%interval_atm, INT64), 'interval_atm') + call this%Update_model_clock () + call this%Set_event_phase () + ! Initial forcing and the first coupled flux window start at the origin. + this%next_atm_step = 0_INT64 this%datetime_next_atm_update = this%datetime_start end subroutine Set_time_stamps + subroutine Update_model_clock (this) + class (state_fire_t), intent (in out) :: this + + this%datetime_now = this%clock%Datetime_at (this%datetime_start, int (this%itimestep, INT64)) + end subroutine Update_model_clock + + subroutine Set_event_phase (this) + class (state_fire_t), intent (in out) :: this + + ! After restart, datetime_start is the original run origin and itimestep + ! counts all fire steps since that origin. Resuming at 9 s (step 30 for + ! dt=0.3) with 12 s output schedules the next output at 12 s (step 40). + ! Each event retains its own interval, measured from the same origin. + this%final_step = this%clock%Step_at (this%datetime_end%Seconds_since (this%datetime_start), 'Final time') + if (this%final_step <= this%itimestep) call Stop_simulation ('Final time must follow the current fire state') + this%next_output_step = Next_event_step (this%itimestep, this%output_step_interval) + this%next_atm_step = Next_event_step (this%itimestep, this%atm_step_interval) + if (this%output_step_interval > 0) & + this%datetime_next_output = this%clock%Datetime_at (this%datetime_start, this%next_output_step) + if (this%atm_step_interval > 0) & + this%datetime_next_atm_update = this%clock%Datetime_at (this%datetime_start, this%next_atm_step) + end subroutine Set_event_phase + + logical function Atm_event_due (this) result (due) + class (state_fire_t), intent (in out) :: this + + due = Event_due (this%itimestep, this%next_atm_step, this%atm_step_interval) + if (due) this%datetime_next_atm_update = this%clock%Datetime_at (this%datetime_start, this%next_atm_step) + end function Atm_event_due + subroutine Set_vars_to_default (this, config_flags) implicit none diff --git a/state/state_restart_submod.F90 b/state/state_restart_submod.F90 index 3aef76b..9a46eb2 100644 --- a/state/state_restart_submod.F90 +++ b/state/state_restart_submod.F90 @@ -14,6 +14,7 @@ implicit none integer, parameter :: RESTART_IO_ROOT = 0 + ! Legacy moisture timestamps retain their existing REAL32 consistency check. real, parameter :: RESTART_INTERVAL_TOL = 1.0e-6 contains @@ -37,8 +38,6 @@ module subroutine Handle_restart (this, config_flags, initialize) type (namelist_t), intent (in) :: config_flags logical, intent (in), optional :: initialize - real :: restart_interval_seconds, restart_interval_error - character (len = 256) :: msg logical :: do_initialize @@ -48,16 +47,7 @@ module subroutine Handle_restart (this, config_flags, initialize) if (do_initialize .or. .not. this%is_restart_output_initialized) then this%restart_step_interval = -1 if (config_flags%restart_interval > 0) then - this%restart_step_interval = nint (real (config_flags%restart_interval) / this%dt) - restart_interval_seconds = this%restart_step_interval * this%dt - restart_interval_error = abs (restart_interval_seconds - real (config_flags%restart_interval)) - - if (this%restart_step_interval <= 0 .or. & - restart_interval_error > max (RESTART_INTERVAL_TOL, abs (real (config_flags%restart_interval)) * RESTART_INTERVAL_TOL)) then - write (msg, '(a, i0, a, f12.6)') 'restart_interval must map to an integer number of time steps: restart_interval = ', & - config_flags%restart_interval, ', dt = ', this%dt - call Stop_simulation (msg) - end if + this%restart_step_interval = this%clock%Step_at (int (config_flags%restart_interval, INT64), 'restart_interval') end if this%is_restart_output_initialized = .true. end if @@ -370,6 +360,21 @@ module subroutine Read_restart (this, config_flags) call Validate_restart_integer (file_restart, 'nx', this%nx) call Validate_restart_integer (file_restart, 'ny', this%ny) call Validate_restart_real (file_restart, 'dt', this%dt) + if (Has_restart_attribute (file_restart, 'clock_seconds') .neqv. & + Has_restart_attribute (file_restart, 'clock_steps')) & + call Stop_simulation ('Incomplete restart clock metadata') + if (Has_restart_attribute (file_restart, 'clock_seconds') .neqv. & + Has_restart_attribute (file_restart, 'interval_atm')) & + call Stop_simulation ('Incomplete restart clock metadata') + if (Has_restart_attribute (file_restart, 'clock_seconds')) then + call Validate_restart_integer (file_restart, 'clock_seconds', this%clock%seconds) + call Validate_restart_integer (file_restart, 'clock_steps', this%clock%steps) + if (config_flags%ideal_opt == 0) & + call Validate_restart_integer (file_restart, 'interval_atm', config_flags%interval_atm) + else if (config_flags%ideal_opt == 0) then + ! Old files did not record the cadence that produced the saved forcing. + call Stop_simulation ('Legacy restart lacks interval_atm metadata; forcing schedule compatibility is unknown') + end if call Validate_restart_real (file_restart, 'dx', this%dx) call Validate_restart_real (file_restart, 'dy', this%dy) call Validate_restart_real (file_restart, 'cen_lat', this%cen_lat) @@ -458,12 +463,10 @@ module subroutine Read_restart (this, config_flags) this%datetime_start = datetime_t (start_year, start_month, start_day, start_hour, start_minute, start_second) this%datetime_now = datetime_t (restart_year, restart_month, restart_day, restart_hour, restart_minute, restart_second) - datetime_check = this%datetime_start - call datetime_check%Add_seconds (this%itimestep * this%dt) + datetime_check = this%clock%Datetime_at (this%datetime_start, int (this%itimestep, INT64)) if (datetime_check /= this%datetime_now) call Stop_simulation ('Restart clock is inconsistent with itimestep and dt') - call Set_next_datetime_after_restart (this%datetime_next_output, config_flags%interval_output) - call Set_next_datetime_after_restart (this%datetime_next_atm_update, config_flags%interval_atm) + call this%Set_event_phase () call Read_restart_field (file_restart, 'lfn', this%lfn) call Read_restart_field (file_restart, 'lfn_hist', this%lfn_hist) @@ -743,22 +746,6 @@ subroutine Read_restart_fmc (file_name) end subroutine Read_restart_fmc - subroutine Set_next_datetime_after_restart (datetime_next, interval_seconds) - - implicit none - - type (datetime_t), intent (in out) :: datetime_next - integer, intent (in) :: interval_seconds - - datetime_next = this%datetime_now - if (interval_seconds > 0) then - datetime_next = this%datetime_start - do while (datetime_next <= this%datetime_now) - call datetime_next%Add_seconds (interval_seconds) - end do - end if - - end subroutine Set_next_datetime_after_restart end subroutine Read_restart @@ -855,6 +842,9 @@ module subroutine Write_restart (this, config_flags) if (config_flags%ideal_opt /= 0 .and. config_flags%ideal_opt /= 1) & call Stop_simulation ('Write_restart is implemented for standalone idealized and real runs only') + call this%Check_model_clock () + if (this%datetime_now%Get_frac_ss () /= 0.0) & + call Stop_simulation ('Restart writes require a whole-second fire clock boundary') file_restart = Build_restart_file_name (this%datetime_now%datetime) call this%datetime_start%Get_datetime_as_ints (start_year, start_month, start_day, start_hour, start_minute, start_second) @@ -886,6 +876,9 @@ module subroutine Write_restart (this, config_flags) call Add_netcdf_att (file_restart, 'global', 'nx', int (this%nx, kind = INT32)) call Add_netcdf_att (file_restart, 'global', 'ny', int (this%ny, kind = INT32)) call Add_netcdf_att (file_restart, 'global', 'dt', real (this%dt, kind = REAL32)) + call Add_netcdf_att (file_restart, 'global', 'clock_seconds', int (this%clock%seconds, INT32)) + call Add_netcdf_att (file_restart, 'global', 'clock_steps', int (this%clock%steps, INT32)) + call Add_netcdf_att (file_restart, 'global', 'interval_atm', int (config_flags%interval_atm, INT32)) call Add_netcdf_att (file_restart, 'global', 'dx', real (this%dx, kind = REAL32)) call Add_netcdf_att (file_restart, 'global', 'dy', real (this%dy, kind = REAL32)) call Add_netcdf_att (file_restart, 'global', 'cen_lat', real (this%cen_lat, kind = REAL32)) diff --git a/tests/regression/CMakeLists.txt b/tests/regression/CMakeLists.txt index b97b317..c15e253 100644 --- a/tests/regression/CMakeLists.txt +++ b/tests/regression/CMakeLists.txt @@ -79,7 +79,7 @@ function(add_cfbm_test record) list(APPEND _arguments "--launcher-post-arg=${_flag}") endforeach() endif() - if(CFBM_REFERENCE) + if(CFBM_REFERENCE AND NOT _configuration MATCHES "^schedule_") list(APPEND _arguments --reference "${CFBM_REFERENCE}") endif() add_test(NAME ${_name} COMMAND "${Python3_EXECUTABLE}" -B "${_runner}" case diff --git a/tests/regression/README.md b/tests/regression/README.md index 3bdab1d..37526d7 100644 --- a/tests/regression/README.md +++ b/tests/regression/README.md @@ -57,7 +57,9 @@ The three cases are `circle`, `fuels`, and `terrain`. Terrain has `u10m` and `u3d` configurations, with both wind representations in its generated inputs. The `small` scale integrates for 60 s on a 72 × 72 fire grid at 100 m spacing with a 4 s timestep. Terrain also runs through NUOPC and ESMX with MPI and -hybrid MPI/OpenMP. The complete PR matrix has 36 runs and 40 comparisons. See the +hybrid MPI/OpenMP. Fractional-step terrain configurations extend the complete +PR matrix to 72 runs and 85 execution comparisons. The original 36 cases retain +their approved-reference comparisons. See the [YAML configuration guide](doc/configuration.md) for exact namelist options, configuration names, and the `large` scale. GNU `Strict` builds select the shared `github-pr` baseline by default. @@ -76,8 +78,9 @@ regression acceptance. The standalone and coupled pathways map 3D profiles horizontally before sampling winds at 6.096 m. Standalone bilinear interpolation uses double precision for weights and summation. The PR suite keeps `fire_upwinding=9` -and uses the 60 s endpoint for acceptance. Intermediate-time and longer-run -experiments are separate diagnostics, documented in the +and checks the 60 s endpoint for reference cases. Fractional-step cases check +every scheduled output through 60 or 72 s. Longer-run experiments remain +separate diagnostics, documented in the [validation history](doc/validation.md), rather than additional PR-suite gates. The original tests are preserved in [tests/legacy](../legacy/README.md), including @@ -87,78 +90,67 @@ investigations, use `compile_legacy.sh` with separate build/install directories. Coverage limits and postponed investigations are recorded in [deferred work](doc/deferred.md). +See [fractional event schedules](doc/scheduling.md) for continuous and restarted +cases selected by `pr` and `restart`. + ## How test counts are reported -The counts below describe the complete four-build `pr` and `restart` selections -validated at `5de15ed` in [CI run 39970138677](https://github.com/NCAR/fire_behavior/actions/runs/39970138677). -They count different levels of work: CTest invocations, model integrations, -individual checkpoint-file checks, and comparisons between files. Selecting -fewer builds or filtering cases changes these totals. When registrations or -schedules change, update this breakdown from the suite summaries, per-build -CTest/JUnit reports, and each case's `restart.json`. - -### 450 checkpoint-validity checks - -These are checks of **450 checkpoint files** across 15 restart cases. Each case -writes checkpoints every 4 s, from 4 through 60 s: - -- 15 files from the uninterrupted run. -- 15 files produced across the segmented run. -- Total: **15 cases × 30 files = 450 checkpoint-file checks**. - -Each file is checked for required fields, finite/unmasked values, REAL64 fuel -storage, correct dates and timestep count, and the expected perimeter-activation -flag. The final checkpoint also checks for precision that would be lost if -fuel state were reduced to REAL32. Evolving-moisture cases additionally check -initialization status, update cadence and times, five-class dimensions, and -moisture bounds appropriate to these dry-forcing cases. The class-moisture -range `(0,1)` is specific to these cases, not a general bound on fuel moisture. -The checks are implemented in [check_checkpoint](restart.py). - -These checks test each file against the expected checkpoint contract. The -separately reported **320 file comparisons** compare continuous and restarted -results: 90 regular-output comparisons, 225 checkpoint comparisons, and five -accepted-legacy checkpoint comparisons. Each checkpoint comparison requires -exact physical-state agreement. The 450 validity checks run inside the 15 -restart cases; they are not 450 additional CTest invocations. See -[restart continuity](doc/restart.md) for field applicability and comparison rules. - -### 238 PR CTest invocations - -The `pr` suite executes the following registered CTests across four builds: - -| Build | Fortran unit checks | Python harness invocation | Model runs | Total | +The complete four-build selection distinguishes CTest invocations, model +integrations, checkpoint-file checks, and comparisons between files. Selecting +fewer builds or filtering cases changes these totals. Reports retain counts +and errors at each level; an expected-rejection probe is not a failed case +when its diagnostic and absence of output match the specified condition. + +### PR checks + +The `pr` suite runs the original 36 reference cases and 36 fractional-step +cases. It performs 85 execution-layout/cross-driver comparisons and retains +all 36 approved-reference comparisons. The fractional cases use exact output +inventories, forcing-record checks, and same-revision execution comparisons; +they require no new stored baseline. + +| Build | Fortran unit checks | Python harness invocation | Model runs | Total CTests | | --- | ---: | ---: | ---: | ---: | -| Serial | 49 | 1 | 4 | 54 | -| OpenMP | 49 | 1 | 8 | 58 | -| MPI | 50 | 1 | 16 | 67 | -| Hybrid | 50 | 1 | 8 | 59 | -| **Total** | **198** | **4** | **36** | **238** | - -The [Fortran unit checks](../unit/CMakeLists.txt) cover namelist validation, -timing, tiles, perimeter activation, numerical precision, wind interpolation, -and moisture initialization. MPI and hybrid add the namelist-broadcast check. - -Each `regression_python` invocation runs **45 Python tests**, but CTest counts -that invocation as one entry. Many unit checks are repeated across builds to -exercise the corresponding compiled configuration. Thus 238 is a count of -CTest invocations, not 238 distinct scientific scenarios. The 40 cross-execution -comparisons and 36 approved-reference comparisons are reported separately from -these invocation counts. - -### 67 restart CTest invocations - -The `restart` suite is separate from the 238 PR invocations. It contains 15 -restart cases, 48 moisture-initialization unit invocations (12 in each build), -and four Python harness invocations: **15 + 48 + 4 = 67**. The unit and Python -checks overlap with `pr` and run again when both suites are selected. - -Each restart case invokes the model several times. Together they run 15 -uninterrupted integrations, 50 chain segments, and 30 restart-loading probes: -**95 model invocations** inside the 15 case-level CTests. The loading probes -include five accepted legacy files and 25 expected rejections of ambiguous or -damaged checkpoints. An expected rejection passes only when its diagnostic -and absence of regular output match the test requirements. +| Serial | 88 | 1 | 7 | 96 | +| OpenMP | 88 | 1 | 14 | 103 | +| MPI | 89 | 1 | 34 | 124 | +| Hybrid | 89 | 1 | 17 | 107 | +| **Total** | **354** | **4** | **72** | **430** | + +Fortran checks cover timing, namelists, moisture initialization and validity, +extinction, perimeter activation, tiling, interpolation, and numerical precision. +The clock checks include long-duration event counts and exchange-window +accumulation/reset. + +### Restart checks + +The `restart` suite contains 30 case-level tests across five execution layouts, +48 moisture-initialization unit invocations (12 per build), and four Python +harness invocations: **82 CTests**. The unit and Python checks also run in `pr`. + +Within the 30 cases there are **200 model invocations**: 30 uninterrupted runs, +95 chain segments, 30 moisture-loading probes, five idealized legacy-clock +resumes, 35 rejected schedule/clock probes, and five runs ending between events. +These probes retain separate diagnostics and reports. + +The cases check **680 checkpoint files** against the expected field inventory, +finite state, REAL64 fuel storage, clock, perimeter state, and moisture lifecycle. +The original 15 cases check 450 files. The 15 fractional cases add 230: each +layout checks continuous and segmented sets of 8, 9, and 6 checkpoint files. +Final fuel state must contain a signal that would be lost with REAL32 storage. +Moisture bounds `(0,1)` describe these dry-forcing cases, not a general model bound. + +The **525 continuity file comparisons** check 185 output files and 340 +checkpoints. Five accepted moisture-legacy checkpoints and 40 idealized +legacy-clock checkpoints add 45 comparisons, for **570 comparisons** overall. +Checkpoint physical state is compared exactly; regular output retains its +existing field policies. Independent execution-layout comparisons total 24. +See [restart continuity](doc/restart.md) and [fractional event +schedules](doc/scheduling.md) for the schedules and schema assumptions. + +Earlier counts for the integer-step configurations are preserved in the +[validation history](doc/validation.md); they describe their recorded revisions, +not the expanded selection above. ## Python style diff --git a/tests/regression/cases.yaml b/tests/regression/cases.yaml index 650f9aa..2c5bc4f 100644 --- a/tests/regression/cases.yaml +++ b/tests/regression/cases.yaml @@ -208,6 +208,75 @@ cases: namelist: fire: wind_vinterp_opt: 1 + schedule_03: + duration_seconds: 72 + namelist: + time: + dt: 0.3 + interval_output: 12 + restart_interval: 9 + atm: + interval_atm: 18 + fire: + fire_ignition_start_time1: 0.0 + schedule_03_restart: + duration_seconds: 72 + restart_splits: [9, 27] + namelist: + time: + dt: 0.3 + interval_output: 12 + restart_interval: 9 + atm: + interval_atm: 18 + fire: + fire_ignition_start_time1: 0.0 + schedule_rounded: + duration_seconds: 72 + namelist: + time: + dt: 0.222222 + interval_output: 12 + restart_interval: 8 + atm: + interval_atm: 18 + fire: + fire_ignition_start_time1: 0.0 + schedule_rounded_restart: + duration_seconds: 72 + restart_splits: [8, 24] + namelist: + time: + dt: 0.222222 + interval_output: 12 + restart_interval: 8 + atm: + interval_atm: 18 + fire: + fire_ignition_start_time1: 0.0 + schedule_nested: + duration_seconds: 60 + namelist: + time: + dt: 0.2777777777777778 + interval_output: 15 + restart_interval: 10 + atm: + interval_atm: 20 + fire: + fire_ignition_start_time1: 0.0 + schedule_nested_restart: + duration_seconds: 60 + restart_splits: [10, 30] + namelist: + time: + dt: 0.2777777777777778 + interval_output: 15 + restart_interval: 10 + atm: + interval_atm: 20 + fire: + fire_ignition_start_time1: 0.0 restart_time: # Updates at 4, 20, 36, 52 s; splits bracket perimeter activation # and include times between moisture updates and forcing records. @@ -307,7 +376,8 @@ suites: configurations: [restart_prescribed] executions: [serial, omp4, mpi1, mpi4, hybrid4] terrain: - configurations: [restart_time, restart_step] + configurations: [restart_time, restart_step, schedule_03_restart, + schedule_rounded_restart, schedule_nested_restart] executions: [serial, omp4, mpi1, mpi4, hybrid4] quick: scale: small @@ -358,6 +428,9 @@ suites: configurations: - u10m - u3d + - schedule_03 + - schedule_rounded + - schedule_nested executions: - serial - omp1 diff --git a/tests/regression/check_outputs.py b/tests/regression/check_outputs.py index 16794a7..02db632 100644 --- a/tests/regression/check_outputs.py +++ b/tests/regression/check_outputs.py @@ -10,6 +10,7 @@ import netCDF4 import numpy as np from generate_inputs import DATE_FORMAT +from config import resolved_clock OUTPUT_METADATA = { "lats": ("degrees_north", "fire-grid cell-center latitude"), @@ -255,10 +256,54 @@ def validate_outputs(run_dir: Path, manifest: dict[str, Any]) -> dict[str, Any]: validation["reasons"].append( "Fuel-adjusted 10 m winds lost the prescribed direction or magnitude bounds" ) + if spec["identity"]["configuration"].startswith( + "schedule_") and expected <= actual: + validation["forcing_history"] = check_forcing_history(run_dir, spec) + validation["reasons"].extend(validation["forcing_history"]["reasons"]) validation["pass"] = not validation["reasons"] return validation +def check_forcing_history(run_dir: Path, spec: dict[str, + Any]) -> dict[str, Any]: + """Check each forcing record used by the completed fire interval.""" + result = {"records": [], "reasons": []} + seconds, steps = resolved_clock(spec) + cadence = spec["namelist"]["atm"]["interval_atm"] + exchange_steps = int(cadence) * steps // seconds + origin = dt.datetime.strptime(spec["start"], DATE_FORMAT) + forcing = spec["inputs"]["atmosphere"] + for name in expected_output_names(spec): + date = dt.datetime.strptime(name[len("fire_output_"):-3], DATE_FORMAT) + elapsed = int((date - origin).total_seconds()) + step = elapsed * steps // seconds + record_time = max(0, step - 1) // exchange_steps * cadence + fraction = record_time / spec["duration_seconds"] + targets = { + "fire_t2": + forcing["temperature_start_k"] + fraction * + (forcing["temperature_end_k"] - forcing["temperature_start_k"]), + "fire_q2": + forcing["mixing_ratio_start_kg_kg"] + fraction * + (forcing["mixing_ratio_end_kg_kg"] - + forcing["mixing_ratio_start_kg_kg"]), + } + with netCDF4.Dataset(run_dir / name) as dataset: + for field, target in targets.items(): + values = np.ma.asarray(dataset[field][:]) + if np.ma.count_masked(values) or not np.allclose( + values, target, rtol=FORCING_CHECK_RTOL, atol=0.0): + result["reasons"].append( + f"{name} {field} does not match the {record_time:g} s forcing record" + ) + result["records"].append({ + "output_seconds": elapsed, + "forcing_seconds": record_time + }) + result["pass"] = not result["reasons"] + return result + + def check_wind_profile(path: Path, spec: dict[str, Any]) -> dict[str, Any]: """Check the prescribed profile, including the surface logarithmic layer. diff --git a/tests/regression/config.py b/tests/regression/config.py index 8eea422..80f64ed 100644 --- a/tests/regression/config.py +++ b/tests/regression/config.py @@ -5,6 +5,7 @@ import copy import datetime as dt import math +import struct from pathlib import Path from typing import Any @@ -312,6 +313,27 @@ def registrations(document: dict[str, Any], variant: str, return list(records.values()) +def resolved_clock(spec: dict[str, Any]) -> tuple[int, int]: + """Resolve integer seconds/steps using the production clock's rounding rule.""" + time = spec["namelist"]["time"] + step = struct.unpack("f", struct.pack("f", time["dt"]))[0] + intervals = [ + time["interval_output"], spec["namelist"]["atm"]["interval_atm"], + time.get("restart_interval", -1) + ] + if any(value != int(value) for value in intervals): + raise ValueError( + "Output and forcing timestamps require integer seconds") + common = math.gcd(*(int(value) for value in intervals if value > 0)) + ratio = common / step + count = round(ratio) + if count < 1 or abs(ratio - count) > min(0.01, 5.0e-6 * ratio): + raise ValueError( + "Schedule must be an integer number of model timesteps") + divisor = math.gcd(common, count) + return common // divisor, count // divisor + + def validate_spec(spec: dict[str, Any]) -> None: """Reject unsupported scientific combinations and misaligned schedules.""" nml, inputs = spec["namelist"], spec["inputs"] @@ -320,16 +342,10 @@ def validate_spec(spec: dict[str, Any]) -> None: step = _require_number(time, "dt", 1.0e-12, "time") output = _require_number(time, "interval_output", step, "time") forcing_interval = _require_number(nml["atm"], "interval_atm", step, "atm") - for value in (duration, step, output, forcing_interval): - if abs(value / step - round(value / step)) > 1.0e-10: - raise ValueError( - "Schedule must be an integer number of model timesteps") - # Output names and forcing Times have whole-second precision; the model - # timestep may be fractional provided every scheduled time aligns with it. - if any(value != int(value) - for value in (duration, output, forcing_interval)): + seconds, steps = resolved_clock(spec) + if duration != int(duration) or int(duration) * steps % seconds: raise ValueError( - "Output and forcing timestamps require integer seconds") + "Schedule must be an integer number of model timesteps") for interval in (output, forcing_interval): if abs(duration / interval - round(duration / interval)) > 1.0e-10: raise ValueError( @@ -337,7 +353,7 @@ def validate_spec(spec: dict[str, Any]) -> None: if "restart_splits" in spec: splits = spec["restart_splits"] interval = _require_number(time, "restart_interval", step, "time") - if interval / step != round(interval / step): + if int(interval) * steps % seconds: raise ValueError( "restart_interval must align with the fire timestep") if (not isinstance(splits, list) or not splits or any( diff --git a/tests/regression/doc/references.md b/tests/regression/doc/references.md index dd7ee90..2daf3dd 100644 --- a/tests/regression/doc/references.md +++ b/tests/regression/doc/references.md @@ -48,17 +48,18 @@ history](validation.md). ## GitHub review and activation CI runs `pr` and `restart` using all four GNU Strict builds: serial, OpenMP, -MPI and hybrid, with NUOPC/ESMX in the latter two. The `pr` suite's 36 generated -runs produce 40 cross-execution comparisons. The `restart` suite compares +MPI and hybrid, with NUOPC/ESMX in the latter two. The `pr` suite's 72 generated +runs produce 85 cross-execution comparisons. The `restart` suite compares continuous and segmented standalone integrations from the same revision and does not use a stored baseline. Both suites must pass. Unit checks run in each -build. PRs targeting prerequisite branches are included, so a merge into -`develop` is not needed. +build. Normal Strict checks default to the [approved GitHub reference](../baselines/github-pr/README.md) -committed for the complete PR suite. A missing or unapproved reference fails -the check. To generate a future candidate, select **Run workflow** for -**Fire Behavior CI Test**, choose the PR branch, and enable `create_reference`. +committed for the original 36 cases. Fractional-step cases use exact timing and +forcing checks and same-revision comparisons. A missing or unapproved reference +for a case requiring one fails the check. To generate a future candidate, select +**Run workflow** for **Fire Behavior CI Test**, choose the source revision, and +enable `create_reference`. That run checks both suites without a stored reference, then creates a candidate from only the `pr` results. It uploads the unapproved baseline only if all checks and comparisons pass. Adding restart coverage does not change the @@ -74,6 +75,6 @@ across compilers and libraries. After explicit team approval, commit the candidate under the workflow's selected baseline directory, record the approval, and rerun the normal workflow on the -final PR revision. The candidate-generation run alone is not reference acceptance. +final source revision. The candidate-generation run alone is not reference acceptance. See [baseline storage](../baselines/README.md). The larger `full` suite remains available through the local CLI and PBS template, with references covering those selections when historical comparisons are required. diff --git a/tests/regression/doc/restart.md b/tests/regression/doc/restart.md index 71964e5..62478d4 100644 --- a/tests/regression/doc/restart.md +++ b/tests/regression/doc/restart.md @@ -18,7 +18,7 @@ all times are seconds from the original 2020-01-01 00:00:00 origin. | terrain/restart_time | `fmoist_freq=0`, `fmoist_dt=16` | Every 12 s | 4, 12, 28 | Observed perimeter at 8 s | | terrain/restart_step | `fmoist_freq=3`, `fmoist_dt=12` | Every 12 s | 4, 12, 28 | Observed perimeter at 8 s | -All integrations use `dt=4`, run through 60 s, write regular output every 12 s, +The three configurations above use `dt=4`, run through 60 s, write regular output every 12 s, and write checkpoints every 4 s. Time-based moisture updates occur at 4, 20, 36, and 52 s. The split at 4 s precedes perimeter activation; 12 and 28 s follow it. The 28 s split is between moisture updates @@ -53,7 +53,7 @@ it; `fz0` and atmospheric forcing are checked only for real cases. Idealized runs do not use those atmospheric fields. Regular-output tolerances do not weaken the exact checkpoint-state comparison. -The complete selection contains 15 cases across `serial`, `omp4`, `mpi1`, +The integer-step configurations contain 15 cases across `serial`, `omp4`, `mpi1`, `mpi4`, and `hybrid4`: 65 model invocations, including the 15 continuous runs, and 315 file comparisons (90 output and 225 checkpoint comparisons). Counts of attempted/completed segments and failed comparisons are recorded separately @@ -64,7 +64,7 @@ outputs and the existing comparison policy. The new configurations use same-revision comparisons and do not select the historical `github-pr` reference, which does not contain their keys. The -existing `pr` suite remains unchanged and checks its approved reference. +existing reference cases in `pr` retain their approved baseline checks. No reference candidate is created or approved by this suite. The suite additionally runs 30 restart-loading probes for moisture initialization @@ -84,9 +84,12 @@ cross-revision equivalence. Restart experiments currently require the standalone driver. NUOPC/ESMX and WRF-host restart continuity are not established here. Existing coupled PR tests exercise ordinary integrations only. Cross-layout checkpoint transfers, -legacy-file compatibility across revisions, fractional timesteps, and coupled -flux accumulation/reset are outside this suite's coverage and require separate -validation. +legacy-file equivalence across scientific corrections, and coupled restart +continuity require separate validation. Fractional timesteps, original-origin +restart phase, and clock/cadence rejection checks are included through the +three [fractional restart configurations](scheduling.md). Together the six +configurations select 30 case-level tests. See the [count breakdown](../README.md#how-test-counts-are-reported) +for CTest, model-invocation, checkpoint-check, and comparison totals. ## Extend a restart experiment diff --git a/tests/regression/doc/running.md b/tests/regression/doc/running.md index 58fcdd0..1aff37d 100644 --- a/tests/regression/doc/running.md +++ b/tests/regression/doc/running.md @@ -38,7 +38,8 @@ With direct CMake, `-DCFBM_REFERENCE=AUTO` selects this build-type default, `-DCFBM_REFERENCE=/absolute/directory` overrides it, and `-DCFBM_REFERENCE:STRING=` disables historical comparison. `compile.sh` resets this selection on every invocation, so a cached override does not carry over -when the command changes. The bundled reference covers the small `pr` cases; +when the command changes. The bundled reference covers the original 36 small `pr` cases; fractional +configurations use exact timing/forcing and same-revision checks instead; `full` needs its own reference or an intentional `--no-reference` run. ### Coupled hybrid runs @@ -53,10 +54,10 @@ per rank, use at least 16 allocated CPUs and a separate build directory: --prefix="$PWD/install/hybrid-strict" ``` -This selects eight model runs: four standalone cases and four coupled terrain -cases. The complete four-build PR matrix has 36 runs and 40 cross-execution -comparisons. The shared `github-pr` reference includes all 36 names, including -the four coupled hybrid cases. +This selects 17 model runs: seven standalone cases and ten coupled terrain +cases. The complete four-build PR matrix has 72 runs and 85 cross-execution +comparisons. The shared `github-pr` reference retains the original 36 names; +the additional fractional-step cases have no stored reference. ## Run a suite @@ -139,10 +140,10 @@ The suite options are defined in [regression.py](../regression.py): | Option | Tests selected | |---|---| | `quick` | Default. Unit tests plus the four default case/configuration combinations in serial, and both terrain wind configurations with `omp4`, `mpi4`, or `hybrid4`, where supported. | -| `pr` | Unit tests plus all four default case/configuration combinations across `serial`, `omp1`, `omp4`, `mpi1`, `mpi4`, and `hybrid4`, where supported. | +| `pr` | Unit tests, the four default case/configuration combinations, and three fractional-step terrain configurations across `serial`, `omp1`, `omp4`, `mpi1`, `mpi4`, and `hybrid4`, where supported. | | `full` | Unit tests plus the larger experiments: 320 × 320 grids, 3600 s integrations, both method pairs `(9,4)` and `(2,4)`, and the additional `mpi8` execution. Requires `CFBM_FULL_TESTS=ON`. | | `unit` | Unit Fortran tests and the Python harness tests. No generated fire-case integrations. | -| `restart` | Moisture initialization unit checks, Python harness checks, and standalone continuous/restarted cases in serial, OpenMP, MPI, and hybrid layouts. See [restart continuity](restart.md) for schedules, counts, and coverage limits. | +| `restart` | Moisture initialization unit checks, Python harness checks, and standalone continuous/restarted cases in serial, OpenMP, MPI, and hybrid layouts. Includes fractional restart phase, legacy clock metadata, and schedule rejection checks. See [restart continuity](restart.md) and [event schedules](scheduling.md). | | `legacy` | Original tests registered by the legacy build route. | These selections follow [cases.yaml](../cases.yaml). Each build contributes only @@ -218,7 +219,7 @@ namelist_broadcast_mpi Here `*_unit` describes a naming pattern, not a literal selector. Copy the complete name from the CTest listing when using `--test`. `regression_python` tests **the Python regression harness**: input generation, configuration, output -checks, comparisons, and reporting. It currently contains **45 Python tests** +checks, comparisons, and reporting. Core coverage contains **47 Python tests** across two files and does not launch the Fortran model. | Area | What it checks | @@ -234,7 +235,7 @@ across two files and does not launch the Fortran model. | Restart chains | Checks checkpoint precision, state and metadata validity, segment schedules, and preservation of forcing across segments. | The implementation is in -[python_harness_test.py](../tests/python_harness_test.py) (28 tests) and +[python_harness_test.py](../tests/python_harness_test.py) (30 core tests) and [generator_comparator_test.py](../tests/generator_comparator_test.py) (17 tests). @@ -348,7 +349,7 @@ it, then resubmit. Avoid concurrent submissions of the same commit/date. The job clones the local repository, checks out the revision present when the job starts, loads `env/derecho/gnu-12.2.0`, and builds four Strict configurations. -It runs `pr`: unit checks, 36 model runs, 40 cross-execution comparisons, and +It runs `pr`: unit checks, 72 model runs, 85 cross-execution comparisons, and 36 comparisons against the approved `github-pr` baseline. The environment uses GNU 12.2, Cray MPICH 8.1.25, NetCDF 4.9.2, ESMF 8.5.0, and shared NPL Python. The build loop calls `compile.sh` once per configuration; each call builds only diff --git a/tests/regression/doc/scheduling.md b/tests/regression/doc/scheduling.md new file mode 100644 index 0000000..dc76130 --- /dev/null +++ b/tests/regression/doc/scheduling.md @@ -0,0 +1,64 @@ +# Fractional event schedules + +Fractional scheduling coverage runs in the existing `pr` and `restart` suites, +including GitHub CI. Use `regression.py suite --suite pr` for continuous runs +and `--suite restart` for checkpoint chains and rejected schedules. Supply +`--build-dir=BUILD` for each serial, OpenMP, MPI, and hybrid build and +`--run-root=RESULTS`; run inside PBS on NCAR systems. + +The cases use the existing terrain geometry, fuel map, forcing endpoints, +propagation method, and restart runner. The geometry figures remain applicable; +the table below records the different timing. The observed perimeter is active +at time zero, and moisture updates every fire step. + +| Configuration suffix | Input dt (s) | Clock dt (s) | End (s) | Output (s) | Atmosphere (s) | Restart (s) | Chained restart splits (s) | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 03 | 0.3 | 3/10 | 72 | 12 | 18 | 9 | 9, 27 | +| rounded | 0.222222 | 2/9 | 72 | 12 | 18 | 8 | 8, 24 | +| nested | 0.277777777777778 | 5/18 | 60 | 15 | 20 | 10 | 10, 30 | + +The `pr` suite runs each `schedule_*` configuration continuously through +standalone, NUOPC, and ESMX where supported, using its normal `serial`, `omp1`, +`omp4`, `mpi1`, `mpi4`, and `hybrid4` layouts. This adds 36 model runs to the +36 existing reference cases. The `restart` suite runs the three matching +`schedule_*_restart` configurations on `serial`, `omp4`, `mpi1`, `mpi4`, and +`hybrid4`, adding 15 case-level tests. A coupled continuous run does not +establish coupled restart continuity. + +The existing `circle/restart_prescribed` configuration also checks legacy +idealized checkpoints without `clock_seconds`, `clock_steps`, and `interval_atm`. +Only checkpoint copies lose these attributes; original files remain unchanged. +This tests whether the original clock can be reconstructed from the remaining +metadata, not equivalence across a scientific correction. + +Each `schedule_03_restart` case additionally runs seven expected startup +rejections and one valid run ending between events. Across five layouts this +gives 35 rejection probes and five endpoint checks. Rejections must produce +their specific diagnostic before writing history: unaligned output/end times, +forcing-record gaps, incompatible or incomplete clock/cadence metadata, and +legacy forcing state whose cadence cannot be established. The valid 69 s +endpoint is between output, atmospheric, and checkpoint events and must be +reached exactly. `scheduling.py` supplies these checks to the existing case +runner; there is no separate scheduling suite or launcher. + +Acceptance requires the exact history-file inventory, forcing temperature and +humidity from the correct record at every output, existing physical checks, +layout/driver comparisons, and bitwise checkpoint comparisons. Restart splits +include times between output and atmospheric events. Fractional configurations +have no approved historical baseline; their exact timing/forcing assertions +and same-revision comparisons provide acceptance. The existing 36 reference +cases retain their baseline selection and tolerances. + +`fire_clock_unit` checks seven-day event counts, exact event timestamps, leap-day +rollover, subsecond resolution at long elapsed times, independent output/restart +periods, restored event phase, and the exchange reset predicate used by NUOPC. +Its weighted flux accumulator checks complete windows and rejects duplicate or +mid-window resets. Negative tests reject unaligned intervals/final time and +missed events. Python checks reject partial steps and stale intermediate forcing +records even when the final forcing record is correct. + +The new clock treats input rounding as specified in `doc/Configuration.rst`. +The physics timestep remains the configured default-real value. Moisture elapsed +time still follows the component's configured timestep. Connecting that lifecycle +to the REAL64 scheduling clock, including long-duration moisture-time precision, +requires separate validation; these short cases do not establish that behavior. diff --git a/tests/regression/doc/structure.md b/tests/regression/doc/structure.md index 10c2a7c..28e2435 100644 --- a/tests/regression/doc/structure.md +++ b/tests/regression/doc/structure.md @@ -53,10 +53,10 @@ flowchart TD COMPARE -->|records comparisons and case status in| SUMMARY ``` -The complete PR matrix has 36 model runs, 40 cross-execution comparisons, and +The complete PR matrix has 72 model runs, 85 cross-execution comparisons, and 36 approved-baseline comparisons, plus unit tests. In contrast, `compile.sh --openmp-on --esmx --test-suite=pr` builds only hybrid and then -invokes the suite for that build: four standalone, two NUOPC, and two ESMX +invokes the suite for that build: seven standalone, five NUOPC, and five ESMX runs. `--esmx` enables both coupled drivers while retaining standalone. Strict selects the shared baseline automatically. `--reference` overrides it; diff --git a/tests/regression/restart.py b/tests/regression/restart.py index 434ff48..8d426b3 100644 --- a/tests/regression/restart.py +++ b/tests/regression/restart.py @@ -22,6 +22,7 @@ import numpy as np from compare_outputs import compare_file +from config import resolved_clock from generate_inputs import DATE_FORMAT, sha256_file from render_namelist import render_namelist from reports import write_result @@ -114,8 +115,9 @@ def check_checkpoint(path: Path, spec: dict[str, Any], name = f"{prefix}_{part}" if getattr(dataset, name, None) != getattr(parsed, part): reasons.append(f"Incorrect checkpoint clock: {name}") + clock_seconds, clock_steps = resolved_clock(spec) if getattr(dataset, "itimestep", - None) != seconds / spec["namelist"]["time"]["dt"]: + None) != seconds * clock_steps // clock_seconds: reasons.append("Incorrect checkpoint itimestep") ignited = int(fire["fire_is_real_perim"] and seconds >= fire["fire_ignition_start_time1"]) diff --git a/tests/regression/run_case.py b/tests/regression/run_case.py index fbb526a..12a1a41 100644 --- a/tests/regression/run_case.py +++ b/tests/regression/run_case.py @@ -196,6 +196,20 @@ def run_case(args: Any) -> dict[str, Any]: result["reference_policy"] = "same-revision continuous/restarted" if not result["restart"]["pass"]: result["reasons"].append("Restart continuity checks failed") + if spec["identity"]["configuration"] == "restart_prescribed": + from scheduling import check_legacy_ideal + result["legacy_clock"] = check_legacy_ideal(spec, directory, + command, environment) + if not result["legacy_clock"]["pass"]: + result["reasons"].append( + "Legacy idealized clock compatibility checks failed") + if spec["identity"]["configuration"] == "schedule_03_restart": + from scheduling import check_schedule_limits + result["scheduling_limits"] = check_schedule_limits( + spec, directory, command, environment) + if not result["scheduling_limits"]["pass"]: + result["reasons"].append( + "Scheduling rejection or final-time checks failed") if args.reference and not spec.get("restart_splits"): from reference import compare_reference result["reference"] = compare_reference(args.reference, result, diff --git a/tests/regression/scheduling.py b/tests/regression/scheduling.py new file mode 100644 index 0000000..48f00d3 --- /dev/null +++ b/tests/regression/scheduling.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +# Copyright 2026 Research Applications Laboratory (RAL), +# National Center for Atmospheric Research (NCAR), +# University Corporation for Atmospheric Research (UCAR) +#-------------------------------------------------------------------------------- +# Created by Maria Frediani (frediani@ucar.edu) on 2026-10-20 +# run python -B tests/regression/regression.py suite --suite restart --help +"""Exercise invalid schedules and checkpoint metadata with the case launcher.""" +from __future__ import annotations + +#-------------------------------------------------------------------------------- +# Python modules and rejected configurations +#-------------------------------------------------------------------------------- +import copy +from pathlib import Path +import re +import shutil +from typing import Any + +import netCDF4 + +from render_namelist import render_namelist +from reports import write_result +from restart import timestamp + +REJECTIONS = { + "unaligned_output": "Time intervals must be integer multiples of dt", + "unaligned_end": "Final time must align with a fire timestep", + "forcing_gap": "Atmospheric record spacing differs from interval_atm", + "clock_mismatch": "Restart metadata mismatch: clock_steps", + "partial_clock": "Incomplete restart clock metadata", + "cadence_mismatch": "Restart metadata mismatch: interval_atm", + "legacy_forcing": "Legacy restart lacks interval_atm metadata", +} + +#-------------------------------------------------------------------------------- +# Independent model launches, retaining each rejected input and log +#-------------------------------------------------------------------------------- + + +def check_schedule_limits(spec: dict[str, + Any], directory: Path, command: list[str], + environment: dict[str, str]) -> dict[str, Any]: + """Require specific rejections, then stop normally between event boundaries.""" + from run_case import run_model + + checks = [] + for scenario, diagnostic in REJECTIONS.items(): + target = directory / scenario + target.mkdir() + settings = copy.deepcopy(spec) + for name in ("wrf.nc", "geo_em.d01.nc"): + shutil.copy2(directory / name, target / name) + if scenario == "unaligned_output": + settings["namelist"]["time"]["interval_output"] = 13 + elif scenario == "unaligned_end": + settings["end"] = timestamp(spec, 71) + elif scenario == "forcing_gap": + with netCDF4.Dataset(target / "wrf.nc", "a") as dataset: + dataset["Times"][1, :] = dataset["Times"][0, :] + else: + settings["start"] = timestamp(spec, 9) + settings["namelist"]["time"]["restart"] = True + name = f"fire_restart_{settings['start']}.nc" + shutil.copy2(directory / name, target / name) + with netCDF4.Dataset(target / name, "a") as dataset: + if scenario == "clock_mismatch": + dataset.clock_steps = dataset.clock_steps + 1 + elif scenario == "partial_clock": + dataset.delncattr("clock_steps") + elif scenario == "cadence_mismatch": + dataset.interval_atm = 27 + else: + # Only the copy loses P04 metadata, reproducing the PR #39 + # scheduling schema while preserving all physical state. + for attribute in ("clock_seconds", "clock_steps", + "interval_atm"): + dataset.delncattr(attribute) + (target / "namelist.fire").write_text(render_namelist(settings)) + result = run_model(command, target, environment, + spec["execution"]["timeout_seconds"], "standalone") + log = (target / "model.log").read_text(errors="replace") + passed = (diagnostic in log and + not list(target.glob("fire_output_*.nc"))) + checks.append({ + "scenario": scenario, + "expected_diagnostic": diagnostic, + "pass": passed, + "execution": result, + "directory": str(target) + }) + + target = directory / "end_between_events" + target.mkdir() + settings = copy.deepcopy(spec) + settings["end"] = timestamp(spec, 69) + for name in ("wrf.nc", "geo_em.d01.nc"): + shutil.copy2(directory / name, target / name) + (target / "namelist.fire").write_text(render_namelist(settings)) + result = run_model(command, target, environment, + spec["execution"]["timeout_seconds"], "standalone") + log = (target / "model.log").read_text(errors="replace") + dates = re.findall(r"(2020-01-01_\d\d:\d\d:\d\d)\s+([0-9.E+-]+)", log) + expected_files = { + f"fire_output_{timestamp(spec, second)}.nc" + for second in range(0, 70, 12) + } + passed = (not result["reasons"] and bool(dates) and + dates[-1][0] == timestamp(spec, 69) and + float(dates[-1][1]) == 0 and + {path.name for path in target.glob("fire_output_*.nc") + } == expected_files) + checks.append({ + "scenario": "end_between_events", + "pass": passed, + "execution": result, + "directory": str(target) + }) + result = {"pass": all(check["pass"] for check in checks), "checks": checks} + write_result(directory / "scheduling-limits.json", result) + return result + + +def check_legacy_ideal(spec: dict[str, + Any], directory: Path, command: list[str], + environment: dict[str, str]) -> dict[str, Any]: + """Read the known PR #39 idealized schema and retain original event phase.""" + from restart import compare_checkpoint, segment_names + from run_case import run_model + + target = directory / "legacy_clock" + target.mkdir() + begin = spec["restart_splits"][0] + settings = copy.deepcopy(spec) + settings["start"] = timestamp(spec, begin) + settings["namelist"]["time"]["restart"] = True + name = f"fire_restart_{settings['start']}.nc" + shutil.copy2(directory / name, target / name) + with netCDF4.Dataset(target / name, "a") as dataset: + for attribute in ("clock_seconds", "clock_steps", "interval_atm"): + dataset.delncattr(attribute) + (target / "namelist.fire").write_text(render_namelist(settings)) + execution = run_model(command, target, environment, + spec["execution"]["timeout_seconds"], "standalone") + comparisons = [] + end = int(spec["duration_seconds"]) + for name in segment_names(spec, begin, end, checkpoint=True): + comparisons.append( + compare_checkpoint(directory / name, target / name, spec)) + inventory = ({path.name for path in target.glob("fire_output_*.nc") + } == set(segment_names(spec, begin, end))) + result = { + "pass": + not execution["reasons"] and inventory + and all(comparison["pass"] for comparison in comparisons), + "execution": + execution, + "comparisons": + comparisons, + "output_inventory_pass": + inventory + } + write_result(target / "result.json", result) + return result diff --git a/tests/regression/tests/python_harness_test.py b/tests/regression/tests/python_harness_test.py index 3ebff60..218c2bc 100644 --- a/tests/regression/tests/python_harness_test.py +++ b/tests/regression/tests/python_harness_test.py @@ -30,8 +30,8 @@ MODULE_ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(MODULE_ROOT)) -from check_outputs import check_surface_wind, check_wind_profile, expected_output_names -from config import load_yaml, registrations, resolve_spec +from check_outputs import check_surface_wind, check_wind_profile, expected_output_names, check_forcing_history +from config import load_yaml, registrations, resolve_spec, resolved_clock from generate_inputs import generate_inputs from reference import compare_reference, verify_reference from render_namelist import render_namelist @@ -69,6 +69,42 @@ def setUp(self) -> None: # Scientific configuration #------------------------------------------------------------------------ + def test_fractional_schedule_resolution(self) -> None: + """Accept reported fractional/rounded inputs and reject a partial fire step.""" + expected = {"03": (3, 10), "rounded": (2, 9), "nested": (5, 18)} + for name, ratio in expected.items(): + spec = resolve_spec(self.document, "terrain", "small", + "schedule_" + name, "serial") + self.assertEqual(resolved_clock(spec), ratio) + config = self.document["cases"]["terrain"]["configurations"][ + "schedule_03"] + config["namelist"]["time"]["interval_output"] = 13 + with self.assertRaisesRegex(ValueError, "integer number"): + resolve_spec(self.document, "terrain", "small", "schedule_03", + "serial") + + def test_forcing_history_detects_stale_record(self) -> None: + """A repeated middle record must fail even when the final record is correct.""" + spec = resolve_spec(self.document, "terrain", "small", "schedule_03", + "serial") + for name in expected_output_names(spec): + date = dt.datetime.strptime(name[len("fire_output_"):-3], + "%Y-%m-%d_%H:%M:%S") + origin = dt.datetime.strptime(spec["start"], "%Y-%m-%d_%H:%M:%S") + second = int((date - origin).total_seconds()) + record = max(0, second - 1) // 18 * 18 + fraction = record / 72 + with netCDF4.Dataset(self.root / name, "w") as dataset: + dataset.createDimension("x", 1) + for field, value in (("fire_t2", 300 + 2 * fraction), + ("fire_q2", 0.008 - 0.004 * fraction)): + dataset.createVariable(field, "f4", ("x",))[:] = value + self.assertTrue(check_forcing_history(self.root, spec)["pass"]) + middle = self.root / expected_output_names(spec)[2] + with netCDF4.Dataset(middle, "a") as dataset: + dataset["fire_t2"][:] = 300 + self.assertFalse(check_forcing_history(self.root, spec)["pass"]) + def test_execution_cannot_change_science(self) -> None: """Reject scientific overrides hidden in a rank/thread definition.""" self.document["executions"]["mpi4"]["inputs"] = {"grid": {"nx": 8}} @@ -223,13 +259,28 @@ def test_explicit_suites_preserve_execution_coverage(self) -> None: records = [] for build in ("serial", "omp", "mpi", "hybrid"): drivers = ["standalone", "nuopc", "esmx" - ] if build == "mpi" else ["standalone"] + ] if build in ("mpi", "hybrid") else ["standalone"] records.extend(registrations(self.document, build, drivers)) - for suite, count in (("quick", 14), ("pr", 32), ("full", 80)): + for suite, count in (("quick", 18), ("pr", 72), ("full", 88)): selected = [r for r in records if suite in r["labels"]] self.assertEqual(len(selected), count) self.assertEqual({r["case"] for r in selected}, {"circle", "fuels", "terrain"}) + # CI's existing selections must include all three fractional clocks. + # Continuous coupled runs belong to pr; checkpoint chains belong to + # restart and must not imply support for coupled restart execution. + self.assertNotIn("scheduling", self.document["suites"]) + fractional = [ + r for r in records if r["configuration"].startswith("schedule_") + ] + self.assertEqual(len(fractional), 51) + for record in fractional: + if record["configuration"].endswith("_restart"): + self.assertEqual(record["labels"], ["restart"]) + self.assertEqual(record["driver"], "standalone") + else: + self.assertEqual(record["labels"], ["pr"]) + self.assertEqual(sum("restart" in r["labels"] for r in records), 30) args = SimpleNamespace(test=None, suite="pr", case="terrain", @@ -391,11 +442,13 @@ def test_selection_uses_exact_names(self) -> None: #------------------------------------------------------------------------ def test_restart_registration_and_phase(self) -> None: - """Select standalone restart cases without changing existing PR matrices.""" + """Select integer/fractional restart chains and retain original output phase.""" records = registrations(self.document, "mpi", ["standalone", "nuopc", "esmx"]) selected = [r for r in records if "restart" in r["labels"]] - self.assertEqual(len(selected), 6) + # Six restart configurations each run with one and four MPI ranks; + # NUOPC/ESMX continuous tests must not acquire restart labels. + self.assertEqual(len(selected), 12) self.assertEqual({r["driver"] for r in selected}, {"standalone"}) self.assertTrue(all("pr" not in r["labels"] for r in selected)) spec = resolve_spec(self.document, diff --git a/tests/unit/CMakeLists.txt b/tests/unit/CMakeLists.txt index c3cb835..b39ae7b 100644 --- a/tests/unit/CMakeLists.txt +++ b/tests/unit/CMakeLists.txt @@ -15,7 +15,7 @@ function(add_cfbm_unit_test) endfunction() foreach(test IN ITEMS tiles_unit tile_namelist_unit observed_perimeter_unit - namelist_validation_unit time_interval_unit + namelist_validation_unit time_interval_unit fire_clock_unit coordinate_precision_unit projection_precision_unit bilinear_precision_unit fuel_precision_unit wind_order_unit wind_state_unit fmc_initialization_unit) add_executable(${test} ${test}.F90) @@ -126,7 +126,7 @@ add_cfbm_unit_test(NAME namelist_atm_only_unit EXPECT "namelist validation accepted: atm_only") add_cfbm_unit_test(NAME namelist_atm_nonmultiple_unit COMMAND namelist_validation_unit atm_nonmultiple LABELS namelist timing - EXPECT "STOP:interval_atm must be an integer multiple of dt") + EXPECT "STOP:Time intervals must be integer multiples of dt") add_cfbm_unit_test(NAME namelist_atm_missing_unit COMMAND namelist_validation_unit atm_missing LABELS namelist timing EXPECT "STOP:interval_atm must be positive") @@ -156,7 +156,7 @@ endforeach() foreach(scenario IN ITEMS reversed nonmultiple) add_cfbm_unit_test(NAME namelist_${scenario}_unit COMMAND namelist_validation_unit ${scenario} LABELS namelist - EXPECT "STOP:interval_atm must be an integer multiple of dt") + EXPECT "STOP:Time intervals must be integer multiples of dt") endforeach() foreach(scenario IN ITEMS zero_count negative_count) add_cfbm_unit_test(NAME namelist_${scenario}_unit @@ -214,6 +214,15 @@ add_cfbm_unit_test(NAME wind_state_10m_unit COMMAND wind_state_unit u10m add_cfbm_unit_test(NAME wind_state_invalid_unit COMMAND wind_state_unit invalid LABELS wind interpolation EXPECT "STOP:Error: wrong wind_vinterp_opt") +add_cfbm_unit_test(NAME fire_clock_unit COMMAND fire_clock_unit valid + LABELS timing EXPECT "fire clock checks passed") +add_cfbm_unit_test(NAME fire_clock_unaligned_unit COMMAND fire_clock_unit unaligned + LABELS timing EXPECT "STOP:Time intervals must be integer multiples of dt") +add_cfbm_unit_test(NAME fire_clock_final_unit COMMAND fire_clock_unit final + LABELS timing EXPECT "STOP:Final time must align with a fire timestep") +add_cfbm_unit_test(NAME fire_clock_missed_unit COMMAND fire_clock_unit missed + LABELS timing EXPECT "STOP:Missed scheduled fire event") + foreach(scenario IN ITEMS each delayed time late_first_call) add_cfbm_unit_test(NAME fmc_${scenario}_unit COMMAND fmc_initialization_unit ${scenario} LABELS moisture restart EXPECT "moisture initialization checks passed") diff --git a/tests/unit/fire_clock_unit.F90 b/tests/unit/fire_clock_unit.F90 new file mode 100644 index 0000000..dce8926 --- /dev/null +++ b/tests/unit/fire_clock_unit.F90 @@ -0,0 +1,119 @@ +program fire_clock_unit + + use, intrinsic :: iso_fortran_env, only : int64, real64 + use fire_clock_mod, only : fire_clock_t, Event_due, Next_event_step + use datetime_mod, only : datetime_t + use state_mod, only : state_fire_t + use namelist_mod, only : namelist_t +#ifdef DM_PARALLEL + use mpi +#endif + implicit none + type(fire_clock_t) :: clock + type(state_fire_t) :: grid + type(namelist_t) :: config + type(datetime_t) :: origin, now, expected + integer(int64) :: next_output, next_exchange + integer :: scenario_index, step, output_interval, exchange_interval, stop_step, outputs, resets, ierr + real :: dt + real(real64) :: flux + real, parameter :: timesteps(*) = [0.3, 2.0/9.0, 5.0/18.0, 4.0/15.0, 10.0/27.0, 0.222222, 0.111111] + character(len=40) :: scenario + +#ifdef DM_PARALLEL + call MPI_Init (ierr) +#endif + call get_command_argument (1, scenario) + select case (trim (scenario)) + case ('unaligned') + call clock%Init (0.3, [4, 6]) + error stop 'unaligned clock accepted' + case ('final') + call clock%Init (0.3, [60, 30]) + step = clock%Step_at (604801_int64, 'Final time') + error stop 'unaligned final time accepted' + case ('missed') + next_output = 10 + if (Event_due (11, next_output, 10)) error stop 'missed event accepted' + case ('valid') + origin = datetime_t (2020, 2, 28, 23, 59, 0) + do scenario_index = 1, size (timesteps) + dt = timesteps(scenario_index) + call clock%Init (dt, [120, 60, 180]) + output_interval = clock%Step_at (120_int64, 'output') + exchange_interval = clock%Step_at (60_int64, 'exchange') + stop_step = clock%Step_at (604800_int64, 'Final time') + next_output = output_interval + next_exchange = 0 + outputs = 0 + resets = 0 + flux = 0.0 + do step = 0, stop_step + ! NUOPC resets before an advance, after exporting the previous mean. + if (Event_due (step, next_exchange, exchange_interval)) then + if (step > 0 .and. abs (flux - 7.0) > 3.0e-5) error stop 'incorrect exchange mean' + resets = resets + 1 + flux = 0.0 + end if + if (Event_due (step, next_output, output_interval)) then + outputs = outputs + 1 + now = clock%Datetime_at (origin, int (step, int64)) + expected = origin + call expected%Add_seconds (int (outputs, int64) * 120) + if (now /= expected) error stop 'event timestamp drifted' + end if + ! Restore schedule phase between output and exchange events. + if (step == exchange_interval + 1) then + next_output = Next_event_step (step, output_interval) + next_exchange = Next_event_step (step, exchange_interval) + end if + flux = flux + 7.0_real64 * (1.0 / real (exchange_interval)) + end do + if (outputs /= 5040 .or. resets /= 10081) error stop 'wrong event count' + now = clock%Datetime_at (origin, int (stop_step, int64)) + if (now%datetime /= '2020-03-06_23:59:00') error stop 'calendar rollover failed' + if (clock%Elapsed (stop_step) /= 604800.0_real64) error stop 'long elapsed time drifted' + if (abs (clock%Elapsed (stop_step) - clock%Elapsed (stop_step-1) - & + real (clock%seconds, real64) / clock%steps) > 1.0e-9_real64) error stop 'clock lost subsecond resolution' + end do + ! The 300 s cadence reported for dt=0.7 is not aligned; 7 s is aligned. + call clock%Init (0.7, [7, 14, 21]) + if (clock%Step_at (21_int64, 'output') /= 30) error stop '0.7 s clock failed' + config%dt = 0.3 + config%interval_output = 12 + config%interval_atm = 18 + config%restart_interval = 9 + config%start_year = 2020 + config%start_month = 1 + config%start_day = 1 + config%start_hour = 0 + config%start_minute = 0 + config%start_second = 0 + config%end_year = 2020 + config%end_month = 1 + config%end_day = 1 + config%end_hour = 0 + config%end_minute = 1 + config%end_second = 12 + call grid%Set_time_stamps (config) + call grid%Handle_restart (config, initialize = .true.) + if (grid%restart_step_interval /= 30 .or. grid%output_step_interval /= 40) & + error stop 'output and restart intervals were not independent' + if (.not. grid%Atm_event_due ()) error stop 'first exchange window did not reset' + grid%itimestep = 30 + call grid%Update_model_clock () + call grid%Set_event_phase () + if (grid%next_output_step /= 40 .or. grid%next_atm_step /= 60 .or. grid%final_step /= 240) & + error stop 'restart moved schedule origin' + if (grid%Atm_event_due ()) error stop 'partial exchange window reset on restart' + grid%itimestep = 60 + if (.not. grid%Atm_event_due ()) error stop 'resumed exchange window did not reset' + if (grid%Atm_event_due ()) error stop 'exchange reset repeated at the same step' + print *, 'fire clock checks passed' + case default + error stop 'unknown fire clock scenario' + end select +#ifdef DM_PARALLEL + call MPI_Finalize (ierr) +#endif +end program fire_clock_unit