sports-alliance / sports-lib

A Library for processing GPX, TCX, FIT and JSON files from services such as Strava, Movescount, Garmin, Polar etc
GNU Affero General Public License v3.0
149 stars 21 forks source link

Robustness integration tests (2nd pass) #43

Closed thomaschampagne closed 4 years ago

thomaschampagne commented 4 years ago

In the development of my project using your lib. I noticed new small issues which i need to solve to continue my developpments.

Using integrations tests , this PR highlights the following problems:

This PR is failing of course at the moment. Don't merge it until every tests is green :)

jimmykane commented 4 years ago

Alright! Good job.

I am a bit curious about the pause time and the ascent. Is there a big different or this tolerance is about it?

I am asking because for example the TCX,GPX files (not sure if both need to refresh my memory) I think they don't export those values eg ascent / pause time.

Do you think there might be something wrong in the parsing of the files?

thomaschampagne commented 4 years ago
jimmykane commented 4 years ago

Alright. I ll dig in. FYI there is a param for the ascent / descent that can fine tune the algo. Its the threshold for filtering 'climbs' eg how much you need to gain to register ascent.

About the moving time I hope the data are there in the files, meaning a way to determine moving time.

thomaschampagne commented 4 years ago

Same as before. I can dig in too ;) I just raised errors through the tests at the moment.

jimmykane commented 4 years ago

Let me check do the altitude. I am very eager to check this one. :-D

jimmykane commented 4 years ago

I pushed 17cb7dc that should fix the ascent issues. I lowered the threshold of gain/loss mechanism to 4m. Typically 3m can be ideal but I had no data to validate my claim. Lets keep it at 4m (was 5m before) or fine tune this to fit the needs :-D

thomaschampagne commented 4 years ago

Here is the merge with develop. I added the assertions for the test of file: .../fixtures/rides/others_export/2020-01-09_virtualride.fit

As explained in https://github.com/sports-alliance/sports-lib/pull/48, concerning the pause/move time:

From my analysis of strava & garmin platforms, the concept of "is in movement or not" is different for every sports. I mean the speeds thresholds to detect movement is not the same for all sports. I've detected that;

I think this depends of the grade adjusted speed (it should...).

But does the lib really need to go so deep in the pause time analysis (parsing velocity stream etc..)? Since you provide a getDuration & a getPause we may think they are value returned are accurate. It's a debate :). Or this computation should be more on the side of apps...?

jimmykane commented 4 years ago

But does the lib really need to go so deep in the pause time analysis (parsing velocity stream etc..)? Since you provide a getDuration & a getPause we may think they are value returned are accurate. It's a debate :). Or this computation should be more on the side of apps...?

Its a good debate. Ideally the lib should detect the moving time.

Here are 2 things to know in general from my experience with services etc.

GPX -> Not a away to export pauses TCX -> Kinda possible but few services do it (Polar I think does) FIT -> No problem.

Now there is one more thing.

Strava if you pause the device it wont apply moving time calculations. When strava detects a pause event in the FIT file it will not do it.

Proposal:

The thing I have in mind (guidance ) is:

a) duration , pause should be device guided, meaning that it should reflect what the service / app / device has as of values b) Extra stuff like GAP, moving time should be calculated on top , aka not replace duration with moving time. In this way the user / consumer of the lib has / can give the option to use what he likes.

What do you think?

thomaschampagne commented 4 years ago

Thanks for you feedback about duration and pause depending of file formats. I wasn't aware of this.

Perhaps a helper to generate moving time / separately?

Seems to be the right thing to do.

What about GAP/GAS(Speed)? Should that be used for moving time?

We can start without but ideally I think it's neccessary. If you run & climb a slope at low pace we might detect a pause because you are not fast enough. Using GAP, the pace will be faster (equivalent to a pace on flat for the same effort level), then pause will not be detected as expected.

This means we need another thing into the helper: calculate a grade stream and a gap stream. I already mathematically analysed this. It's coded on my side with unit test. I may push this as a lib in the org or in a new helper?

So about your a and b points. You right. Separate things will be more clear :)


FYI Grade/GAPCalculator preview:

import { LowPassFilter } from "@elevate/shared/tools";

export class GradeCalculator {

