@@ -830,6 +830,93 @@ def _numeric_delta_to_days(delta: float, time_units: Optional[str]) -> float:
830830 return delta / 86400.0
831831 return delta
832832
833+ @staticmethod
834+ def _days_to_numeric_units (days : float , time_units : Optional [str ]) -> float :
835+ """Convert day-length values back to the numeric coordinate units."""
836+ if not time_units :
837+ return days
838+
839+ interval = str (time_units ).split ("since" , 1 )[0 ].strip ().lower ()
840+ if interval in {"day" , "days" , "d" }:
841+ return days
842+ if interval in {"hour" , "hours" , "hr" , "hrs" , "h" }:
843+ return days * 24.0
844+ if interval in {"minute" , "minutes" , "min" , "mins" , "m" }:
845+ return days * 1440.0
846+ if interval in {"second" , "seconds" , "sec" , "secs" , "s" }:
847+ return days * 86400.0
848+ return days
849+
850+ def _align_subdaily_point_time_to_square_grid (self ) -> None :
851+ """Align point-sampled sub-daily timestamps to day-boundary slots.
852+
853+ Some ACCESS streams begin point-sampled sub-daily series at +3h/+6h.
854+ WCRP TIME001 expects these frequencies to be square on the canonical
855+ daily grid. Shift the whole time axis (and time bounds if present) by
856+ that leading offset.
857+ """
858+ if "time" not in self .ds .coords or self .cmor_name not in self .ds :
859+ return
860+
861+ table_id = (self .compound_name or "" ).split ("." , 1 )[0 ]
862+ table_hours = {
863+ "3hr" : 3.0 ,
864+ "3hrPt" : 3.0 ,
865+ "6hrPlevPt" : 6.0 ,
866+ }
867+ target_hours = table_hours .get (table_id )
868+ if target_hours is None :
869+ return
870+
871+ cell_methods = str (self .ds [self .cmor_name ].attrs .get ("cell_methods" , "" ))
872+ if "time: point" not in cell_methods :
873+ return
874+
875+ time_da = self .ds ["time" ]
876+ values = np .asarray (time_da .values )
877+ if values .size == 0 or not np .issubdtype (values .dtype , np .number ):
878+ return
879+
880+ time_units = time_da .attrs .get ("units" )
881+ if not isinstance (time_units , str ) or "since" not in time_units :
882+ return
883+
884+ first_value = float (values .flat [0 ])
885+ first_days = self ._numeric_delta_to_days (first_value , time_units )
886+ leading_offset_days = first_days - np .floor (first_days )
887+ if np .isclose (leading_offset_days , 0.0 , atol = 1e-10 ):
888+ return
889+
890+ target_days = target_hours / 24.0
891+ if not np .isclose (leading_offset_days , target_days , atol = 1e-8 ):
892+ return
893+
894+ shift_units = self ._days_to_numeric_units (leading_offset_days , time_units )
895+ self .ds ["time" ] = xr .DataArray (
896+ values - shift_units ,
897+ dims = time_da .dims ,
898+ coords = time_da .coords ,
899+ attrs = time_da .attrs ,
900+ )
901+
902+ bounds_name = time_da .attrs .get ("bounds" )
903+ if isinstance (bounds_name , str ) and bounds_name in self .ds :
904+ bnds = self .ds [bounds_name ]
905+ if np .issubdtype (np .asarray (bnds .values ).dtype , np .number ):
906+ self .ds [bounds_name ] = xr .DataArray (
907+ bnds .values - shift_units ,
908+ dims = bnds .dims ,
909+ coords = bnds .coords ,
910+ attrs = bnds .attrs ,
911+ )
912+
913+ logger .debug (
914+ "Shifted '%s' time axis by %.6f %s to align sub-daily point timestamps" ,
915+ self .cmor_name ,
916+ shift_units ,
917+ time_units .split ("since" , 1 )[0 ].strip (),
918+ )
919+
833920 def rechunk_dataset (self ):
834921 """
835922 Apply intelligent rechunking to the dataset.
@@ -1186,6 +1273,10 @@ def write(self):
11861273 self .ds [var ].attrs ["units" ] = normalized
11871274 logger .debug ("Normalized '%s' units: %r -> %r" , var , units , normalized )
11881275
1276+ # Align point-sampled sub-daily timestamps (e.g. 3hrPt/6hrPlevPt)
1277+ # to WCRP TIME001 square-grid expectations before writing.
1278+ self ._align_subdaily_point_time_to_square_grid ()
1279+
11891280 # ========== Prepare String Coordinates ==========
11901281 # Detect and prepare all string/character coordinates before writing
11911282 string_coords_info = self ._prepare_string_coordinates ()
0 commit comments