api/inferred_freq: get the inferred frequency and a quality indicator
M test/test_api.py +58 -0
@@ 242,6 242,64 @@ 2020-01-04 16:00+00:00, 800.0
     pd.testing.assert_series_equal(computed_ts, expected_ts, check_names=False)
 
 
+def test_inferred_freq(tsx):
+    tsx.delete('infer_freq')
+
+    ts = pd.Series(
+        [1, 2, 3],
+        index=pd.date_range(
+            pd.Timestamp('2023-1-1'),
+            freq='D',
+            periods=3
+        )
+    )
+    tsx.update(
+        'infer_freq',
+        ts,
+        'Babar',
+        insertion_date=pd.Timestamp('2023-5-1', tz='utc')
+    )
+
+    assert tsx.inferred_freq('infer_freq') == (
+        pd.Timedelta(days=1),
+        1
+    )
+    assert tsx.inferred_freq(
+        'infer_freq',
+        from_value_date=pd.Timestamp('2023-1-3')
+    ) is None
+    assert tsx.inferred_freq('no-such-series') is None
+
+    # frequence change
+    ts = pd.Series(
+        [1, 2, 3, 4, 5],
+        index=pd.date_range(
+            pd.Timestamp('2023-1-3T01:00:00'),
+            freq='H',
+            periods=5
+        )
+    )
+    tsx.update(
+        'infer_freq',
+        ts,
+        'Babar',
+        insertion_date=pd.Timestamp('2023-5-2', tz='utc')
+    )
+
+    assert tsx.inferred_freq('infer_freq') == (
+        pd.Timedelta(hours=1),
+        0.7142857142857143
+    )
+    freq = tsx.inferred_freq(
+        'infer_freq',
+        revision_date=pd.Timestamp('2023-5-1')
+    )
+    assert freq == (
+        pd.Timedelta(days=1),
+        1
+    )
+
+
 def test_log(tsx):
     for name in ('log-me',):
         tsx.delete(name)

          
M tshistory/api.py +22 -0
@@ 624,6 624,28 @@ class mainsource:
             return self.othersources.interval(name)
         return ival
 
+    def inferred_freq(self,
+                      name: str,
+                      revision_date: Optional[pd.Timestamp]=None,
+                      from_value_date: Optional[pd.Timestamp]=None,
+                      to_value_date: Optional[pd.Timestamp]=None
+                      ) -> Optional[Tuple[pd.Timedelta, float]]:
+        """Return a tuple of timedelta, float (between 0 and 1).
+
+        The timedelta represents the period (or 'freq' in pandas
+        parlance) and the number the quality of the period, which may vary
+        because of the irregularity of the series.
+
+        """
+        with self.engine.begin() as cn:
+            return self.tsh.infer_freq(
+                cn,
+                name,
+                revision_date,
+                from_value_date,
+                to_value_date
+            )
+
     def metadata(self,
                  name: str,
                  all: bool=None) -> Optional[Dict[str, Any]]:

          
M tshistory/http/client.py +29 -0
@@ 13,6 13,7 @@ from tshistory.util import (
     logme,
     pack_group,
     pack_series,
+    parse_delta,
     series_metadata,
     ts,
     tzaware_serie,

          
@@ 239,6 240,34 @@ class httpclient:
         return res
 
     @unwraperror
+    def inferred_freq(self, name,
+                      revision_date=None,
+                      from_value_date=None,
+                      to_value_date=None):
+        args = {
+            'name': name
+        }
+        if revision_date:
+            args['revision_date'] = strft(revision_date)
+        if from_value_date:
+            args['from_value_date'] = strft(from_value_date)
+        if to_value_date:
+            args['to_value_date'] = strft(to_value_date)
+
+        res = self.session.get(
+            f'{self.uri}/series/freq', params=args
+        )
+        if res.status_code == 404:
+            return None
+        if res.status_code == 200:
+            data = res.json()
+            if data is None:
+                return
+            ifreq = data['inferred_freq']
+            return parse_delta(ifreq[0]), float(ifreq[1])
+        return res
+
+    @unwraperror
     def get(self, name,
             revision_date=None,
             from_value_date=None,

          
M tshistory/http/server.py +40 -0
@@ 107,6 107,18 @@ put_metadata.add_argument(
     help='set new metadata for a series'
 )
 
+inferred_freq = base.copy()
+inferred_freq.add_argument(
+    'revision_date', type=utcdt, default=None,
+)
+inferred_freq.add_argument(
+    'from_value_date', type=utcdt, default=None
+)
+inferred_freq.add_argument(
+    'to_value_date', type=utcdt, default=None
+)
+
+
 insertion_dates = base.copy()
 insertion_dates.add_argument(
     'from_insertion_date', type=utcdt, default=None

          
@@ 516,6 528,34 @@ class httpapi:
 
                 return '', 200
 
+        @nss.route('/freq')
+        class timeseries_freq(Resource):
+
+            @api.expect(inferred_freq)
+            @onerror
+            def get(self):
+                args = inferred_freq.parse_args()
+                if not tsa.exists(args.name):
+                    api.abort(404, f'`{args.name}` does not exists')
+
+                freq_qa = tsa.inferred_freq(
+                    args.name,
+                    args.revision_date,
+                    args.from_value_date,
+                    args.to_value_date
+                )
+
+                if freq_qa is None:
+                    return make_response('null')
+
+                freq = util.delta_isoformat(freq_qa[0])
+                response = make_response(
+                    {
+                        'inferred_freq': (freq, str(freq_qa[1]))
+                    }
+                )
+                response.headers['Content-Type'] = 'text/json'
+                return response
 
         @nss.route('/state')
         class timeseries_state(Resource):

          
M tshistory/testutil.py +5 -0
@@ 272,6 272,11 @@ class with_http_bridge:
         )
 
         resp.add_callback(
+            responses.GET, uri + '/series/freq',
+            callback=partial(read_request_bridge, wsgitester)
+        )
+
+        resp.add_callback(
             responses.GET, uri + '/series/log',
             callback=partial(read_request_bridge, wsgitester)
         )

          
M tshistory/util.py +19 -0
@@ 3,6 3,7 @@ import os
 import math
 import struct
 import json
+import re
 from array import array
 from collections import defaultdict
 import logging

          
@@ 329,6 330,24 @@ def infer_freq(ts):
     return freq, conform_intervals / len(deltas)
 
 
+# timedelta (de)serialisation
+
+def delta_isoformat(td):
+    return f'P{td.days}DT0H0M{td.seconds}S'
+
+
+_DELTA = re.compile('P(.*)DT(.*)H(.*)M(.*)S')
+def parse_delta(td):
+    match = _DELTA.match(td)
+    if not match:
+        raise Exception(f'unparseable time delta `{td}`')
+    days, hours, minutes, seconds = match.groups()
+    return pd.Timedelta(
+        days=int(days), hours=int(hours),
+        minutes=int(minutes), seconds=int(seconds)
+    )
+
+
 # metadata
 
 def series_metadata(ts):