    public static computeGrade(previousDistance: number, currentDistance: number, previousAltitude: number, currentAltitude: number): number {
        const distanceDelta = currentDistance - previousDistance;
        const altitudeDelta = currentAltitude - previousAltitude;
        if (distanceDelta === 0) {
            return 0;
        }
        let percentage: number = altitudeDelta / distanceDelta * 100;
        percentage = Math.min(Math.max(percentage, -45), 45); // Clamp between -45% & 45%
        return Math.round(percentage * 10) / 10;
    }

    public static computeGradeStream(distanceStream: number[], altitudeStream: number[]): number[] {

        let gradeStream = [];

        for (let i = 0; i < distanceStream.length; i++) {

            const previousDistance = (distanceStream[i - 1]) ? distanceStream[i - 1] : 0;
            const currentDistance = distanceStream[i];
            const previousAltitude = (altitudeStream[i - 1]) ? altitudeStream[i - 1] : altitudeStream[i];
            const currentAltitude = altitudeStream[i];

            const currentGrade = GradeCalculator.computeGrade(previousDistance, currentDistance, previousAltitude, currentAltitude);
            gradeStream.push(currentGrade);
        }

        const lowPassFilter = new LowPassFilter(0.55);
        gradeStream = lowPassFilter.smoothArray(gradeStream);
        gradeStream = lowPassFilter.smoothArray(gradeStream);
        gradeStream.push((gradeStream[gradeStream.length - 1] + gradeStream[gradeStream.length - 2]) / 2); // "Predict last sample before shift"
        gradeStream.push((gradeStream[gradeStream.length - 1] + gradeStream[gradeStream.length - 2]) / 2); // "Predict last sample before shift"
        gradeStream.shift();
        gradeStream.shift();
        return gradeStream;
    }

    /**
     * Contains a 5th order equation which models the Strava GAP behavior
     *
     * This Strava GAP behavior is described by the below data
     * [{ grade: -34, speedFactor: 1.7 }, { grade: -32, speedFactor: 1.6 }, { grade: -30, speedFactor: 1.5 }, { grade: -28, speedFactor: 1.4 }, { grade: -26, speedFactor: 1.3 }, { grade: -24, speedFactor: 1.235 }, { grade: -22, speedFactor: 1.15 }, { grade: -20, speedFactor: 1.09 }, { grade: -18, speedFactor: 1.02 }, { grade: -16, speedFactor: 0.95 }, { grade: -14, speedFactor: 0.91 }, { grade: -12, speedFactor: 0.89 }, { grade: -10, speedFactor: 0.88 }, { grade: -8, speedFactor: 0.88 }, { grade: -6, speedFactor: 0.89 }, { grade: -4, speedFactor: 0.91 }, { grade: -2, speedFactor: 0.95 }, { grade: 0, speedFactor: 1 }, { grade: 2, speedFactor: 1.05 }, { grade: 4, speedFactor: 1.14 }, { grade: 6, speedFactor: 1.24 }, { grade: 8, speedFactor: 1.34 }, { grade: 10, speedFactor: 1.47 }, { grade: 12, speedFactor: 1.5 }, { grade: 14, speedFactor: 1.76 }, { grade: 16, speedFactor: 1.94 }, { grade: 18, speedFactor: 2.11 }, { grade: 20, speedFactor: 2.3 }, { grade: 22, speedFactor: 2.4 }, { grade: 24, speedFactor: 2.48 }, { grade: 26, speedFactor: 2.81 }, { grade: 28, speedFactor: 3 }, { grade: 30, speedFactor: 3.16 }, { grade: 32, speedFactor: 3.31 }, { grade: 34, speedFactor: 3.49 } ]
     *
     * The 5th order equation after curve fitting
     */
    public static estimateAdjustedSpeed(speedMeterSeconds: number, grade: number): number {
        const kA: number = 0.9944001227713231;
        const kB: number = 0.029290920646623777;
        const kC: number = 0.0018083953212790634;
        const kD: number = 4.0662425671715924e-7;
        const kE: number = -3.686186584867523e-7;
        const kF: number = -2.6628107325930747e-9;
        const speedAdjust = (kA + kB * grade + kC * Math.pow(grade, 2) + kD * Math.pow(grade, 3) + kE * Math.pow(grade, 4) + kF * Math.pow(grade, 5));
        return speedMeterSeconds * speedAdjust;
    }
}
jimmykane commented 4 years ago

