3

I've got two event streams. One is from an inductance loop, the other is an IP camera. Cars will drive over the loop and then hit the camera. I want to combine them if the events are within N milliseconds of each other (car will always hit the loop first), but I also want the unmatched events from each stream (either hardware can fail) all merged into a single stream. Something like this:

           ---> (only unmatched a's, None)
         /                                  \
stream_a (loop)                              \
         \                                    \
            --> (a, b) ---------------------------> (Maybe a, Maybe b)
         /                                    /
stream_b  (camera)                           /
         \                                  /
            --> (None, only unmatched b's)

Now certainly I can hack my way around by doing the good ole Subject anti-pattern:

unmatched_a = Subject()

def noop():
    pass

pending_as = [[]]

def handle_unmatched(a):
    if a in pending_as[0]:
        pending_as[0].remove(a)
        print("unmatched a!")
        unmatched_a.on_next((a, None))

def handle_a(a):
    pending_as[0].append(a)
    t = threading.Timer(some_timeout, handle_unmatched)
    t.start()
    return a

def handle_b(b):
    if len(pending_as[0]):
        a = pending_as[0].pop(0)
        return (a, b)

    else:
        print("unmatched b!")
        return (None, b)

stream_a.map(handle_a).subscribe(noop)
stream_b.map(handle_b).merge(unmatched_a).subscribe(print)

Not only is this rather hacky, but although I've not observed it I'm pretty sure there's a race condition when I check the pending queue using threading.Timer. Given the plethora of rx operators, I'm pretty sure some combination of them will let you do this without using Subject, but I can't figure it out. How does one accomplish this?

Edit

Although for organizational and operational reasons I'd prefer to stick to Python, I'll take a JavaScript rxjs answer and either port it or even possibly rewrite the entire script in node.

Jared Smith
  • 19,721
  • 5
  • 45
  • 83
  • Did you port it? I ask because rxpy has nothing like `auditTime` which the answer used. – Marc J. Schmidt Feb 04 '20 at 17:08
  • @MarcJ.Schmidt no I ended up using the hack described in the question with Subjects, thread timers, and no-op subscriptions. And the code was ten times as long and three times as complex as it would have been, but ops and I made the decision together to stick with python. The node.js POC described in the accepted answer worked beautifully though. – Jared Smith Feb 06 '20 at 11:56
  • If you see my conversation with Cartant in the comments on the accepted answer, a suggestion was made to simply implement auditTime in Python. I gave myself the better part of a day to try to do it, and my Rx/Python chops were not sufficient to the task. – Jared Smith Feb 06 '20 at 12:04

2 Answers2

2

You should be able to solve the problem using auditTime and buffer. Like this:

function matchWithinTime(a$, b$, N) {
  const merged$ = Rx.Observable.merge(a$, b$);
  // Use auditTime to compose a closing notifier for the buffer.
  const audited$ = merged$.auditTime(N);
  // Buffer emissions within an audit and filter out empty buffers.
  return merged$
    .buffer(audited$)
    .filter(x => x.length > 0);
}

const a$ = new Rx.Subject();
const b$ = new Rx.Subject();
matchWithinTime(a$, b$, 50).subscribe(x => console.log(JSON.stringify(x)));

setTimeout(() => a$.next("a"), 0);
setTimeout(() => b$.next("b"), 0);
setTimeout(() => a$.next("a"), 100);
setTimeout(() => b$.next("b"), 125);
setTimeout(() => a$.next("a"), 200);
setTimeout(() => b$.next("b"), 275);
setTimeout(() => a$.next("a"), 400);
setTimeout(() => b$.next("b"), 425);
setTimeout(() => a$.next("a"), 500);
setTimeout(() => b$.next("b"), 575);
setTimeout(() => b$.next("b"), 700);
setTimeout(() => b$.next("a"), 800);
.as-console-wrapper { max-height: 100% !important; top: 0; }
<script src="https://unpkg.com/rxjs@5/bundles/Rx.min.js"></script>

If it's possible for b values to be closely followed by a values and you do not want them to be matched, you could use a more specific audit, like this:

const audited$ = merged$.audit(x => x === "a" ?
  // If an `a` was received, audit upcoming values for `N` milliseconds.
  Rx.Observable.timer(N) :
  // If a `b` was received, don't audit the upcoming values.
  Rx.Observable.of(0, Rx.Scheduler.asap)
);
cartant
  • 57,105
  • 17
  • 163
  • 197
  • Sorry I should have been more clear. a and b are inputs from an inductance loop and an ip camera respectively. It is entirely possible that either piece of hardware might fail, or that a car drives so slowly that the events are too disjointed in time (i.e. the millisecond timeout) but it has to hit the loop before it gets to the camera so while it might be a then b or just a if b fails or just b if a fails, it can never be b followed by a unless a car is going backwards. – Jared Smith May 18 '18 at 00:28
  • Maybe this is closer to what you need. – cartant May 18 '18 at 00:55
  • That looks promising. I'll give it a go first thing in the morning. Is the `50` an example for the unused `N` parameter? – Jared Smith May 18 '18 at 00:57
  • Yeah. Fixed it. – cartant May 18 '18 at 00:58
  • @Cartant: why do you use `auditTime` instead of a `timer(0, N)` - I see that the results are not the same but I struggle to understand why. – Picci May 18 '18 at 07:24
  • 2
    @Picci I used `auditTime` because it implements the logic that I wanted. The audit starts when the first value is received and ends when the duration has elapsed. Then, the next audit doesn't start until the next value is received - and that's what I wanted. The behaviour is explained in the [docs](http://reactivex.io/rxjs/class/es6/Observable.js~Observable.html#instance-method-auditTime). – cartant May 18 '18 at 11:26
  • @cartant looks like rxpy doesn't have those operators. As far as I can tell from a quick search of a couple of other language reactive repos, they seem to be unique? to rxjs. I said I'd take an rxjs answer, and I will, but I'm wondering if there's a more portable way? Otherwise I'll either have to rewrite my entire application in node (will if I have to) or I'll have to dig through the rxjs source and implement those operators in python (again, will if I have to). – Jared Smith May 18 '18 at 11:59
  • Sorry, should have been more specific. `audit` and `auditTime` are the ones the other reactive extensions don't have. They all have the `buffer` operators. – Jared Smith May 18 '18 at 12:08
  • `auditTime` is a thin wrapper around `audit` which itself is [pretty simple](https://github.com/ReactiveX/rxjs/blob/master/src/internal/operators/audit.ts). I'm not familiar with rxpy, but it should be possible to compose something similar to `audit`. Perhaps that's another question? Anyway, I'm off to bed. – cartant May 18 '18 at 12:21
  • @cartant thanks for the explanation - now it is clear - I did not get the point about when the audit starts - thanks – Picci May 19 '18 at 00:53
1

I have developed a different strategy than Cartant, and clearly much less elegant, which may give you somehow a different result. I apologize if I have not understood the question and if my answer turns out to be useless.

My strategy is based on using switchMap on a$ and then bufferTime on b$.

This code emits at every timeInterval and it emits an object which contains the last a received and an array of bs representing the bs received during the time interval.

a$.pipe(
    switchMap(a => {
        return b$.pipe(
            bufferTime(timeInterval),
            mergeMap(arrayOfB => of({a, arrayOfB})),
        )
    })
)

If arrayOfB is empty, than it means that the last a in unmatched.

If arrayOfB has just one element, than it means that the last a has been matched by the b of the array.

If arrayOfB has more than one element, than it means that the last a has been matched by the first b of the array while all other bs are unmatched.

Now it is a matter of avoiding the emission of the same a more than once and this is where the code gets a bit messy.

In summary, the code could look like the following

const a$ = new Subject();
const b$ = new Subject();

setTimeout(() => a$.next("a1"), 0);
setTimeout(() => b$.next("b1"), 0);
setTimeout(() => a$.next("a2"), 100);
setTimeout(() => b$.next("b2"), 125);
setTimeout(() => a$.next("a3"), 200);
setTimeout(() => b$.next("b3"), 275);
setTimeout(() => a$.next("a4"), 400);
setTimeout(() => b$.next("b4"), 425);
setTimeout(() => b$.next("b4.1"), 435);
setTimeout(() => a$.next("a5"), 500);
setTimeout(() => b$.next("b5"), 575);
setTimeout(() => b$.next("b6"), 700);
setTimeout(() => b$.next("b6.1"), 701);
setTimeout(() => b$.next("b6.2"), 702);
setTimeout(() => a$.next("a6"), 800);


setTimeout(() => a$.complete(), 1000);
setTimeout(() => b$.complete(), 1000);


let currentA;

a$.pipe(
    switchMap(a => {
        currentA = a;
        return b$.pipe(
            bufferTime(50),
            mergeMap(arrayOfB => {
                let aVal = currentA ? currentA : null;
                if (arrayOfB.length === 0) {
                    const ret = of({a: aVal, b: null})
                    currentA = null;
                    return ret;
                }
                if (arrayOfB.length === 1) {
                    const ret = of({a: aVal, b: arrayOfB[0]})
                    currentA = null;
                    return ret;
                }
                const ret = from(arrayOfB)
                            .pipe(
                                map((b, _indexB) => {
                                    aVal = _indexB > 0 ? null : aVal;
                                    return {a: aVal, b}
                                })
                            )
                currentA = null;
                return ret;
            }),
            filter(data => data.a !== null || data.b !== null)
        )
    })
)
.subscribe(console.log);
Picci
  • 16,775
  • 13
  • 70
  • 113
  • Have a look at the edit history of my answer. `a` is not guaranteed to occur before `b`. I think my first edit was closer to yours. I'll come back in a bit to explain what I did. Busy at the moment. – cartant May 18 '18 at 07:29
  • @cartant how much rep do you need to be able to see edit history? But yeah, IIRC this looks a lot like your first attempt. Thanks anyways Picci, have a +1. – Jared Smith May 18 '18 at 11:48
  • @JaredSmith I would have thought it was a pretty low limit and that you guys could see it. Just click on the "edited 8 hours ago" label under my answer. If you expand the first edit, you should see my original answer. – cartant May 18 '18 at 11:51