Exemplo n.º 1
0
def _get_guided_qrs_shape(signal, shape):
    """
    Creates a new shape object adapting the attributes and wave limits to a
    new signal fragment, whose similarity has also been checked.
    """
    ref = shape.sig
    _, _, path = dtw_std(signal, ref, dist_only=False)
    newshape = o.QRSShape()
    waves = []
    for wav in shape.waves:
        wave = Wave()
        lpt = int(np.mean(path[0][path[1] == wav.l]))
        mpt = int(np.mean(path[0][path[1] == wav.m]))
        rpt = int(np.mean(path[0][path[1] == wav.r]))
        wave.pts = (lpt, mpt, rpt)
        wsig = signal[lpt : rpt + 1]
        wave.e = np.sum(np.diff(wsig) ** 2)
        wavamp = np.ptp(ref[wav.l : wav.r + 1])
        wave.amp = wav.amp * np.ptp(wsig) / wavamp if wavamp > 0 else 0.0
        waves.append(wave)
    newshape.waves = tuple(waves)
    newshape.sig = signal[waves[0].l : waves[-1].r + 1] - signal[waves[0].l]
    dif = np.diff(newshape.sig)
    newshape.energy = np.sum(dif ** 2)
    newshape.amplitude = np.ptp(newshape.sig)
    newshape.maxslope = np.max(np.abs(dif))
    newshape.tag = shape.tag
    return newshape
Exemplo n.º 2
0
def _contains_qrs(pattern):
    """
    Checks if inside the flutter fragment there is a waveform "identical" to
    the first environment QRS complex.
    """
    qrs = pattern.evidence[o.QRS][0]
    # We limit the duration of the QRS to check this condition.
    if qrs.lateend - qrs.earlystart not in C.NQRS_DUR:
        return False
    defls = pattern.evidence[o.Deflection]
    if len(defls) > 1:
        limit = defls[-3].lateend if len(defls) > 2 else qrs.lateend
        sig = {}
        # We take the signal fragment with maximum correlation with the QRS
        # signal in each lead, and we check if the two fragments can be
        # clustered as equal QRS complexes.
        qshape = {}
        corr = -np.Inf
        delay = 0
        leads = sig_buf.get_available_leads()
        for lead in leads:
            qshape[lead] = o.QRSShape()
            sigfr = sig_buf.get_signal_fragment(qrs.earlystart,
                                                qrs.lateend + 1,
                                                lead=lead)[0]
            qshape[lead].sig = sigfr - sigfr[0]
            qshape[lead].amplitude = np.ptp(qshape[lead].sig)
            sig[lead] = sig_buf.get_signal_fragment(limit,
                                                    defls[-1].earlystart,
                                                    lead=lead)[0]
            if len(sig[lead]) > 0 and len(qshape[lead].sig) > 0:
                lcorr, ldelay = xcorr_valid(sig[lead], qshape[lead].sig)
                if lcorr > corr:
                    corr, delay = lcorr, ldelay
        if 0 <= delay < len(sig[lead]):
            sshape = {}
            for lead in leads:
                sshape[lead] = o.QRSShape()
                sshape[lead].sig = sig[lead][
                    delay:delay + len(qshape[lead].sig)] - sig[lead][delay]
                sshape[lead].amplitude = np.ptp(sshape[lead].sig)
            return not signal_unmatch(sshape, qshape)
    return False