Hey sorry for the late response. Having troubles on Firebase + the maps thingy hehe.

I you want you can add it to the EventUtilities as a helper, I ll take care on the generation of the stream (where it belongs).

Then I think the next step is to implement a moving time stat via a helper. I am eager to try todo this (the moving time helper) :-D

thomaschampagne commented 4 years ago

Grade Calculator is pushed. To create a grade stream you can use:

public static computeGradeStream(distanceStream: number[], altitudeStream: number[]): number[];

Then, while looping on the speed and grade stream just created, you can get a grade adjusted speed stream using:

public static estimateAdjustedSpeed(speedMeterSeconds: number, grade: number): number;

jimmykane commented 4 years ago

Alright. How do you want to proceed on this? Should I be adding work here? I suppose yes but will take a few... Will need to experiment as it's not an easy task per se. Right?

thomaschampagne commented 4 years ago

I can do the work base if you want into generateMissingStreamsForActivity If possible you will get 2 new DataType & streams: grade & gradeAdjustedSpeed

jimmykane commented 4 years ago

I can do that today. Done here with all the Google issues firebase introduced so hands are free today.

Let me help here.

On Sun, 1 Mar 2020, 15:31 Thomas Champagne, notifications@github.com wrote:

I can do the work base if you want into generateMissingStreamsForActivity If possible you will get 2 new DataType & streams: grade & gradeAdjustedSpeed

— You are receiving this because your review was requested. Reply to this email directly, view it on GitHub https://github.com/sports-alliance/sports-lib/pull/43?email_source=notifications&email_token=AAJVX43EXEGJQYQQJEQGYVLRFJWUHA5CNFSM4KMWHEF2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOENNAWGQ#issuecomment-593103642, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAJVX42HYYMA3T7CGI6GBXDRFJWUHANCNFSM4KMWHEFQ .

jimmykane commented 4 years ago

The reason I am asking is because appart from the normal speed/pace we also need the unit based ones. Perhaps you don't use those but it will take a little for me to make sure all works fine

jimmykane commented 4 years ago

Just one very quick question. I should add grade adjusted speed as well right?

Meaning that pace is not our only target here (I would assume)

jimmykane commented 4 years ago

I am starting to work as you can see in pr #50

Its not trivial.

On the bright side how the lib is structued perhaps it will be trivial to mimic stravas algo. I think...

jimmykane commented 4 years ago

Ok a minor incompatability with GAP

Distance stream from some FIT files:

[0,2,null, null, 3, etc]

Altitude

[150,null, 151,152,etc]
jimmykane commented 4 years ago

@thomaschampagne almost there!

Ok now I do need a little advice / debate.

What should be the Grade be when eg there is no previous altitude?

In regards to

Altitude Stream:

[null,null, 151,152,etc]

Distance stream

[0, 1, 2, 3, etc]
thomaschampagne commented 4 years ago

It's a good debate for your 2 last comments.

Should we fill the null with the prev value?

I agree your suggestion: fill with the last know value. We could also linear interpolate unknown values. Can't say if it's better or not. This is something i already performed in elevate if needed (https://github.com/thomaschampagne/elevate/blob/develop/plugin/core/scripts/processors/split-calculator.ts): it can be reused.

If there is a null at the start / end should we clip both streams to match?

On start, i usually set a value of 0 if first distance was unknown. This make sense IMO since you start to track the distance travelled and at this moment no distance have been travelled yet. First known value for altitude, so the first grade is 0%. For the end, i would say we should use the last known value: sample - 1.

What should be the Grade be when eg there is no previous altitude? Altitude Stream: [null,null, 151,152,etc] Distance stream [0, 1, 2, 3, etc]

Following my previous comment and IMO the Altitude Stream should be: [151,151, 151,152,etc] => Fill with first known value. It make sense with the reals with use cases. It can't be zero. You can't jump from 0 to 151 meters in few seconds :). So this will give 0% grades on first sample. Still make sense

jimmykane commented 4 years ago

Roger that. Commits incoming. I had solved it already like so

On Wed, 4 Mar 2020, 17:44 Thomas Champagne, notifications@github.com wrote:

