2121)
2222from uxarray .core .vector_calculus import _calculate_divergence
2323from uxarray .core .utils import _map_dims_to_ugrid
24- from uxarray .core .zonal import _compute_non_conservative_zonal_mean
24+ from uxarray .core .zonal import (
25+ _compute_conservative_zonal_mean_bands ,
26+ _compute_non_conservative_zonal_mean ,
27+ )
2528from uxarray .cross_sections import UxDataArrayCrossSectionAccessor
2629from uxarray .formatting_html import array_repr
2730from uxarray .grid import Grid
@@ -511,16 +514,20 @@ def integrate(
511514
512515 return uxda
513516
514- def zonal_mean (self , lat = (- 90 , 90 , 10 ), ** kwargs ):
515- """Compute averages along lines of constant latitude.
517+ def zonal_mean (self , lat = (- 90 , 90 , 10 ), conservative : bool = False , ** kwargs ):
518+ """Compute non-conservative or conservative averages along lines of constant latitude or latitude bands .
516519
517520 Parameters
518521 ----------
519522 lat : tuple, float, or array-like, default=(-90, 90, 10)
520- Latitude values in degrees. Can be specified as:
521- - tuple (start, end, step): Computes means at intervals of `step` in range [start, end]
522- - float: Computes mean for a single latitude
523- - array-like: Computes means for each specified latitude
523+ Latitude specification:
524+ - tuple (start, end, step): For non-conservative, computes means at intervals of `step`.
525+ For conservative, creates band edges via np.arange(start, end+step, step).
526+ - float: Single latitude for non-conservative averaging
527+ - array-like: For non-conservative, latitudes to sample. For conservative, band edges.
528+ conservative : bool, default=False
529+ If True, performs conservative (area-weighted) zonal averaging over latitude bands.
530+ If False, performs traditional (non-conservative) averaging at latitude lines.
524531
525532 Returns
526533 -------
@@ -530,62 +537,125 @@ def zonal_mean(self, lat=(-90, 90, 10), **kwargs):
530537
531538 Examples
532539 --------
533- # All latitudes from -90° to 90° at 10° intervals
540+ # Non-conservative averaging from -90° to 90° at 10° intervals by default
534541 >>> uxds["var"].zonal_mean()
535542
536- # Single latitude at 30°
543+ # Single latitude (non-conservative) over 30° latitude
537544 >>> uxds["var"].zonal_mean(lat=30.0)
538545
539- # Range from -60° to 60° at 10° intervals
540- >>> uxds["var"].zonal_mean(lat=(-60, 60, 10))
546+ # Conservative averaging over latitude bands
547+ >>> uxds["var"].zonal_mean(lat=(-60, 60, 10), conservative=True)
548+
549+ # Conservative with explicit band edges
550+ >>> uxds["var"].zonal_mean(lat=[-90, -30, 0, 30, 90], conservative=True)
541551
542552 Notes
543553 -----
544- Only supported for face-centered data variables. Candidate faces are determined
545- using spherical bounding boxes - faces whose bounds contain the target latitude
546- are included in calculations.
554+ Only supported for face-centered data variables.
555+
556+ Conservative averaging preserves integral quantities and is recommended for
557+ physical analysis. Non-conservative averaging samples at latitude lines.
547558 """
548559 if not self ._face_centered ():
549560 raise ValueError (
550561 "Zonal mean computations are currently only supported for face-centered data variables."
551562 )
552563
553- if isinstance (lat , tuple ):
554- # zonal mean over a range of latitudes
555- latitudes = np .arange (lat [0 ], lat [1 ] + lat [2 ], lat [2 ])
556- latitudes = np .clip (latitudes , - 90 , 90 )
557- elif isinstance (lat , (float , int )):
558- # zonal mean over a single latitude
559- latitudes = [lat ]
560- elif isinstance (lat , (list , np .ndarray )):
561- # zonal mean over an array of arbitrary latitudes
562- latitudes = np .asarray (lat )
563- else :
564- raise ValueError (
565- "Invalid value for 'lat' provided. Must either be a single scalar value, tuple (min_lat, max_lat, step), or array-like."
564+ face_axis = self .dims .index ("n_face" )
565+
566+ if not conservative :
567+ # Non-conservative (traditional) zonal averaging
568+ if isinstance (lat , tuple ):
569+ start , end , step = lat
570+ if step <= 0 :
571+ raise ValueError ("Step size must be positive." )
572+ if step < 0.1 :
573+ warnings .warn (
574+ f"Very small step size ({ step } °) may lead to performance issues..." ,
575+ UserWarning ,
576+ stacklevel = 2 ,
577+ )
578+ num_points = int (round ((end - start ) / step )) + 1
579+ latitudes = np .linspace (start , end , num_points )
580+ latitudes = np .clip (latitudes , - 90 , 90 )
581+ elif isinstance (lat , (float , int )):
582+ latitudes = [lat ]
583+ elif isinstance (lat , (list , np .ndarray )):
584+ latitudes = np .asarray (lat )
585+ else :
586+ raise ValueError (
587+ "Invalid value for 'lat' provided. Must be a scalar, tuple (min_lat, max_lat, step), or array-like."
588+ )
589+
590+ res = _compute_non_conservative_zonal_mean (
591+ uxda = self , latitudes = latitudes , ** kwargs
566592 )
567593
568- res = _compute_non_conservative_zonal_mean (
569- uxda = self , latitudes = latitudes , ** kwargs
570- )
594+ dims = list (self .dims )
595+ dims [face_axis ] = "latitudes"
596+
597+ return xr .DataArray (
598+ res ,
599+ dims = dims ,
600+ coords = {"latitudes" : latitudes },
601+ name = self .name + "_zonal_mean"
602+ if self .name is not None
603+ else "zonal_mean" ,
604+ attrs = {"zonal_mean" : True , "conservative" : False },
605+ )
571606
572- face_axis = self .dims .index ("n_face" )
573- dims = list (self .dims )
574- dims [face_axis ] = "latitudes"
607+ else :
608+ # Conservative zonal averaging
609+ if isinstance (lat , tuple ):
610+ start , end , step = lat
611+ if step <= 0 :
612+ raise ValueError (
613+ "Step size must be positive for conservative averaging."
614+ )
615+ if step < 0.1 :
616+ warnings .warn (
617+ f"Very small step size ({ step } °) may lead to performance issues..." ,
618+ UserWarning ,
619+ stacklevel = 2 ,
620+ )
621+ num_points = int (round ((end - start ) / step )) + 1
622+ edges = np .linspace (start , end , num_points )
623+ edges = np .clip (edges , - 90 , 90 )
624+ elif isinstance (lat , (list , np .ndarray )):
625+ edges = np .asarray (lat , dtype = float )
626+ else :
627+ raise ValueError (
628+ "For conservative averaging, 'lat' must be a tuple (start, end, step) or array-like band edges."
629+ )
575630
576- uxda = UxDataArray (
577- res ,
578- uxgrid = self .uxgrid ,
579- dims = dims ,
580- coords = {"latitudes" : latitudes },
581- name = self .name + "_zonal_mean" if self .name is not None else "zonal_mean" ,
582- attrs = {"zonal_mean" : True },
583- )
631+ if edges .ndim != 1 or edges .size < 2 :
632+ raise ValueError ("Band edges must be 1D with at least two values" )
584633
585- return uxda
634+ res = _compute_conservative_zonal_mean_bands (self , edges )
635+
636+ # Use band centers as coordinate values
637+ centers = 0.5 * (edges [:- 1 ] + edges [1 :])
638+
639+ dims = list (self .dims )
640+ dims [face_axis ] = "latitudes"
641+
642+ return xr .DataArray (
643+ res ,
644+ dims = dims ,
645+ coords = {"latitudes" : centers },
646+ name = self .name + "_zonal_mean"
647+ if self .name is not None
648+ else "zonal_mean" ,
649+ attrs = {
650+ "zonal_mean" : True ,
651+ "conservative" : True ,
652+ "lat_band_edges" : edges ,
653+ },
654+ )
586655
587- # Alias for 'zonal_mean', since this name is also commonly used.
588- zonal_average = zonal_mean
656+ def zonal_average (self , lat = (- 90 , 90 , 10 ), conservative : bool = False , ** kwargs ):
657+ """Alias of zonal_mean; prefer `zonal_mean` for primary API."""
658+ return self .zonal_mean (lat = lat , conservative = conservative , ** kwargs )
589659
590660 def azimuthal_mean (
591661 self ,
0 commit comments