Exemplo n.º 3
0
def _get_paced_qrs_shape(signal, points, start, end):
    """
    Obtains the QRSShape object corresponding to a paced QRS complex delimited
    inside a signal fragment.

    Parameters
    ----------
    signal:
        Signal fragment containing a paced QRS complex. The limits of the
        signal should be the limits determined by the *_paced_qrs_delineation*
        function.
    points:
        Relevant points in the signal fragment.
    start:
        Start point of the pace spike wrt the start of the signal.
    end:
        Finish point of the paced QRS wrt the start of the signal.

    Returns
    -------
    out:
        QRSShape object representing the paced beat.
    """
    try:
        signal = signal[start : end + 1]
        points = points[np.logical_and(points >= start, points <= end)] - start
        verify(len(points) > 0)
        if points[0] != 0:
            points = np.insert(points, 0, 0)
        if points[-1] != len(signal) - 1:
            points = np.append(points, len(signal) - 1)
        verify(len(points) >= 3)
        # We assume the baseline level is the start signal value of the spike
        waves = extract_waves(signal, points, signal[points[0]])
        verify(waves)
        total_energ = sum(w.e for w in waves)
        # We get the longest wave sequence with a valid QRS tag.
        i = 0
        while i < len(waves) and _tag_qrs(waves[: i + 1]) in QRS_SHAPES:
            i += 1
        tag = _tag_qrs(waves[:i])
        verify(tag in QRS_SHAPES)
        shape = o.QRSShape()
        shape.waves = waves[:i]
        shape.energy = sum(w.e for w in shape.waves)
        shape.tag = tag
        shape.sig = signal[shape.waves[0].l : shape.waves[-1].r + 1] - signal[shape.waves[0].l]
        shape.maxslope = np.max(np.abs(np.diff(shape.sig)))
        shape.amplitude = np.ptp(shape.sig)
        shape.move(start)
        verify(shape.energy / total_energ > 0.5)
        return shape
    except (ValueError, InconsistencyError):
        return None
Exemplo n.º 4
0
def _get_qrs_shape(signal, points, peak, baseline):
    """
    Obtains the QRSShape object that best fits a signal fragment, considering
    the simplification determined by points, and the peak and baseline
    estimations. The detected QRS shape must collect the majority of the total
    energy of the waves present in the signal fragment.
    """
    try:
        waves = extract_waves(signal, points, baseline)
        verify(waves)
        total_energ = sum(w.e for w in waves)
        # We find the longest valid sequence of waves with the highest energy.
        sequences = []
        for i in range(len(waves)):
            # Largest valid sequence starting in the i-th wave.
            seq = [waves[i]]
            j = i + 1
            while j < len(waves) and _is_qrs_complex(waves[i : j + 1]):
                seq.append(waves[j])
                j += 1
            # We add the valid sequence and the acumulated energy (we require
            # the peak to actually be inside the sequence.)
            tag = _tag_qrs(seq)
            energ = sum(w.e for w in seq)
            if tag in QRS_SHAPES and energ / total_energ > 0.5 and any(w.l <= peak <= w.r for w in seq):
                sequences.append((seq, tag, energ))
        # We get the sequence with the maximum value
        verify(sequences)
        seq, tag, energ = max(sequences, key=operator.itemgetter(2))
        shape = o.QRSShape()
        shape.energy = energ
        shape.tag = tag
        shape.waves = seq
        shape.sig = signal[seq[0].l : seq[-1].r + 1] - signal[seq[0].l]
        shape.maxslope = np.max(np.abs(np.diff(shape.sig)))
        shape.amplitude = np.ptp(shape.sig)
        return shape
    except (ValueError, InconsistencyError):
        return None
Exemplo n.º 5
0
def _update_morphology(pattern):
    """
    Updates the reference morphology of the hypothesis of the pattern from
    the morphology of the beats that are part of the rhythm.
    """
    beats = pattern.evidence[o.QRS]
    for lead in sig_buf.get_available_leads():
        # We get the most common pattern as the reference.
        ctr = Counter(b.shape[lead].tag for b in beats if lead in b.shape)
        if ctr:
            mc = ctr.most_common(2)
            # If the most common is not unique, we move on
            if len(mc) == 2 and mc[0][1] == mc[1][1]:
                continue
            tag = mc[0][0]
            energy = np.mean([
                b.shape[lead].energy for b in beats
                if lead in b.shape and b.shape[lead].tag == tag
            ])
            if not lead in pattern.hypothesis.morph:
                pattern.hypothesis.morph[lead] = o.QRSShape()
            pattern.hypothesis.morph[lead].tag = tag
            pattern.hypothesis.morph[lead].energy = energy