It's a good debate for your 2 last comments.

Should we fill the null with the prev value?

I agree your suggestion: fill with the last know value. We could also linear interpolate unknown values. Can't say if it's better or not. This is something i already performed in elevate if needed ( https://github.com/thomaschampagne/elevate/blob/develop/plugin/core/scripts/processors/split-calculator.ts): it can be reused.

If there is a null at the start / end should we clip both streams to match?

On start, i usually set a value of 0 if first distance was unknown. This make sense IMO since you start to track the distance travelled and at this moment no distance have been travelled yet. First known value for altitude, so the first grade is 0%. For the end, i would say we should use the last known value: sample - 1.

What should be the Grade be when eg there is no previous altitude? Altitude Stream: [null,null, 151,152,etc] Distance stream [0, 1, 2, 3, etc]

Following my previous comment and IMO the Altitude Stream should be: [ 151,151, 151,152,etc] => Fill with first known value. It make sense with the reals with use cases. It can't be zero. You can't jump from 0 to 151 meters in few seconds :). So this will give 0% grades on first sample. Still make sense

— You are receiving this because your review was requested. Reply to this email directly, view it on GitHub https://github.com/sports-alliance/sports-lib/pull/43?email_source=notifications&email_token=AAJVX46LGXRVROFM4CZYNLDRF2APBA5CNFSM4KMWHEF2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOENY25QQ#issuecomment-594652866, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAJVX4YU6XU7DSBJXDQKNL3RF2APBANCNFSM4KMWHEFQ .

jimmykane commented 4 years ago

I pushed some fixes that should solve the start and end the way you suggested but did not add any interpolation for the rest.

jimmykane commented 4 years ago

Could you please check if the current state of grade calculator doesn't break anything ? For some of my files it produces strange results

For example activities that have asc/desc almost the same usually produce less GAP than the pace.

I can send some files if needed

jimmykane commented 4 years ago

Here is an example

Screenshot 2020-03-05 at 08 13 06

The altitude only has small variations but the GAP / Grade has too much noise I think. I am assuming this is due to non linear interpolation

https://drive.google.com/file/d/1jcbkVCXqifUAHG-QOuPZ63Ub_5j1Ob5Z/view?usp=sharing

Above the FIT file.

Would be great if we could check this on your side if it produces the same issues

jimmykane commented 4 years ago

I ll be reverting my changes , they don't seem to work well :-D

thomaschampagne commented 4 years ago

Indeed there's a problem. Strange results :/

I perform checks this evening on this branch.

Note: the grade stream in my calculator receives 2 low pass filtering to smooth the data. The major difference we could get is that i used directly distance and altitude data stream from strava to build it :/ My grade calculated stream matched point-to-point with the one after computation. But their streams especially the altitude one is probably already interpolated, smoothed, etc...

Your graph seems interesting to debug data. It's your app i think? How can i get same including the new streams representation for my tests/fixes?

jimmykane commented 4 years ago

I can easily help with providing a debug view / beta on this no worries :-)

Have free days of work this week so 100% on this.

jimmykane commented 4 years ago

For the info this is the GAP via the equivelent processed Strava GPX

Screenshot 2020-03-05 at 09 56 11

The AVG pace is 1:1 match with Strava but still some smoothing could be done I think.

Notice that the scale for Grade is smaller , even if there is still some noise.

I have also a weighted AVG filter if you are interested I could first pass the data to there.

jimmykane commented 4 years ago

Ok I think I spotted the issue. The issue is on the Grade Calc. I checked both Strava API and the file for what Strava sets as altitude and distance.

Will open a PR to this branch perhaps with some extra findings.

thomaschampagne commented 4 years ago

You right from the comparison of your 2 last graphs: Problem is my grade calculator with other data source than strava streams :/.

Thanks for spotting this!

The grade adjusted pace calculation should be ok after this. The grade calculator modelisation (the 5 order equation) follows properly the strava modelisation below (orange line with orange dots)

image

I can re-investigate this grade problem this weekend. On my side i use https://chart-studio.plot.ly/create/#/ to compare streams. It's less efficient than your solution but it works.

So my code doing this in elevate is wrong too :/ Btw my goal is to use sports-lib streams as more as possible if exists. It will switch the streams when ok ;).

jimmykane commented 4 years ago

