Skip to content

Commit

Permalink
Attempt to get weight and FTP using alt means
Browse files Browse the repository at this point in the history
Weight is gone for 99% of non-self activities but FTP is discernible for
some.

Also...
 * Add segment score number to segment list
 * Fix placement of segment badges
  • Loading branch information
mayfield committed Oct 24, 2023
1 parent 7ee0901 commit e83f7a0
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 47 deletions.
22 changes: 19 additions & 3 deletions scss/site/analysis.scss
Original file line number Diff line number Diff line change
Expand Up @@ -158,13 +158,17 @@ html.sauce-enabled {
img.sauce-rank {
margin-left: 0.8em;
width: 3.5em;
opacity: 0.9;
opacity: 0.88;
object-fit: contain;
transition: opacity 200ms;

&:hover {
opacity: 1;
}
}

img.sauce-rank:hover {
opacity: 1;
.local-legend-col > .sauce-rank {
margin-left: 0;
}

.sauce-rank-widget {
Expand All @@ -182,6 +186,18 @@ img.sauce-rank:hover {
cursor: pointer;
}

.sauce-segment-score {
font-size: 0.68em;
font-weight: 800;
opacity: 0.5;
line-height: 0.9;
transition: opacity 200ms;
}

tr:hover .sauce-segment-score {
opacity: 1;
}

abbr.unit {
margin-left: 0.12em;
font-size: 0.84em;
Expand Down
55 changes: 53 additions & 2 deletions src/common/lib.js
Original file line number Diff line number Diff line change
Expand Up @@ -98,9 +98,9 @@ sauce.ns('data', function() {


function stddev(data) {
const mean = sauce.data.avg(data);
const mean = avg(data);
const variance = data.map(x => (mean - x) ** 2);
return Math.sqrt(sauce.data.avg(variance));
return Math.sqrt(avg(variance));
}


Expand Down Expand Up @@ -2054,6 +2054,54 @@ sauce.ns('perf', function() {
}


async function fetchAthletePowerData(athleteId, start) {
const q = new URLSearchParams({start, week_span: -1});
const resp = await sauce.fetch(`https://www.strava.com/athletes/${athleteId}/power_data?${q}`);
if (!resp.ok) {
console.warn("Failed to fetch power data:", resp.status, await resp.text());
return;
}
// Guard empty body responses from bad requests that are still 200 OK.
const text = await resp.text();
return text ? JSON.parse(text) : undefined;
}


async function fetchActivityPowerData(activity) {
const resp = await sauce.fetch(`https://www.strava.com/activities/${activity}/power_data`);
if (!resp.ok) {
console.warn("Failed to fetch power data:", resp.status, await resp.text());
return;
}
const data = await resp.json();
return data;
}


function inferPowerDataAthleteInfo(data) {
const info = {};
if (data.cp_data_wkg && data.cp_data) {
info.athlete_weight = sauce.data.avg(data.cp_data.map((x, i) => x.at(-1) / data.cp_data_wkg[i].at(-1)));
}
if (data.range_array) {
const zones = data.range_array.map(x => parseInt(x)); // lower bounds
if (typeof zones[1] === 'number') {
info.athlete_ftp = Math.round(sauce.data.avg([
zones[1] / 0.55,
zones[2] / 0.75,
zones[3] / 0.90,
zones[4] / 1.05,
zones[5] / 1.20,
zones[6] / 1.50,
]));
}
} else if (data.estimated_cp) {
info.athlete_ftp = data.estimated_cp;
}
return info;
}


function calcTRIMP(duration, hrr, gender) {
const y = hrr * (gender === 'female' ? 1.67 : 1.92);
return (duration / 60) * hrr * 0.64 * Math.exp(y);
Expand Down Expand Up @@ -2129,6 +2177,9 @@ sauce.ns('perf', function() {
fetchSelfFTPs,
fetchHRZones,
fetchPaceZones,
fetchAthletePowerData,
fetchActivityPowerData,
inferPowerDataAthleteInfo,
fetchPeerGender,
calcTRIMP,
tTSS,
Expand Down
144 changes: 102 additions & 42 deletions src/site/analysis.js
Original file line number Diff line number Diff line change
Expand Up @@ -1042,9 +1042,6 @@ sauce.ns('analysis', ns => {

const _correctedRolls = new Map();
async function correctedRollTimeRange(stream, wallStartTime, wallEndTime, options) {
// startTime and endTime can be pad based values with corrected power sources.
// Using wall time values and starting with full streams gives us the correct
// padding as the source.
const key = stream;
if (!_correctedRolls.has(key)) {
let fullStream = await fetchStream(stream);
Expand Down Expand Up @@ -1153,8 +1150,8 @@ sauce.ns('analysis', ns => {
{active: true, ignoreZeros: true});
const tempStream = await fetchStreamTimeRange('temp', startTime, endTime);
const distance = streamDelta(streams.distance);
const startIdx = getStreamTimeIndex(wallStartTime);
const endIdx = getStreamTimeIndex(wallEndTime);
const startIdx = getStreamTimeIndex(startTime);
const endIdx = getStreamTimeIndex(endTime);
let gap;
if (ns.activityType === 'run') {
streams.grade_adjusted_distance = streams.distance && await fetchGradeDistStream({startTime, endTime});
Expand Down Expand Up @@ -1869,44 +1866,33 @@ sauce.ns('analysis', ns => {


function addBadge(row) {
if (!ns.weight || row.querySelector(':scope > td.sauce-mark')) {
if (!ns.weight || row.classList.contains('sauce-mark')) {
return;
}
row.classList.add('sauce-mark');
const segment = pageView.segmentEfforts().getEffort(row.dataset.segmentEffortId);
if (!segment) {
console.warn('Segment data not found for:', row.dataset.segmentEffortId);
return;
}
const score = segment.get('score');
if (typeof score === 'number') {
jQuery(row.querySelector(':scope > td.starred-col'))
.append(`<div class="sauce-segment-score" title="Segment popularity score">${score.toLocaleString()}</div>`);
}
const rank = sauce.power.rank(segment.get('elapsed_time_raw'),
segment.get('avg_watts_raw'), null, ns.weight, ns.gender);
if (!rank || !rank.badge) {
return; // Too slow/weak
}
let targetTD;
for (const td of row.querySelectorAll(':scope > td')) {
const unit = td.querySelector('abbr.unit');
if (unit && unit.innerText.toLowerCase() === 'w') {
// This is the highest pref for placement. The TD doesn't have any other indications besides
// the watts unit abbr tag. The title value is translated, so we have to look for this.
targetTD = td;
break;
}
}
if (!targetTD) {
// Use fallback strategy of using a TD column with a real identifier.
targetTD = row.querySelector('.time-col');
}
if (!targetTD) {
throw new Error('Badge Fail: row query selector failed');
}
targetTD.classList.add('sauce-mark');
jQuery(targetTD).html(`
<div class="sauce-rank-holder">
<div>${targetTD.innerHTML}</div>
<img src="${rank.badge}" data-cat="${rank.cat}" class="sauce-rank"
title="${rank.tooltip}"/>
</div>
`);
const targetTD = row.querySelector('.local-legend-col');
const badgeHTML = `<img src="${rank.badge}" data-cat="${rank.cat}" class="sauce-rank" ` +
`title="${rank.tooltip}"/>`;
if (targetTD.innerHTML) {
jQuery(targetTD).html(`<div class="sauce-rank-holder">${targetTD.innerHTML}${badgeHTML}</div>`);
} else {
jQuery(targetTD).html(badgeHTML);
}
}


Expand Down Expand Up @@ -2219,6 +2205,41 @@ sauce.ns('analysis', ns => {
}


let _loadingPowerCtrl = null;
function loadPowerController() {
if (_loadingPowerCtrl !== null) {
return _loadingPowerCtrl;
}
const powerCtrl = (pageView.powerController && pageView.powerController()) ||
(pageView.estPowerController && pageView.estPowerController());
if (!powerCtrl) {
_loadingPowerCtrl = undefined;
} else {
_loadingPowerCtrl = new Promise(resolve => {
powerCtrl.deferred.done(resolve);
powerCtrl.deferred.fail(resolve);
}).then(() => {
if (!powerCtrl.has('athlete_ftp') && !powerCtrl.has('athlete_weight')) {
powerCtrl.set(sauce.perf.inferPowerDataAthleteInfo(powerCtrl.attributes));
}
return powerCtrl;
});
}
return _loadingPowerCtrl;
}


let _loadingAthletePowerData = null;
function loadAthletePowerData(athleteId, ts) {
if (_loadingAthletePowerData !== null) {
return _loadingAthletePowerData;
}
_loadingAthletePowerData = sauce.perf.fetchAthletePowerData(athleteId, ts).then(powerData =>
powerData && sauce.perf.inferPowerDataAthleteInfo(powerData));
return _loadingAthletePowerData;
}


async function getFTPInfo(athleteInfo) {
if (ns.syncAthlete) {
// XXX this is a weak integration for now. Will need complete overhaul...
Expand All @@ -2237,19 +2258,11 @@ sauce.ns('analysis', ns => {
info.ftpOrigin = 'sauce';
} else {
let stravaFtp;
const powerCtrl = pageView.powerController && pageView.powerController();
const powerCtrl = await loadPowerController();
if (powerCtrl) {
try {
await new Promise((resolve, reject) => {
powerCtrl.deferred.done(resolve);
powerCtrl.deferred.fail(reject);
});
stravaFtp = powerCtrl.get('athlete_ftp');
} catch(e) {/*no-pragma*/}
stravaFtp = powerCtrl.get('athlete_ftp');
}
if (stravaFtp == null) {
/* This fallback is for athletes that once had premium, set their FTP, then let
* their subscription pass. It only works for them, but it's a nice to have. */
stravaFtp = pageView.activity().get('ftp');
}
if (stravaFtp) {
Expand All @@ -2274,6 +2287,16 @@ sauce.ns('analysis', ns => {
}


function getActivityValuesViaSimilar(id) {
if (!pageView.similarActivitiesData || !pageView.similarActivitiesData()) {
return;
}
const efforts = pageView.similarActivitiesData().efforts || [];
const match = efforts.find(x => x.activity_id === id);
return match && match.activity_values;
}


async function getWeightInfo(athleteInfo) {
if (ns.syncAthlete) {
// XXX this is a weak integration for now. Will need complete overhaul...
Expand All @@ -2287,11 +2310,48 @@ sauce.ns('analysis', ns => {
}
const info = {};
const override = athleteInfo.weight_override;
const activityId = pageView.activityId();
if (override) {
info.weight = override;
info.weightOrigin = 'sauce';
} else {
const stravaWeight = sauce.stravaAthleteWeight;
let stravaWeight;
const activityData = getActivityValuesViaSimilar(activityId);
if (activityData && activityData.values) {
stravaWeight = activityData.values.athlete_weight;
console.warn("similar activities based good weight:", stravaWeight);
}
if (stravaWeight == null) {
const powerCtrl = await loadPowerController();
if (powerCtrl) {
stravaWeight = powerCtrl.get('athlete_weight');
console.warn("power controller inferred weight:", stravaWeight);
}
}
if (stravaWeight == null) {
stravaWeight = sauce.stravaAthleteWeight;
console.warn("strava reported athlete weight on activity:", stravaWeight);
}
if (stravaWeight == null) {
const data = getActivityValuesViaSimilar(activityId);
if (data && data.values && data.values.athlete_weight) {
stravaWeight = data.values.athlete_weight;
console.warn("similar activity matched weight:", stravaWeight);
}
}
if (stravaWeight == null && ns.athlete.id === pageView.currentAthlete().id) {
let ts = pageView.activity().get('startDateLocal');
if (!ts) {
const data = getActivityValuesViaSimilar(activityId);
ts = data && data.start_date;
debugger; // XXX maybe this makes no sense since I'd expect a match from above? verify
}
const powerData = await loadAthletePowerData(ns.athlete.id, ts || (Date.now() / 1000 | 0));
if (powerData && powerData.athlete_weight) {
stravaWeight = powerData.athlete_weight;
console.warn("athlete power profile inferred weight:", stravaWeight);
}
}
if (stravaWeight) {
info.weight = stravaWeight;
info.weightOrigin = 'strava';
Expand Down

0 comments on commit e83f7a0

Please sign in to comment.