Exemplo n.º 6
0
 def _qrs_tconst(pattern, qrs):
     """
     Temporal constraints to observe a new QRS complex.
     """
     beats = pattern.evidence[o.QRS]
     idx = beats.index(qrs)
     hyp = pattern.hypothesis
     tnet = pattern.last_tnet
     obseq = pattern.obs_seq
     oidx = pattern.get_step(qrs)
     # The environment complex sets the start of the rhythm observation.
     if pattern.get_evidence_type(qrs)[1] is ENVIRONMENT:
         tnet.set_equal(hyp.start, qrs.time)
     else:
         if idx > 0:
             prev = beats[idx - 1]
             tnet.remove_constraint(hyp.end, prev.time)
             # We create a new temporal network for the cyclic observations
             tnet = ConstraintNetwork()
             tnet.add_constraint(prev.time, qrs.time, rr_bounds)
             if rr_bounds is not C.TACHY_RR:
                 # Also bounding on begin and end, but with relaxed variation
                 # margin.
                 rlx_rrb = Iv(rr_bounds.start - C.TMARGIN,
                              rr_bounds.end + C.TMARGIN)
                 tnet.add_constraint(prev.start, qrs.start, rlx_rrb)
                 tnet.add_constraint(prev.end, qrs.end, rlx_rrb)
             tnet.set_before(prev.end, qrs.start)
             # If there is a prior T Wave, it must finish before the start
             # of the QRS complex.
             if isinstance(obseq[oidx - 1], o.TWave):
                 prevt = obseq[oidx - 1]
                 tnet.set_before(prevt.end, qrs.start)
             ##RR evolution constraint. We combine the statistical limits
             # with a dynamic evolution.
             if idx > 1:
                 prev2 = beats[idx - 2]
                 rrev = prev.time.start - prev2.time.start
                 if hyp.meas.rr[0] > 0:
                     meanrr, stdrr = hyp.meas.rr
                     const = Iv(
                         min(0.8 * rrev, rrev - C.RR_MAX_DIFF,
                             meanrr - 2 * stdrr),
                         max(1.2 * rrev, rrev + C.RR_MAX_DIFF,
                             meanrr + 2 * stdrr),
                     )
                 else:
                     const = Iv(min(0.8 * rrev, rrev - C.RR_MAX_DIFF),
                                max(1.2 * rrev, rrev + C.RR_MAX_DIFF))
                 tnet.add_constraint(prev.time, qrs.time, const)
             pattern.temporal_constraints.append(tnet)
             # TODO improve
             if not qrs.frozen and hyp.morph:
                 nullsh = o.QRSShape()
                 refbeat = next(
                     (b for b in reversed(beats[:idx])
                      if not b.clustered and all(
                          b.shape.get(lead, nullsh).tag ==
                          hyp.morph[lead].tag for lead in hyp.morph)),
                     None,
                 )
                 if refbeat is not None:
                     qrs.shape = refbeat.shape
                     qrs.paced = refbeat.paced
     BASIC_TCONST(pattern, qrs)
     tnet.add_constraint(qrs.start, qrs.end, C.QRS_DUR)
     tnet.set_before(qrs.time, hyp.end)