I think I am on the right track with the grade issue. We might not be able to match strava 1:1 for the smooth grade they provide but at least offer something quite close to.

On Thu, 5 Mar 2020, 16:06 Thomas Champagne, notifications@github.com wrote:

You right from the comparison of your 2 last graphs: Problem is my grade calculator with other data source than strava streams :/.

Thanks for spotting this!

The grade adjusted pace calculation should be ok after this. My grade calculator modelisation follows properly the strava modelisation below:

[image: image] https://user-images.githubusercontent.com/151973/75993603-065fd480-5efa-11ea-8d17-a91d6f45d5e0.png

I can re-investigate this grade problem this weekend. On my side i use https://chart-studio.plot.ly/create/#/ to compare streams. It's less efficient than your solution but it works.

So my code doing this in elevate is wrong too :/ Btw my goal is to use sports-lib streams as more as possible if exists. It will switch the streams when ok ;).

— You are receiving this because your review was requested. Reply to this email directly, view it on GitHub https://github.com/sports-alliance/sports-lib/pull/43?email_source=notifications&email_token=AAJVX47HUBTD44I7VPAOB5LRF65YBA5CNFSM4KMWHEF2YY3PNVWWK3TUL52HS4DFVREXG43VMVBW63LNMVXHJKTDN5WW2ZLOORPWSZGOEN5TM6Y#issuecomment-595277435, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAJVX4YIUICDOBVZIF4SYUTRF65YBANCNFSM4KMWHEFQ .

jimmykane commented 4 years ago

Check https://github.com/sports-alliance/sports-lib/pull/52

I have added some tests. Would be great if you add as well.

I have enabled the calculation of grade + meta and added some tests via:

jimmykane commented 4 years ago

Leaving this here as it's important finding.

For the moving time Strava looks at the GNSS speed aka speed deriving from lat,long

There is a geolib adapter but let wait. to merge #52

jimmykane commented 4 years ago

@thomaschampagne if you could look a bit at the tests that would be great !

thomaschampagne commented 4 years ago

@thomaschampagne if you could look a bit at the tests that would be great !

Yes i will look at them. I try tomorrow evening depending of the new main work.

jimmykane commented 4 years ago

I am going todo my next production release (that uses this lib) with this branch. So no worries take your time.

There are

jimmykane commented 4 years ago

@thomaschampagne I think our moving time calc has a little flaw.

when doing the time delta it adds extra moving time in cases that there is a big distance between the speed samples.

For exmple.

Speed 5ms/ at time 5s -> Moving time 5s Speed 10m/s at time 10s -> Moving time 10s Speed 10m/s at time 20s -> Moving time 20s .... Lets ommit some samples (no speed recorded) or something else

Speed 10m/s at time 300s -> moving time 280s -> I don't think that is correct right ?

jimmykane commented 4 years ago

Ok I think I got the logic we could use.

Instead of detecting the delta between speed samples that qualify or not we can:

jimmykane commented 4 years ago

@thomaschampagne all tests pass now

I added the above algo and it matched strava with just a tiny tweak at the threholds.

codecov-io commented 4 years ago

Codecov Report

Merging #43 into develop will not change coverage by %. The diff coverage is n/a.

Impacted file tree graph

@@           Coverage Diff            @@
##           develop      #43   +/-   ##
========================================
  Coverage    78.56%   78.56%           
========================================
  Files          194      194           
  Lines         5084     5084           
  Branches       782      782           
========================================
  Hits          3994     3994           
  Misses        1067     1067           
  Partials        23       23           

Continue to review full report at Codecov.

Legend - Click here to learn more Δ = absolute <relative> (impact), ø = not affected, ? = missing data Powered by Codecov. Last update aa3e3ed...aa3e3ed. Read the comment docs.

jimmykane commented 4 years ago

@thomaschampagne I think that those files with the ascent issue just use to much filtering on the ASCENT (Not the altitude) by the device it self and not from services. And since those are GPX files they don't export the information (ASCENT)

I took the GPX of 20190929_ride_4108490848.gpx

Loaded it to the parser and these are the results :

Original data with various filters

Screenshot 2020-03-28 at 17 34 23

Corrected by DEB

Screenshot 2020-03-28 at 17 37 29

