LCOV - code coverage report
Current view: top level - antares-api/src/temporal - aggregate.rs (source / functions) Coverage Total Hit
Test: merged.info Lines: 96.2 % 416 400
Test Date: 2026-11-02 13:21:27 Functions: 58.8 % 216 127

            Line data    Source code
       1              : // SPDX-License-Identifier: EUPL-1.2
       2              : //! Aggregated temporal representation (4.5.19).
       3              : 
       4              : use super::*;
       5              : 
       6              : /// Aggregated representation (4.5.19): attr → `{type, <method>: [[v,start,end]]}`.
       7              : /// Aggregation datatype class per 4.5.19.1 (Tables -1, -2, -3). Booleans
       8              : /// count as numbers (1/0, table NOTE); a JSON String, a DateTime and a Date
       9              : /// share the ordered min/max column; a Time additionally supports avg.
      10              : #[derive(Clone, Copy, PartialEq, Debug)]
      11              : pub(super) enum AggrClass {
      12              :     Number,
      13              :     Text,
      14              :     /// 4.6.3 DateTime or Date: ordered, and Table 4.5.19.1-2 gives it no
      15              :     /// arithmetic.
      16              :     Instant,
      17              :     TimeOfDay,
      18              :     List,
      19              :     Opaque,
      20              :     Relationship,
      21              : }
      22              : 
      23              : /// The 4.6.3 datatype a Property instance's value carries, in either
      24              : /// representation C.6 gives for one: a JSON-LD typed value
      25              : /// (`{"@type": "DateTime", "@value": …}`), or a string whose `valueType`
      26              : /// names the datatype and is coerced to its URI on the way in (4.5.2.2).
      27              : /// Table 4.5.19.1-2 applies to the datatype, not to the spelling.
      28          174 : pub(super) fn value_datatype(inst: &Value) -> Option<&str> {
      29           56 :     fn term(s: &str) -> &str {
      30           56 :         s.strip_prefix(antares_jsonld::NGSI_LD_BASE).unwrap_or(s)
      31           56 :     }
      32          174 :     if let Some(vt) = inst.get("valueType").and_then(Value::as_str) {
      33            4 :         if inst.get("value").is_some_and(Value::is_string) {
      34            4 :             return Some(term(vt));
      35            0 :         }
      36          170 :     }
      37          170 :     inst.get("value")?
      38          170 :         .get("@type")
      39          170 :         .and_then(Value::as_str)
      40          170 :         .map(term)
      41          174 : }
      42              : 
      43              : /// The lexical form of a value: the string itself, or the `@value` of a
      44              : /// JSON-LD typed value.
      45          168 : pub(super) fn lexical_of(v: &Value) -> Option<&str> {
      46          168 :     v.as_str()
      47          168 :         .or_else(|| v.get("@value").and_then(Value::as_str))
      48          168 : }
      49              : 
      50              : /// The key the ordered classes compare by. A JSON String and a Date are
      51              : /// compared as written — 4.6.3 fixes the width of every component of a Date,
      52              : /// so lexicographical order is chronological — a DateTime by its canonical
      53              : /// instant, since an optional seconds fraction is written before the `Z` it
      54              : /// follows and sorts ahead of it, and a Time by its second of the day, at a
      55              : /// fixed width so one string comparison serves all four.
      56          128 : pub(super) fn order_key(class: AggrClass, v: &Value) -> Option<String> {
      57          128 :     let s = lexical_of(v)?;
      58          128 :     Some(match class {
      59           56 :         AggrClass::Instant => antares_model::dt_key(s),
      60           16 :         AggrClass::TimeOfDay => format!("{:013.6}", seconds_of_day(s)?),
      61           56 :         _ => s.to_owned(),
      62              :     })
      63          128 : }
      64              : 
      65          186 : pub(super) fn classify_instance(inst: &Value) -> AggrClass {
      66          186 :     if inst.get("object").is_some() {
      67            8 :         return AggrClass::Relationship;
      68          178 :     }
      69          178 :     if inst.get("valueList").is_some() || inst.get("objectList").is_some() {
      70            0 :         return AggrClass::List;
      71          178 :     }
      72          178 :     if inst.get("vocab").is_some()
      73          174 :         || inst.get("languageMap").is_some()
      74          174 :         || inst.get("json").is_some()
      75              :     {
      76              :         // URI / JSON-object valued kinds: only counting methods apply
      77            4 :         return AggrClass::Opaque;
      78          174 :     }
      79          174 :     match value_datatype(inst) {
      80           56 :         Some("DateTime" | "Date") => return AggrClass::Instant,
      81           20 :         Some("Time") => return AggrClass::TimeOfDay,
      82          118 :         _ => {}
      83              :     }
      84          118 :     match inst.get("value") {
      85          102 :         Some(Value::Number(_)) | Some(Value::Bool(_)) => AggrClass::Number,
      86           16 :         Some(Value::String(_)) => AggrClass::Text,
      87            0 :         Some(Value::Array(_)) => AggrClass::List,
      88            0 :         _ => AggrClass::Opaque,
      89              :     }
      90          186 : }
      91              : 
      92              : /// Table 4.5.19.1 eligibility: which methods apply to which datatype class.
      93          276 : pub(super) fn aggr_eligible(class: AggrClass, method: &str) -> bool {
      94          276 :     match method {
      95          276 :         "totalCount" | "distinctCount" => true,
      96          208 :         "min" | "max" => !matches!(class, AggrClass::Opaque | AggrClass::Relationship),
      97          112 :         "avg" => matches!(
      98           76 :             class,
      99              :             AggrClass::Number | AggrClass::List | AggrClass::TimeOfDay
     100              :         ),
     101           36 :         "sum" => matches!(class, AggrClass::Number | AggrClass::List),
     102           16 :         "stddev" | "sumsq" => matches!(class, AggrClass::Number),
     103            0 :         _ => false,
     104              :     }
     105          276 : }
     106              : 
     107              : /// `HH:MM:SS[.f]` → seconds of day (4.6.3 Time is UTC with optional `Z`).
     108           88 : pub(super) fn seconds_of_day(s: &str) -> Option<f64> {
     109           88 :     let t = s.strip_suffix('Z').unwrap_or(s);
     110           88 :     let b = t.as_bytes();
     111           88 :     if b.len() < 8 || b.get(2) != Some(&b':') || b.get(5) != Some(&b':') {
     112           20 :         return None;
     113           68 :     }
     114           68 :     let h: f64 = t.get(0..2)?.parse().ok()?;
     115           64 :     let m: f64 = t.get(3..5)?.parse().ok()?;
     116           64 :     let sec: f64 = t.get(6..)?.parse().ok()?;
     117           64 :     (h < 24.0 && m < 60.0 && sec < 62.0).then_some(h * 3600.0 + m * 60.0 + sec)
     118           88 : }
     119              : 
     120              : /// The raw member an instance carries its data under.
     121         1616 : pub(super) fn raw_of(inst: &Value) -> Option<&Value> {
     122         1664 :     for k in [
     123         1616 :         "value",
     124         1616 :         "object",
     125         1616 :         "valueList",
     126         1616 :         "objectList",
     127         1616 :         "vocab",
     128         1616 :         "languageMap",
     129         1616 :         "json",
     130         1616 :     ] {
     131         1664 :         if let Some(v) = inst.get(k) {
     132         1616 :             return Some(v);
     133           48 :         }
     134              :     }
     135            0 :     None
     136         1616 : }
     137              : 
     138              : /// Numeric view of one raw value for the class (None ⇒ excluded from
     139              : /// numeric methods; List aggregates SIZES per Table 4.5.19.1-1).
     140         2898 : pub(super) fn numeric_of(class: AggrClass, v: &Value) -> Option<f64> {
     141         2898 :     match class {
     142         2662 :         AggrClass::Number => match v {
     143            0 :             Value::Bool(b) => Some(if *b { 1.0 } else { 0.0 }),
     144         2662 :             _ => v.as_f64(),
     145              :         },
     146            8 :         AggrClass::List => v.as_array().map(|a| a.len() as f64),
     147           40 :         AggrClass::TimeOfDay => lexical_of(v).and_then(seconds_of_day),
     148          188 :         _ => None,
     149              :     }
     150         2898 : }
     151              : 
     152          182 : pub(super) fn render_aggregated(
     153          182 :     w: &Windowed,
     154          182 :     tq: Option<&TemporalQ>,
     155          182 :     r: &TRepr,
     156          182 :     ctx: &Context,
     157          182 :     timeprop: &str,
     158          182 : ) -> Result<Map<String, Value>, NgsiError> {
     159          182 :     let mut out = Map::new();
     160          200 :     for (k, instances) in &w.attrs {
     161          186 :         let held = w.held.contains(k);
     162          186 :         if let Some(attr_out) = aggregate_attr(k, instances, held, tq, r, ctx, timeprop)? {
     163          142 :             out.insert(ctx.compact_iri(k), Value::Object(attr_out));
     164          142 :         }
     165              :     }
     166          138 :     Ok(out)
     167          182 : }
     168              : 
     169              : type Moment = chrono::DateTime<chrono::FixedOffset>;
     170              : /// One period: its start and its (exclusive) end.
     171              : type Bucket = (Moment, Moment);
     172              : /// The instances of one period, by their time.
     173              : type Rows<'a> = Vec<(Moment, &'a Value)>;
     174              : 
     175              : /// One Attribute's 4.5.19 member, `None` when no instance carries a time.
     176              : /// `held`: the Attribute records `onChange`.
     177          186 : fn aggregate_attr(
     178          186 :     k: &str,
     179          186 :     instances: &[Value],
     180          186 :     held: bool,
     181          186 :     tq: Option<&TemporalQ>,
     182          186 :     r: &TRepr,
     183          186 :     ctx: &Context,
     184          186 :     timeprop: &str,
     185          186 : ) -> Result<Option<Map<String, Value>>, NgsiError> {
     186          186 :     let (mut times, class) = timed_rows(instances, timeprop);
     187              :     // Set alongside the first instance, and `times` is non-empty then.
     188          186 :     let Some(class) = class.filter(|_| !times.is_empty()) else {
     189            0 :         return Ok(None);
     190              :     };
     191          186 :     check_methods(k, class, held, r, ctx)?;
     192          144 :     times.sort_by_key(|(t, _)| *t);
     193          144 :     let Some(whole) = whole_range(&times, tq) else {
     194            0 :         return Ok(None);
     195              :     };
     196          144 :     let Some(&(earliest, _)) = times.first() else {
     197            0 :         return Ok(None);
     198              :     };
     199          144 :     let anchor = tq
     200          144 :         .and_then(|tq| chrono::DateTime::parse_from_rfc3339(&tq.time_at).ok())
     201          144 :         .unwrap_or(earliest);
     202              :     // the row carried in from before the window holds from its start
     203          144 :     if held {
     204         1206 :         for (t, _) in &mut times {
     205         1206 :             *t = (*t).max(whole.0);
     206         1206 :         }
     207          128 :     }
     208        22786 :     let bucket_of = |t: Moment| -> Bucket {
     209        22786 :         match r.aggr_period {
     210          208 :             AggrPeriod::Whole => whole,
     211        22558 :             AggrPeriod::Seconds(sc) => seconds_bucket(anchor, sc, t),
     212           20 :             AggrPeriod::Months(m, sc) => months_bucket(anchor, m, sc, t),
     213              :         }
     214        22786 :     };
     215          144 :     let Some(buckets) = bucketed(&times, held, whole, &bucket_of) else {
     216            2 :         return Err(NgsiError::BadRequestData(format!(
     217            2 :             "aggrPeriodDuration splits the window of {} into more than {MAX_HELD_BUCKETS} \
     218            2 :              periods; ask a longer period or a shorter window",
     219            2 :             ctx.compact_iri(k)
     220            2 :         )));
     221              :     };
     222          142 :     Ok(Some(render_buckets(&buckets, class, held, whole.1, r)))
     223          186 : }
     224              : 
     225              : /// The instances that carry `timeprop`, with their raw values, and the
     226              : /// aggregation class of the first of them.
     227          186 : fn timed_rows<'a>(instances: &'a [Value], timeprop: &str) -> (Rows<'a>, Option<AggrClass>) {
     228          186 :     let mut times: Rows<'a> = Vec::new();
     229          186 :     let mut class: Option<AggrClass> = None;
     230         1616 :     for inst in instances {
     231         1616 :         let Some(t) = inst
     232         1616 :             .get(timeprop)
     233         1616 :             .and_then(Value::as_str)
     234         1616 :             .and_then(|t| chrono::DateTime::parse_from_rfc3339(t).ok())
     235              :         else {
     236            0 :             continue;
     237              :         };
     238         1616 :         if class.is_none() {
     239          186 :             class = Some(classify_instance(inst));
     240         1430 :         }
     241         1616 :         let Some(raw) = raw_of(inst) else { continue };
     242         1616 :         times.push((t, raw));
     243              :     }
     244          186 :     (times, class)
     245          186 : }
     246              : 
     247              : /// 5.7.4.4 p.211: "If an aggregated temporal representation is
     248              : /// requested and any of the requested Attributes is not eligible for
     249              : /// at least one of the aggregation methods specified in the request
     250              : /// parameters, then an error of type InvalidRequest shall be raised."
     251              : /// An `onChange` Attribute leaves out the rows
     252              : /// that repeat a value, and a left-out row is not a zero.
     253          186 : fn check_methods(
     254          186 :     k: &str,
     255          186 :     class: AggrClass,
     256          186 :     held: bool,
     257          186 :     r: &TRepr,
     258          186 :     ctx: &Context,
     259          186 : ) -> Result<(), NgsiError> {
     260          276 :     for method in &r.aggr_methods {
     261          276 :         if !aggr_eligible(class, method) {
     262           38 :             return Err(NgsiError::InvalidRequest(format!(
     263           38 :                 "attribute {} ({class:?}-valued) is not eligible for \
     264           38 :                  aggregation method {method} (4.5.19.1, 5.7.4.4)",
     265           38 :                 ctx.compact_iri(k)
     266           38 :             )));
     267          238 :         }
     268              :     }
     269          148 :     if let Some(method) = r
     270          148 :         .aggr_methods
     271          148 :         .iter()
     272          238 :         .find(|m| held && matches!(m.as_str(), "sum" | "totalCount"))
     273              :     {
     274            4 :         return Err(NgsiError::InvalidRequest(format!(
     275            4 :             "aggregation-not-meaningful: {method} over {}, which records a row only \
     276            4 :              when its value changes",
     277            4 :             ctx.compact_iri(k)
     278            4 :         )));
     279          144 :     }
     280          144 :     Ok(())
     281          186 : }
     282              : 
     283              : /// 4.5.19.1: "A duration of 0 second (e.g. expressed as "PT0S" or
     284              : /// "P0D") is valid and is interpreted as a duration spanning the whole
     285              : /// time range specified by the temporal query." The query names one
     286              : /// edge of that range; 4.11 leaves the other open for `before` and
     287              : /// `after`, so the data closes the open one. `times` is sorted.
     288          144 : fn whole_range(times: &Rows<'_>, tq: Option<&TemporalQ>) -> Option<Bucket> {
     289          144 :     let at = |s: &str| chrono::DateTime::parse_from_rfc3339(s).ok();
     290          144 :     let (&(first, _), &(last_at, _)) = (times.first()?, times.last()?);
     291          144 :     let last = last_at
     292          144 :         .checked_add_signed(chrono::Duration::seconds(1))
     293          144 :         .unwrap_or(last_at);
     294           46 :     Some(match tq {
     295           76 :         Some(q) if q.timerel == "before" => (first, at(&q.time_at).unwrap_or(last)),
     296           68 :         Some(q) if q.timerel == "between" => (
     297           22 :             at(&q.time_at).unwrap_or(first),
     298           22 :             q.end_time_at.as_deref().and_then(at).unwrap_or(last),
     299           22 :         ),
     300           46 :         Some(q) if q.timerel == "after" => (at(&q.time_at).unwrap_or(first), last),
     301           68 :         _ => (first, last),
     302              :     })
     303          144 : }
     304              : 
     305              : /// The `sc`-second period from `anchor` holding `t`. Checked throughout:
     306              : /// an offset no representable date can hold puts the instant in one final
     307              : /// open-ended bucket instead of panicking.
     308        22558 : fn seconds_bucket(anchor: Moment, sc: i64, t: Moment) -> Bucket {
     309        22558 :     let idx = t.signed_duration_since(anchor).num_seconds().div_euclid(sc);
     310        22558 :     let bucket = idx
     311        22558 :         .checked_mul(sc)
     312        22558 :         .and_then(chrono::Duration::try_seconds)
     313        22558 :         .and_then(|off| anchor.checked_add_signed(off))
     314        22558 :         .and_then(|start| {
     315        22558 :             chrono::Duration::try_seconds(sc)
     316        22558 :                 .and_then(|w| start.checked_add_signed(w))
     317        22558 :                 .map(|end| (start, end))
     318        22558 :         });
     319        22558 :     match bucket {
     320        22558 :         Some(b) => b,
     321            0 :         None => (anchor, chrono::DateTime::<chrono::Utc>::MAX_UTC.into()),
     322              :     }
     323        22558 : }
     324              : 
     325              : /// The period of `m` months and `sc` seconds from `anchor` holding `t`.
     326           20 : fn months_bucket(anchor: Moment, m: u32, sc: i64, t: Moment) -> Bucket {
     327              :     use chrono::Datelike;
     328              :     // start of the k-th period, O(1) in k. Negative k are
     329              :     // the periods BEFORE the anchor, which is what a
     330              :     // `before` query is made of.
     331           76 :     let step = |k: i64| -> Option<Moment> {
     332           76 :         let n = k.unsigned_abs().checked_mul(u64::from(m))?;
     333           76 :         let n = chrono::Months::new(u32::try_from(n).ok()?);
     334           76 :         let base = if k < 0 {
     335           40 :             anchor.checked_sub_months(n)?
     336              :         } else {
     337           36 :             anchor.checked_add_months(n)?
     338              :         };
     339           76 :         let off = chrono::Duration::try_seconds(k.checked_mul(sc)?)?;
     340           76 :         base.checked_add_signed(off)
     341           76 :     };
     342              :     // The whole-month distance ignores the day and the time
     343              :     // of day, and one period is at least one month, so it
     344              :     // brackets the exact index within one step: binary-search
     345              :     // between it and the anchor instead of walking there,
     346              :     // which is O(log) however far the instant is.
     347           20 :     let approx = i64::from(t.year())
     348           20 :         .saturating_sub(i64::from(anchor.year()))
     349           20 :         .saturating_mul(12)
     350           20 :         .saturating_add(i64::from(t.month()))
     351           20 :         .saturating_sub(i64::from(anchor.month()))
     352           20 :         .div_euclid(i64::from(m));
     353           20 :     let (mut lo, mut hi) = (
     354           20 :         approx.min(0).saturating_sub(1),
     355           20 :         approx.max(0).saturating_add(1),
     356           20 :     );
     357           56 :     while hi.saturating_sub(lo) > 1 {
     358           36 :         let mid = lo.saturating_add(hi.saturating_sub(lo).div_euclid(2));
     359           36 :         if step(mid).is_some_and(|s| s <= t) {
     360           24 :             lo = mid;
     361           24 :         } else {
     362           12 :             hi = mid;
     363           12 :         }
     364              :     }
     365              :     // saturate instead of panic: a huge month period or a
     366              :     // far-future timeAt overflows chrono's range — treat the
     367              :     // remainder as one open-ended bucket
     368           20 :     match (step(lo), step(hi)) {
     369           20 :         (Some(start), Some(end)) if start <= t => (start, end),
     370            0 :         (Some(start), None) if start <= t => {
     371            0 :             (start, chrono::DateTime::<chrono::Utc>::MAX_UTC.into())
     372              :         }
     373            0 :         _ => (anchor, chrono::DateTime::<chrono::Utc>::MAX_UTC.into()),
     374              :     }
     375           20 : }
     376              : 
     377              : /// The sorted rows in their periods. An onChange attribute held its value
     378              : /// through a period no row fell in: that period answers the value, as
     379              : /// `every` would have, up to the window's end, never past now, and at most
     380              : /// MAX_HELD_BUCKETS of them: a far edge and PT1S is refused (`None`), not
     381              : /// allocated.
     382          144 : fn bucketed<'a>(
     383          144 :     times: &Rows<'a>,
     384          144 :     held: bool,
     385          144 :     whole: Bucket,
     386          144 :     bucket_of: &dyn Fn(Moment) -> Bucket,
     387          144 : ) -> Option<Vec<(Bucket, Rows<'a>)>> {
     388          144 :     let mut buckets: Vec<(Bucket, Rows)> = Vec::new();
     389          144 :     let edge = whole.1.min(chrono::Utc::now().fixed_offset());
     390          144 :     let mut refused = false;
     391         1290 :     let mut fill = |buckets: &mut Vec<(Bucket, Rows)>, until: Moment| {
     392        21254 :         while let Some(end) = buckets.last().map(|((_, end), _)| *end) {
     393        21238 :             let gap = bucket_of(end);
     394        21238 :             if end >= until || end >= edge || gap.0 < end {
     395         1204 :                 break;
     396        20034 :             }
     397        20034 :             if buckets.len() >= MAX_HELD_BUCKETS {
     398            2 :                 refused = true;
     399            2 :                 break;
     400        20032 :             }
     401        20032 :             buckets.push((gap, Vec::new()));
     402              :         }
     403         1222 :     };
     404         1548 :     for &(t, v) in times {
     405         1548 :         let b = bucket_of(t);
     406         1548 :         if held {
     407         1206 :             fill(&mut buckets, b.0);
     408         1392 :         }
     409         1548 :         match buckets.last_mut() {
     410         1404 :             Some((bb, rows)) if bb.0 == b.0 => rows.push((t, v)),
     411         1098 :             _ => buckets.push((b, vec![(t, v)])),
     412              :         }
     413              :     }
     414          144 :     if held {
     415           16 :         fill(&mut buckets, whole.1);
     416          128 :     }
     417          144 :     (!refused).then_some(buckets)
     418          144 : }
     419              : 
     420              : /// The member of one Attribute: its type and, per method, one
     421              : /// `[value, start, end]` per period. `window_end` caps a held value's weight.
     422          142 : fn render_buckets(
     423          142 :     buckets: &[(Bucket, Rows<'_>)],
     424          142 :     class: AggrClass,
     425          142 :     held: bool,
     426          142 :     window_end: Moment,
     427          142 :     r: &TRepr,
     428          142 : ) -> Map<String, Value> {
     429         2464 :     let fmt = |d: Moment| d.format("%Y-%m-%dT%H:%M:%SZ").to_string();
     430              :     // the value the last bucket with a row ended on holds into the next one
     431          142 :     let carried: Vec<Option<&Value>> = buckets
     432          142 :         .iter()
     433         1130 :         .scan(None, |last, (_, rows)| {
     434         1130 :             let before = *last;
     435         1130 :             if let Some(&(_, v)) = rows.last() {
     436         1094 :                 *last = Some(v);
     437         1094 :             }
     438         1130 :             Some(before)
     439         1130 :         })
     440          142 :         .collect();
     441          142 :     let mut attr_out = Map::new();
     442              :     // 4.5.19.0: the member is labelled "Property" for Properties and
     443              :     // "Relationship" for Relationships.
     444          142 :     let label = if class == AggrClass::Relationship {
     445            6 :         "Relationship"
     446              :     } else {
     447          136 :         "Property"
     448              :     };
     449          142 :     attr_out.insert("type".into(), Value::String(label.into()));
     450          232 :     for method in &r.aggr_methods {
     451          232 :         let rows: Vec<Value> = buckets
     452          232 :             .iter()
     453          232 :             .zip(&carried)
     454         1232 :             .map(|(((bs, be), rows), &before)| {
     455         1232 :                 let weighted = (held && method == "avg")
     456         1232 :                     .then(|| time_weighted_avg(class, rows, before, *bs, (*be).min(window_end)))
     457         1232 :                     .flatten();
     458         1232 :                 let val = weighted.unwrap_or_else(|| {
     459          278 :                     let vals: Vec<&Value> = match (rows.is_empty(), before) {
     460            0 :                         (true, Some(v)) => vec![v],
     461          278 :                         _ => rows.iter().map(|&(_, v)| v).collect(),
     462              :                     };
     463          278 :                     aggregate_bucket(method, class, &vals)
     464          278 :                 });
     465         1232 :                 Value::Array(vec![val, Value::String(fmt(*bs)), Value::String(fmt(*be))])
     466         1232 :             })
     467          232 :             .collect();
     468          232 :         attr_out.insert(method.clone(), Value::Array(rows));
     469              :     }
     470          142 :     attr_out
     471          142 : }
     472              : 
     473              : /// The mean of a bucket from `start` to `end` whose `rows` (time, value) each
     474              : /// hold until the next, `before` holding until the first;
     475              : /// `None` for a class with no number or a bucket no value held for a moment.
     476          974 : pub(super) fn time_weighted_avg(
     477          974 :     class: AggrClass,
     478          974 :     rows: &[(chrono::DateTime<chrono::FixedOffset>, &Value)],
     479          974 :     before: Option<&Value>,
     480          974 :     start: chrono::DateTime<chrono::FixedOffset>,
     481          974 :     end: chrono::DateTime<chrono::FixedOffset>,
     482          974 : ) -> Option<Value> {
     483          974 :     if class == AggrClass::TimeOfDay {
     484            4 :         return None;
     485          970 :     }
     486          970 :     let held = before
     487          970 :         .map(|v| (start, v))
     488          970 :         .into_iter()
     489          970 :         .chain(rows.iter().copied());
     490          970 :     let until = rows
     491          970 :         .iter()
     492          970 :         .skip(usize::from(before.is_none()))
     493          970 :         .map(|&(t, _)| t)
     494          970 :         .chain([end]);
     495          970 :     let (mut weighted, mut span) = (0.0, 0.0);
     496         2158 :     for ((from, v), to) in held.zip(until) {
     497         2158 :         let secs = to.signed_duration_since(from).num_milliseconds() as f64 / 1000.0;
     498         2158 :         if let (Some(x), true) = (numeric_of(class, v), secs > 0.0) {
     499         2154 :             weighted += x * secs;
     500         2154 :             span += secs;
     501         2154 :         }
     502              :     }
     503          970 :     (span > 0.0).then(|| serde_json::json!(weighted / span))
     504          974 : }
     505              : 
     506              : /// One bucket, one method — per-class semantics from Tables 4.5.19.1-1/2/3.
     507              : /// Never emits an out-of-range float (the old fold seeded with
     508              : /// f64::INFINITY, which serde_json serializes as null).
     509          346 : pub(super) fn aggregate_bucket(method: &str, class: AggrClass, vals: &[&Value]) -> Value {
     510          740 :     let nums: Vec<f64> = vals.iter().filter_map(|v| numeric_of(class, v)).collect();
     511          346 :     let finite = |x: f64| {
     512          180 :         if x.is_finite() {
     513          176 :             serde_json::json!(x)
     514              :         } else {
     515            4 :             Value::Null
     516              :         }
     517          180 :     };
     518          346 :     match method {
     519          346 :         "totalCount" => serde_json::json!(vals.len()),
     520          284 :         "distinctCount" => {
     521              :             // Relationship: "count of distinct relationship TARGETS" — an
     522              :             // object may be a URI or an array of URIs, so flatten first.
     523           24 :             let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
     524           48 :             for v in vals {
     525           48 :                 let items: Vec<&Value> = match (class, v) {
     526            4 :                     (AggrClass::Relationship, Value::Array(a)) => a.iter().collect(),
     527           44 :                     _ => vec![*v],
     528              :                 };
     529           52 :                 for it in items {
     530           52 :                     seen.insert(it.to_string());
     531           52 :                 }
     532              :             }
     533           24 :             serde_json::json!(seen.len())
     534              :         }
     535          260 :         "min" | "max" => match class {
     536              :             // ordered classes: the first or last value in the order the
     537              :             // tables give the datatype, returned as it was written
     538              :             AggrClass::Text | AggrClass::Instant | AggrClass::TimeOfDay => {
     539           56 :                 let mut keyed: Vec<(String, &Value)> = vals
     540           56 :                     .iter()
     541          128 :                     .filter_map(|v| order_key(class, v).map(|k| (k, *v)))
     542           56 :                     .collect();
     543           88 :                 keyed.sort_by(|a, b| a.0.cmp(&b.0));
     544           56 :                 let pick = if method == "min" {
     545           28 :                     keyed.first()
     546              :                 } else {
     547           28 :                     keyed.last()
     548              :                 };
     549           56 :                 pick.map_or(Value::Null, |(_, v)| (*v).clone())
     550              :             }
     551              :             _ => {
     552           80 :                 let it = nums.iter().copied();
     553           80 :                 let picked = if method == "min" {
     554           76 :                     it.fold(None, |a: Option<f64>, v| Some(a.map_or(v, |x| x.min(v))))
     555              :                 } else {
     556          132 :                     it.fold(None, |a: Option<f64>, v| Some(a.map_or(v, |x| x.max(v))))
     557              :                 };
     558           80 :                 picked.map_or(Value::Null, &finite)
     559              :             }
     560              :         },
     561          124 :         "sum" => finite(nums.iter().sum::<f64>()),
     562          112 :         "avg" => {
     563           96 :             if nums.is_empty() {
     564            4 :                 Value::Null
     565           92 :             } else if class == AggrClass::TimeOfDay {
     566            4 :                 let mean = nums.iter().sum::<f64>() / nums.len() as f64;
     567            4 :                 let (h, m, sec) = (
     568            4 :                     (mean / 3600.0) as u32,
     569            4 :                     ((mean % 3600.0) / 60.0) as u32,
     570            4 :                     (mean % 60.0) as u32,
     571            4 :                 );
     572              :                 // 4.6.3: a Time is `hh:mm:ssZ`, and its JSON-LD type is what
     573              :                 // tells a reader it is one — written bare it reads back as a
     574              :                 // JSON String, which Table 4.5.19.1-1 gives no average.
     575            4 :                 serde_json::json!({
     576            4 :                     "@type": "Time",
     577            4 :                     "@value": format!("{h:02}:{m:02}:{sec:02}Z"),
     578              :                 })
     579              :             } else {
     580           88 :                 finite(nums.iter().sum::<f64>() / nums.len() as f64)
     581              :             }
     582              :         }
     583           16 :         "stddev" => {
     584            8 :             if nums.is_empty() {
     585            4 :                 Value::Null
     586              :             } else {
     587            4 :                 let n = nums.len() as f64;
     588            4 :                 let mean = nums.iter().sum::<f64>() / n;
     589           12 :                 finite((nums.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / n).sqrt())
     590              :             }
     591              :         }
     592           12 :         "sumsq" => finite(nums.iter().map(|v| v * v).sum::<f64>()),
     593            4 :         _ => Value::Null,
     594              :     }
     595          346 : }
        

Generated by: LCOV version 2.0-1