Exemplo n.º 7
0
def _cycle_finished_gconst(pattern, _):
    """
    General constraints to be added when a new cycle is observed, which
    currently coincides with the observation of the T waves or a QRS complex
    not followed by an observed T wave.
    """
    # We update the measurements and the morphology of the rhythm.
    _update_measures(pattern)
    _update_morphology(pattern)
    # And check that there are no missed beat forms.
    _check_missed_beats(pattern)
    beats = pattern.evidence[o.QRS]
    rrs = np.diff([b.time.start for b in beats[-32:]])
    # HINT with this check, we avoid overlapping between sinus rhythms and
    # tachycardias and bradycardias at the beginning of the pattern.
    if len(beats) == 3:
        if pattern.automata is SINUS_PATTERN:
            verify(np.any(rrs < C.BRADY_RR.start))
        elif pattern.automata is BRADYCARDIA_PATTERN:
            if pattern.evidence[o.Cardiac_Rhythm] and isinstance(
                    pattern.evidence[o.Cardiac_Rhythm][0], o.Sinus_Rhythm):
                verify(any([rr not in C.SINUS_RR for rr in rrs]))
    elif len(beats) == 4:
        if pattern.automata is SINUS_PATTERN:
            verify(np.any(rrs > C.TACHY_RR.end))
        elif pattern.automata is TACHYCARDIA_PATTERN:
            if pattern.evidence[o.Cardiac_Rhythm] and isinstance(
                    pattern.evidence[o.Cardiac_Rhythm][0], o.Sinus_Rhythm):
                verify(any([rr not in C.SINUS_RR for rr in rrs]))
    # We impose some constraints in the evolution of the RR interval and
    # of the amplitude
    # TODO remove these lines to enable full check
    ######################################################################
    if len(beats) >= 3:
        # The coefficient of variation within a regular rhythm has to be low
        verify(np.std(rrs) / np.mean(rrs) <= C.RR_MAX_CV)
        # RR evolution
        meanrr, stdrr = pattern.hypothesis.meas.rr
        verify(meanrr - 2 * stdrr <= rrs[-1] <= meanrr + 2 * stdrr
               or abs(rrs[-1] - rrs[-2]) <= C.RR_MAX_DIFF
               or 0.8 * rrs[-2] <= rrs[-1] <= 1.2 * rrs[-2])
    return
    #######################################################################
    # Morphology check. We require the rhythm morphology to be matched
    # by the new beat in the sequence.
    ref = pattern.hypothesis.morph
    # We initialize the morphology with the first beat.
    if not ref:
        for lead in beats[0].shape:
            ref[lead] = o.QRSShape()
            ref[lead].tag = beats[0].shape[lead].tag
            ref[lead].energy = beats[0].shape[lead].energy
    beat = beats[-1]
    # The leads matching morphology should sum more energy than the
    # unmatching.
    menerg = 0.0
    uenerg = 0.0
    perfect_match = False
    for lead in beat.shape:
        if lead in ref:
            bshape = beat.shape[lead]
            # If there is a "perfect" match in one lead, we accept clustering
            if bshape.tag == ref[lead].tag and 0.75 <= bshape.energy / ref[
                    lead].energy <= 1.25:
                perfect_match = True
                break
            # If there are at least 10 beats in the sequence, we require
            # match from the beat to the rhythm, else we are ok in both
            # directions.
            match = bshape.tag in QRS_SHAPES[ref[lead].tag]
            if len(beats) < 10:
                match = bool(match or ref[lead].tag in QRS_SHAPES[bshape.tag])
            if match:
                menerg += ref[lead].energy
            else:
                uenerg += ref[lead].energy
    # If the matched energy is lower than unmatched, the hypothesis is
    # refuted.
    verify(perfect_match or menerg > uenerg)
    _update_morphology(pattern)
    if len(beats) >= 3:
        # RR evolution
        rr_prev = beats[-2].time.start - beats[-3].time.start
        rr_act = beats[-1].time.start - beats[-2].time.start
        verify(abs(rr_act - rr_prev) <= C.RR_MAX_DIFF)
