Custom activity streams with Javascript

Custom activity streams can now be computed using Javascript. You can access other streams, fields on the activity or athlete, wellness data and anything else needed. Click “Charts” under the activity timeline chart, then choose “Custom Streams”:

Note that you need to enter a value for units.

Here is a sample script to calculate power / HR (note that is one is built in so you don’t need this script):

{
  let heartrate = icu.streams.fixed_heartrate
  let watts = icu.streams.fixed_watts
  for (let i = 0; i < data.length; i++) data[i] = watts[i] / heartrate[i]
}

The script is given a data object to hold the stream data and an icu object for access to the athlete, activity, streams etc…

These scripts are recomputed when the activity is re-analysed so they do not have access to the fit file (if any). The stream can be referenced in computed activity fields, added to custom activity trace charts and so on.

If you need to get a record field from the fit file you don’t have to write any code:

If you need to do other stuff with fit file messages please see this post:

10 Likes

I created a chart to track P/HRR with the following code:
{
let mhr = 201
let rhr = 57
let hr = icu.streams.fixed_heartrate
let power = icu.streams.fixed_watts
for (let i = 0; i < data.length; i++) data[i] = power[i] / (((hr[i] - rhr) / (mhr - rhr))*100)
}

Can you help me with accessing the maxHR and restHR? How do I get those variables ?
I also tried to re-use the same code for an interval field, but couldn’t get it to work there. Does it work the same ?

Sure you can get those from the activity:

  let mhr = activity.athlete_max_hr
  let rhr = activity.icu_resting_hr

For an interval field you can calculate the average like this:

{
  let mhr = activity.athlete_max_hr
  let rhr = activity.icu_resting_hr
  let hr = icu.streams.fixed_heartrate
  let power = icu.streams.fixed_watts
  let tot = 0, c = 0
  for (let i = interval.start_index; i < interval.end_index; i++) { 
    let v = power[i] / (((hr[i] - rhr) / (mhr - rhr))*100) 
    if (v) {
      tot += v
      ++c
    }
  }
  c ? tot/c : null
}
2 Likes

This is great, how is it not getting more traction?
I ownder if there is a guide somewhere to how the individual streams are called. I would like to build, for example, an estimated power on climbs from VAM and compare it to the actual power.

Tx. The stream types are listed here: Server side data model for scripts

I pulled all the extension posts into a guide. I hope to edit that to provide more of an overview soon. But it is always “do I write some more docs” vs “implement new features” :slight_smile:

1 Like

I just managed to do exactly what I’ve been looking to do for a long time, thanks again @david for the feature. The integration with the custom charts is amazing!

I implemented the classic VAM algorithm to double-check the power meter on climbs and/or get indication of drafting/winds:
Relative power (watts/kg) = VAM (metres/hour) / (200 + 10 × % grade) [1]
Indeed, it works, and here is the result from a recent climb:


Note how it is fairly accurate on climbs but the real power is far greater than the calculated power on the flats!

Two quick questions:

  • how do I get the athlete weight? Right now I hard-coded it, but maybe this field could be shared so it would make sense to parametrize it.
  • how do I reprocess activities without losing the interval anotations? Sure this was asked before but cannot fnd it.
1 Like

Should be icu.activity.icu_weight according to Server side data model for scripts.

Oops… just activity.icu_weight.

2 Likes

Cool. Thats neat!

You can get the athlete’s weight at the time of the activity from activity.icu_weight (in kg).

If your custom stream is calculated from other streams (and the activity etc.) and does not need to access the fit file then doing Actions → Re-analyse will recompute it. That will let you keep the intervals.

I will sort out reprocess file losing the intervals. Been meaning to do that.

1 Like

@david I see now (and from your earlier replies to this topic, duh) that there’s an activity object directly available for the script. I assumed you had to fetch them from the icu object like you do for streams. It’s not super explicit from the original announcement:

Are any other fields in ActivityJsData also brought into the script scope? Or is this activity object something else?

It is the same activity object. When I started this there wasn’t an ‘icu’ object but then I realised that was a better approach because it allows lazy loading of requested stuff. Future things will all be on the icu object.

Both of those will work fine. It is the same activity object.

You can now choose to keep intervals when re-processing an activity file both for Actions → Re-process and when bulk editing from the activity list view:

4 Likes

is VAM a stream? or do i need to do the trigonometry? I don’t see it listed in the streams documentation.

VAM is a stream but it is computed client side. If you search for “VAM” in the custom streams search dialog Kosio Varbenov’s calculated power comes up.

Gave Di2/eTap/EPS streams a shot. I have something that works for me and in theory it should be universal, but if it doesn’t work for you, try and share a FIT file and I might give it a look. No promises though. Search for “Di2” in custom streams and they should all show up.

This only works if FIT is available, since it needs the FIT gear_change_event messages.