As you can see any algo will report more and only basically heavy filtering with Digital Database Elevation will come close to how the device it self corrected the ascent.

A fit file would have gotten the correct ascent as the device would have exported it.

Now, this is a device specific scope, aka if a device has erratic data the manifacturer applies a filter.

For example for Suunto, the non barometric devices filter on +-7m and the barometric devices on -+3m. For Polar I think it's 10m and the same for Garmin for non baro watches from a few devices I have tested.

Now this is impossible to solve for all.

You can increase the filter but then the other ascents will be small.

Perhaps a solution (if you care so much about GPX files that are become less and less used), would be to use a Gain parameter when importing a GPX activity etc.

Perhaps an importer config object?

Another solution but that is app wise, would be to allow the user to use his own filter there. That is what I do on my app for example.

What do you think

thomaschampagne commented 4 years ago

@jimmykane Thanks for the deep note. It seems you have more awareness than me on this subject.

If i understand well, I'm wrong when i apply kalman+lowpass on altitude stream before my computations and without considering the source (strava, fit, tcx, gpx)?

About GPX, I care about them because of Strava :/. Indeed my users may export their Strava history to import it into my app (using the "file system connector" which use sports-lib). The zip exported from Strava includes fit, tcx, and gpx files. On my 620 activities exported from Strava i have 104 gpx files :(. When i asked my users to provide me some files to perform tests on my side, most of them exported me their Strava history as a zip. That's why i care about GPX.

Another solution but that is app wise, would be to allow the user to use his own filter there. That is what I do on my app for example.

So you apply a filter (which one?) depending on the source type (fit, tcx, gpx)?

Which software are you using on your screenshots :) ? Looks cool !

jimmykane commented 4 years ago

The screenshot is from Runanalyze :-D I use it to check DEB for the ascent data in similar debates :-D

I don't say it's wrong to apply the filter and then present the eg altitude to the user but rather than for computations it adds non determenistic noise. In some cases eg Grade should work well but for example you would not want to apply that on speed and later calculate the average for example. That would give you a wrong average value, because the speed stream would have been filtered.

However, perhaps for the ascent on a device that produces erratic data could add value (or if the GPS reception was bad). Think about it as track smoothing. It really depends. Sometimes can be wrong, sometimes no.

I personally dont treat files differently. I just allow the user to "educate" him self why this is happening.

But for example Runanalyze does apply a filter an correction, states that on the altitude data, and gives the user the option to remove that smoothing.

In this case that means that they still store the the original data for "reference"

That makes me think that we could do this.

My main suggestion is to avoid storing data filtered that cannot be recovered. Grade for example that at the moment is filtered can be recovered from the altitude/distance streams. If you had stored the altitude already filtered, then a second grade computation would filter the filtred altitude again producing eventauly a wrong grade. Get my point here?

thomaschampagne commented 4 years ago

Yes i got it. Indeed it make sense. So i store the altitude filtered on my side :/ I have to brainstorm again :)

We could create a filtered altitude stream / (more streams?) and an API to get that.

Right. It could not be on demand? When we ask for it?

jimmykane commented 4 years ago

I created https://github.com/sports-alliance/sports-lib/issues/55 that should help with this.

Will try to implement this today

jimmykane commented 4 years ago

@thomaschampagne I added a filtering ability.

you should be able to add a filter to a stream and get the data all cross the engine filtered.

jimmykane commented 4 years ago

You can use it like

stream.addFilter(new LowPassStreamFilter())
thomaschampagne commented 4 years ago

@jimmykane Perfect :) !

So it seems this PR is mergeable no?

jimmykane commented 4 years ago

I think yes, and we can continue creating issues etc if needed :-) I have already deployed it to production for me and looks quite fine!

On Tue, Mar 31, 2020 at 6:53 PM Thomas Champagne notifications@github.com wrote:

@jimmykane https://github.com/jimmykane Perfect :) !

So it seems this PR is mergeable no?

— You are receiving this because you were mentioned. Reply to this email directly, view it on GitHub https://github.com/sports-alliance/sports-lib/pull/43#issuecomment-606747883, or unsubscribe https://github.com/notifications/unsubscribe-auth/AAJVX4ZIC7UGAQPE3M64DELRKINY7ANCNFSM4KMWHEFQ .