Exemplo n.º 8
0
def _check_missed_beats(pattern):
    """
    Checks if a rhythm pattern has missed a QRS complex in the identification,
    by looking for a waveform "identical" to the last observed in the interval
    between the last two observations.
    """
    qrs = pattern.evidence[o.QRS][-1]
    obseq = pattern.obs_seq
    idx = obseq.index(qrs)
    if idx > 0:
        prevobs = next(
            (obs for obs in reversed(obseq[:idx]) if obs is not None), None)
        if prevobs is not None:
            if isinstance(prevobs, o.QRS):
                limit = max(prevobs.lateend,
                            prevobs.earlystart + qrs.lateend - qrs.earlystart)
            else:
                limit = prevobs.lateend
        else:
            limit = pattern.hypothesis.earlystart
        ulimit = qrs.earlystart - C.TACHY_RR.start
        if limit >= ulimit:
            return
        sig = {}
        # We take the signal fragment with maximum correlation with the QRS
        # signal in each lead, and we check if the two fragments can be
        # clustered as equal QRS complexes.
        qshape = {}
        corr = -np.Inf
        delay = 0
        leads = sig_buf.get_available_leads()
        for lead in leads:
            qshape[lead] = o.QRSShape()
            sigfr = sig_buf.get_signal_fragment(qrs.earlystart,
                                                qrs.lateend + 1,
                                                lead=lead)[0]
            qshape[lead].sig = sigfr - sigfr[0]
            qshape[lead].amplitude = np.ptp(qshape[lead].sig)
            sig[lead] = sig_buf.get_signal_fragment(limit, ulimit,
                                                    lead=lead)[0]
            lcorr, ldelay = xcorr_valid(sig[lead], qshape[lead].sig)
            if lcorr > corr:
                corr, delay = lcorr, ldelay
        if 0 <= delay < len(sig[lead]):
            sshape = {}
            for lead in leads:
                sshape[lead] = o.QRSShape()
                sshape[lead].sig = sig[lead][
                    delay:delay + len(qshape[lead].sig)] - sig[lead][delay]
                sshape[lead].amplitude = np.ptp(sshape[lead].sig)
            if isinstance(pattern.hypothesis, o.RegularCardiacRhythm):
                qref = pattern.evidence[o.QRS][-2]
                rr = float(qrs.earlystart - qref.earlystart)
                loc = (limit + delay - qref.earlystart) / rr
                # Check for one and two missed beats in regular positions
                if 0.45 <= loc <= 0.55:
                    verify(signal_unmatch(sshape, qshape), 'Missed beat')
                elif 0.28 <= loc <= 0.38 and not signal_unmatch(
                        sshape, qshape):
                    corr = -np.Inf
                    delay = 0
                    for lead in leads:
                        sig[lead] = sig_buf.get_signal_fragment(
                            int(qref.earlystart + 0.61 * rr),
                            min(
                                int(qref.earlystart + 0.71 * rr) +
                                len(qshape[lead].sig), int(qrs.earlystart)),
                            lead=lead,
                        )[0]
                        lcorr, ldelay = xcorr_valid(sig[lead],
                                                    qshape[lead].sig)
                        if lcorr > corr:
                            corr, delay = lcorr, ldelay
                    sshape = {}
                    for lead in leads:
                        sshape[lead] = o.QRSShape()
                        sshape[lead].sig = (
                            sig[lead][delay:delay + len(qshape[lead].sig)] -
                            sig[lead][delay])
                        sshape[lead].amplitude = np.ptp(sshape[lead].sig)
                    verify(signal_unmatch(sshape, qshape), 'Two missed beats')
                elif 0.61 <= loc <= 0.71 and not signal_unmatch(
                        sshape, qshape):
                    corr = -np.Inf
                    delay = 0
                    for lead in leads:
                        sig[lead] = sig_buf.get_signal_fragment(
                            int(qref.earlystart + 0.28 * rr),
                            min(
                                int(qref.earlystart + 0.38 * rr) +
                                len(qshape[lead].sig), int(qrs.earlystart)),
                            lead=lead,
                        )[0]
                        lcorr, ldelay = xcorr_valid(sig[lead],
                                                    qshape[lead].sig)
                        if lcorr > corr:
                            corr, delay = lcorr, ldelay
                    sshape = {}
                    for lead in leads:
                        sshape[lead] = o.QRSShape()
                        sshape[lead].sig = (
                            sig[lead][delay:delay + len(qshape[lead].sig)] -
                            sig[lead][delay])
                        sshape[lead].amplitude = np.ptp(sshape[lead].sig)
                    verify(signal_unmatch(sshape, qshape), 'Two missed beats')
            else:
                verify(signal_unmatch(sshape, qshape), 'Missed beat')
