@@ -125,15 +125,32 @@ def accumulate_fields(self, record):
125125
126126@dataclass
127127class FieldAggregationSpec :
128- """Specification for aggregating a field across lap/segment."""
129- field : str # e.g., 'car/gyro'
130- output_key : str # e.g., 'gyro_z_agg'
128+ """
129+ Specification for aggregating a field across lap/segment.
130+
131+ Two types of fields:
132+ 1. Boundary fields: computed from lap/segment boundaries (time, distance)
133+ - No 'field' attribute (field=None)
134+ - Computed in _finalize_segment_instance()
135+ 2. Record fields: extracted from individual records (gyro, accel, etc.)
136+ - Has 'field' attribute (e.g., 'car/gyro')
137+ - Aggregated across records in _aggregate_single_field()
138+ """
139+ output_key : str # e.g., 'gyro_z_agg' or 'time'
140+ field : Optional [str ] = None # e.g., 'car/gyro' (None for boundary)
131141 index : Optional [int ] = None # Vector index (None for scalars)
132142 transform : Optional [Callable ] = None # Transform function
133143 aggregation : str = 'avg' # avg, sum, min, max, median, delta
144+ reverse : bool = False # For sorting: True = descending
145+
146+ def is_boundary_field (self ) -> bool :
147+ """Check if this is a boundary field (time/distance)."""
148+ return self .field is None
134149
135150 def extract (self , record : dict ) -> Optional [float ]:
136151 """Extract and transform value from record."""
152+ if self .is_boundary_field ():
153+ return None # Boundary fields are not extracted from records
137154 try :
138155 value = record [self .field ]
139156 if self .index is not None :
@@ -193,9 +210,10 @@ def __init__(self,
193210 Construct tub statistics calculator for tub
194211
195212 :param tub: input tub
196- :param config: Config object (loads FIELD_AGGREGATIONS,
197- LAP_SORTING_CRITERIA). Required if
198- field_aggregations not provided.
213+ :param config: Config object (loads FIELD_AGGREGATIONS).
214+ FIELD_AGGREGATIONS is the single source of
215+ truth for both aggregation and ranking.
216+ Required if field_aggregations not provided.
199217 :param sorting_strategy: Optional custom sorting strategy
200218 :param field_aggregations: Optional list of FieldAggregationSpec or
201219 dicts. Required if config not provided.
@@ -233,7 +251,8 @@ def _normalize_field_aggregations(self, field_aggregations: List) -> List[
233251 FieldAggregationSpec ]:
234252 """Convert dict specs to FieldAggregationSpec.
235253
236- :raises ValueError: If old-style extractor syntax is used.
254+ :raises ValueError: If old-style extractor syntax is used or required
255+ fields are missing.
237256 """
238257 normalized = []
239258 for spec in field_aggregations :
@@ -245,12 +264,17 @@ def _normalize_field_aggregations(self, field_aggregations: List) -> List[
245264 f'Old-style field_aggregations with "extractor" '
246265 f'not supported for field { spec .get ("field" , "?" )} . '
247266 f'Use "index" parameter instead.' )
267+ if 'output_key' not in spec :
268+ raise ValueError (
269+ f'Field aggregation missing required "output_key": '
270+ f'{ spec } ' )
248271 normalized .append (FieldAggregationSpec (
249- field = spec ['field' ],
250272 output_key = spec ['output_key' ],
273+ field = spec .get ('field' ), # None for boundary fields
251274 index = spec .get ('index' ),
252275 transform = spec .get ('transform' ),
253- aggregation = spec .get ('aggregation' , 'avg' )
276+ aggregation = spec .get ('aggregation' , 'avg' ),
277+ reverse = spec .get ('reverse' , False )
254278 ))
255279 return normalized
256280
@@ -266,37 +290,60 @@ def _load_field_aggregations_from_config(self, config) -> List[
266290 raise ValueError (
267291 'FIELD_AGGREGATIONS not found in config. '
268292 'Please define FIELD_AGGREGATIONS in your config file. '
269- 'Example: FIELD_AGGREGATIONS = [{"field": "car/gyro", '
270- '"output_key": "gyro_z_agg", "index": 1, "aggregation": "avg"}]' )
271-
272- # Convert config dicts to FieldAggregationSpec
273- specs = []
274- for spec_dict in config_specs :
275- spec = FieldAggregationSpec (
276- field = spec_dict ['field' ],
277- output_key = spec_dict ['output_key' ],
278- index = spec_dict .get ('index' ),
279- transform = spec_dict .get ('transform' ),
280- aggregation = spec_dict .get ('aggregation' , 'avg' )
281- )
282- specs .append (spec )
283- logger .info (f'Loaded field aggregation: { spec .output_key } from '
284- f'{ spec .field } [{ spec .index } ] using { spec .aggregation } ' )
293+ 'Example: FIELD_AGGREGATIONS = [\n '
294+ ' {"output_key": "time"}, # Boundary field\n '
295+ ' {"output_key": "distance"},\n '
296+ ' {"field": "car/gyro", "output_key": "gyro_z_agg", '
297+ '"index": 2, "aggregation": "avg"}\n '
298+ ']' )
299+
300+ # Use normalization method for consistency
301+ specs = self ._normalize_field_aggregations (config_specs )
302+
303+ for spec in specs :
304+ if spec .is_boundary_field ():
305+ logger .info (f'Loaded boundary field: { spec .output_key } ' )
306+ else :
307+ logger .info (
308+ f'Loaded field aggregation: { spec .output_key } from '
309+ f'{ spec .field } [{ spec .index } ] using { spec .aggregation } ' )
285310
286311 return specs
287312
288313 def _load_sorting_strategy_from_config (self , config ) -> SortingStrategy :
289- """Load sorting strategy from config."""
290- criteria = getattr (config , 'LAP_SORTING_CRITERIA' , None )
291- if criteria :
292- logger .info (f'Loaded sorting criteria from config: '
293- f'{ [c ["key" ] for c in criteria ]} ' )
314+ """
315+ Load sorting strategy from config.
316+
317+ Strategy is built from FIELD_AGGREGATIONS (single source of truth).
318+ For backward compatibility, falls back to LAP_SORTING_CRITERIA if found.
319+ """
320+ # Check for deprecated LAP_SORTING_CRITERIA
321+ old_criteria = getattr (config , 'LAP_SORTING_CRITERIA' , None )
322+ if old_criteria :
323+ logger .warning (
324+ 'LAP_SORTING_CRITERIA is DEPRECATED. '
325+ 'Use FIELD_AGGREGATIONS instead as the single source of truth. '
326+ 'Add time/distance as boundary fields: '
327+ '{"output_key": "time"}, {"output_key": "distance"}' )
328+ return SortingStrategy (old_criteria )
329+
330+ # Build strategy from FIELD_AGGREGATIONS
331+ if self .field_aggregations :
332+ criteria = []
333+ for spec in self .field_aggregations :
334+ criteria .append ({
335+ 'key' : spec .output_key ,
336+ 'transform' : spec .transform or (lambda x : x ),
337+ 'reverse' : spec .reverse
338+ })
339+ logger .info (
340+ f'Built sorting strategy from FIELD_AGGREGATIONS: '
341+ f'{ [c ["key" ] for c in criteria ]} ' )
294342 return SortingStrategy (criteria )
295- else :
296- logger .info ('No LAP_SORTING_CRITERIA in config, using minimal '
297- 'defaults (time, distance). Configure LAP_SORTING_CRITERIA '
298- 'in config to include custom fields like gyro_z_agg.' )
299- return default_lap_sorting_strategy ()
343+
344+ # Should never reach here due to validation in __init__
345+ logger .error ('No field aggregations available for sorting strategy' )
346+ return default_lap_sorting_strategy ()
300347
301348 def generate_laptimes_from_records (self , overwrite = False ):
302349
@@ -803,11 +850,16 @@ def _calculate_aggregated_fields(self):
803850
804851 Generic implementation that handles any field with custom
805852 extractor and transform functions.
853+
854+ Boundary fields (time, distance) are skipped here - they're
855+ computed in _finalize_segment_instance().
806856 """
807- logger .info (f'Calculating { len (self .field_aggregations )} field '
808- f'aggregations in tub { self .tub .base_path } ' )
857+ record_fields = [spec for spec in self .field_aggregations
858+ if not spec .is_boundary_field ()]
859+ logger .info (f'Calculating { len (record_fields )} field aggregations '
860+ f'from records in tub { self .tub .base_path } ' )
809861
810- for field_spec in self . field_aggregations :
862+ for field_spec in record_fields :
811863 self ._aggregate_single_field (field_spec )
812864
813865 def _aggregate_single_field (self , spec : FieldAggregationSpec ):
0 commit comments