I have written/tested this with data from Wahoo+Di2, so it’s entirely possible there are nuances I missed with other products.

I also made a Chart that combines front and rear gear:

image

Here’s the code for one of them:

{
    function cmp(a, b) {
        return a.timestamp.value - b.timestamp.value;
    }

    let gear_event_names = ["FRONT_GEAR_CHANGE", "REAR_GEAR_CHANGE"];
    let change_events = [];
    let start_timestamp;
    for (let m = 0; m < icu.fit.length; m++) {
        let r = icu.fit[m];
        if (r.event == null) continue;
        if (gear_event_names.includes(r.event.valueName)) {
            change_events.push(r);
        } else if (r.event.valueName === "TIMER" && r.event_type.valueName === "START") {
            start_timestamp = start_timestamp ? Math.min(start_timestamp, r.timestamp.value) : r.timestamp.value;
        }
    }

    change_events.sort(cmp); // Probably unnecessary?

    let value = undefined;
    for (let x=0; x < icu.streams.time.length; x++) {
        let t = icu.streams.time[x];
        while (change_events.length > 0 && t >= change_events[0].timestamp.value - start_timestamp) {
            let next_event = change_events.shift();
            value = next_event.front_gear.value;
            if (Array.isArray(value)) {
                // I am not sure why, but sometimes the value is an array with the same value twice?
                value = value[0];
            }
        }
        data[x] = value;
    }
}

The only thing different between the charts is the line starting with value = , which either get front_gear, front_gear_num, rear_gear or rear_gear_num.

I imagine this could be used as the basis for some maybe-useful charts as well. I hope someone grabs the code and makes something cool with it :slight_smile:

4 Likes

This is cool. Thanks!

You probably don’t need to do that. The fit messages are provided in order.

I figured that was probably the case, but didn’t know how many assumptions I could make. In that case I can probably get away without collecting the events at all. I have very little experience with FIT files. I also just noticed the setAt helper, which should also save some trouble - shouldn’t need to deal with the timer start event.

Super work, I only had a brief look but this is ace. Will try find time at the weekend to see what else we can achieve.

After looking at documentation again, I have been able to simplify the code a fair bit. There should be no changes to the output, but it’s probably a better basis for further development.

{
    const GEAR_EVENTS = ["FRONT_GEAR_CHANGE", "REAR_GEAR_CHANGE"];
    let rear_gear = undefined;
    let rear_gear_num = undefined;
    let front_gear = undefined;
    let front_gear_num = undefined;
    let fit_idx = 0;

    function fixValue(value) {
        // I am not sure why, but sometimes the value is an array with the same value twice?
        value = Array.isArray(value) ? value[0] : value;
        // If value is 0, make it undefined instead
        value = value === 0 ? undefined : value;
        return value;
    }

    /*
    for (let x=0; x < icu.fit.length; x++) {
        let r = icu.fit[x];
        if (GEAR_EVENTS.includes(r?.event?.valueName)) {
            console.log("Change at", r.timestamp.value - data.startTimestamp, "Front", r.front_gear, "Rear", r.rear_gear);
        }
    }
    */

    for (let i=0; i < data.length; i++) {
        let data_time = data.time[i];
        while (fit_idx < icu.fit.length) {
            let msg = icu.fit[fit_idx];
            // Not all messages have timestamps so this becomes NaN, which happens to work in our favour.
            let msg_elapsed = msg.timestamp?.value - data.startTimestamp;
            if (msg_elapsed > data_time) {
                // Continue to next data point if we reach a message for a later timestamp
                break;
            }
            if (GEAR_EVENTS.includes(msg?.event?.valueName)) {
                rear_gear      = fixValue(msg.rear_gear?.value);
                rear_gear_num  = fixValue(msg.rear_gear_num?.value);
                front_gear     = fixValue(msg.front_gear?.value);
                front_gear_num = fixValue(msg.front_gear_num?.value);
            }
            fit_idx++;
        }

        data[i] = rear_gear;
    }
}

Occasionally hitting some Memory limit exceeded errors on longer activities - not sure what I can do to help that at this point, if anything.

I made your code a little shorter. Think it still produces the same output. Could you please post a link to an activity where you get out of memory errors. That shouldn’t happen with this script.

{
  let rear_gear, front_gear

  function fixValue(value) {
    // I am not sure why, but sometimes the value is an array with the same value twice?
    value = Array.isArray(value) ? value[0] : value
    return value === 0 ? null : value
  }

  for (let m of icu.fit) {
    switch (m.event?.valueName) {
      case "REAR_GEAR_CHANGE":
      case "FRONT_GEAR_CHANGE":
        rear_gear      = fixValue(m.rear_gear?.value)
        front_gear     = fixValue(m.front_gear?.value)
    }
    let ts = m.timestamp
    if (ts) data.setAt(ts.value, rear_gear)
  }
}