Exemplo n.º 9
0
def ann2interp(record, anns, fmt=False):
    """
    Returns an interpretation containing the observations represented in a list
    of annotations associated to a loaded MIT record. Note that only the
    *observations* field is properly set. The optional parameter *fmt* allows
    to specify if the specific kardioml.segmentation.teijeiro.format for annotation files can be
    assumed. This parameter is also inferred from the first annotation in the
    list.
    """
    fmt = fmt or len(anns) > 0 and anns[0].code is C.NOTE and anns[0].aux == FMT_STRING
    interp = Interpretation()
    observations = []
    for i in range(len(anns)):
        ann = anns[i]
        if ann.code in (C.PWAVE, C.TWAVE):
            obs = o.PWave() if ann.code == C.PWAVE else o.TWave()
            if fmt:
                beg = next(
                    a
                    for a in reversed(anns[:i])
                    if a.time < ann.time and a.code == C.WFON and a.subtype == ann.code
                ).time
                end = next(
                    a for a in anns[i:] if a.time > ann.time and a.code == C.WFOFF and a.subtype == ann.code
                ).time
            else:
                beg = next(a for a in reversed(anns[:i]) if a.time < ann.time and a.code == C.WFON).time
                end = next(a for a in anns[i] if a.time > ann.time and a.code == C.WFOFF).time
            obs.start.value = Iv(beg, beg)
            obs.end.value = Iv(end, end)
            if fmt:
                amp = json.loads(ann.aux)
                for l in amp.keys():
                    if l not in record.leads:
                        compatible = next((l2 for l2 in VALID_LEAD_NAMES if VALID_LEAD_NAMES[l2] == l), None)
                        if compatible is None:
                            raise ValueError('Unrecognized lead {0}'.format(l))
                        obs.amplitude[compatible] = amp.pop(l)
            else:
                leads = record.leads if ann.code is C.TWAVE else set(K.PWAVE_LEADS) & set(record.leads)
                leads = record.leads
                for lead in leads:
                    sidx = record.leads.index(lead)
                    s = record.signal[sidx][beg : end + 1]
                    mx, mn = np.amax(s), np.amin(s)
                    pol = 1.0 if max(mx - s[0], mx - s[-1]) >= -min(mn - s[0], mn - s[1]) else -1.0
                    obs.amplitude[lead] = pol * np.ptp(s)
            observations.append(obs)
        elif MIT.is_qrs_annotation(ann):
            obs = o.QRS()
            obs.time.value = Iv(ann.time, ann.time)
            obs.tag = ann.code
            delin = json.loads(ann.aux)
            # QRS start and end is first tried to set according to delineation
            # info. If not present, it is done according to delineation
            # annotations.
            if delin:
                for l in delin.keys():
                    if l not in record.leads:
                        compatible = next((l2 for l2 in VALID_LEAD_NAMES if VALID_LEAD_NAMES[l2] == l), None)
                        if compatible is None:
                            raise ValueError('Unrecognized lead {0}'.format(l))
                        delin[compatible] = delin.pop(l)
                beg = ann.time + min(d[0] for d in delin.itervalues())
                end = ann.time + max(d[-1] for d in delin.itervalues())
            else:

                def extra_cond(a):
                    a.subtype == C.SYSTOLE if fmt else True

                beg = next(a for a in reversed(anns[:i]) if a.code == C.WFON and extra_cond(a)).time
                end = next(a for a in anns[i:] if a.code == C.WFOFF and extra_cond(a)).time
            # Endpoints set
            obs.start.value = Iv(beg, beg)
            obs.end.value = Iv(end, end)
            for lead in delin:
                assert len(delin[lead]) % 3 == 0, 'Unrecognized delineation'
                sidx = record.leads.index(lead)
                beg = ann.time + delin[lead][0]
                end = ann.time + delin[lead][-1]
                obs.shape[lead] = o.QRSShape()
                sig = record.signal[sidx][beg : end + 1]
                obs.shape[lead].sig = sig - sig[0]
                obs.shape[lead].amplitude = np.ptp(sig)
                obs.shape[lead].energy = np.sum(np.diff(sig) ** 2)
                obs.shape[lead].maxslope = np.max(np.abs(np.diff(sig)))
                waves = []
                for i in range(0, len(delin[lead]), 3):
                    wav = Wave()
                    wav.pts = tuple(delin[lead][i : i + 3])
                    wav.move(-delin[lead][0])
                    if wav.r >= len(sig):
                        warnings.warn(
                            'Found delineation information after '
                            'the end of the signal in annotation {0}'.format(ann)
                        )
                        break
                    wav.amp = np.sign(sig[wav.m] - sig[wav.l]) * np.ptp(sig[wav.l : wav.r + 1])
                    wav.e = np.sum(np.diff(sig[wav.l : wav.r + 1]) ** 2)
                    wav.move(delin[lead][0])
                    wav.move(ann.time - obs.earlystart)
                    waves.append(wav)
                if not waves:
                    obs.shape.pop(lead)
                else:
                    obs.shape[lead].waves = tuple(waves)
                    obs.shape[lead].tag = _tag_qrs(waves)
            observations.append(obs)
        elif ann.code is C.RHYTHM and ann.aux in C.RHYTHM_AUX.values():
            rhclazz = next(rh for rh in C.RHYTHM_AUX if C.RHYTHM_AUX[rh] == ann.aux)
            obs = rhclazz()
            obs.start.value = Iv(ann.time, ann.time)
            end = next((a.time for a in anns[i + 1 :] if a.code is C.RHYTHM), anns[-1].time)
            obs.end.value = Iv(end, end)
            observations.append(obs)
        elif ann.code is C.ARFCT:
            obs = o.RDeflection()
            obs.time.value = Iv(ann.time, ann.time)
            observations.append(obs)
    interp.observations = sortedcontainers.SortedList(observations)
    return interp