Implement FreeRide intervals

This commit is contained in:
Rene Saarsoo 2020-09-30 23:25:22 +03:00
parent eef96cce92
commit ac126f36da
11 changed files with 243 additions and 28 deletions

View File

@ -2,7 +2,6 @@
## TODO ## TODO
- FreeRide blocks
- Repeats (and nested repeats) - Repeats (and nested repeats)
- Unsupported params: message duration & y-position - Unsupported params: message duration & y-position
- More restricted syntax for text (with quotes) - More restricted syntax for text (with quotes)

41
examples/ftp-test.txt Normal file
View File

@ -0,0 +1,41 @@
Author: Zwift
Name: FTP Test (shorter)
Description:
The short variation of the standard FTP test starts off with a short warmup,
a quick leg opening ramp, and a 5 minute hard effort to get the legs pumping.
After a brief rest it's time to give it your all and go as hard as you can for 20 solid minutes.
Pace yourself and try to go as hard as you can sustain for the entire 20 minutes -
you will be scored on the final 20 minute segment.
Upon saving your ride, you will be notified if your FTP improved.
Warmup: 5:00 30%..70%
@ 00:20 Welcome to the FTP test
@ 00:35 Time to get warmed up and get your cadence up to 90-100rpm
Interval: 00:20 90%
Interval: 00:20 110%
Interval: 00:20 130%
Interval: 03:00 60%
Interval: 03:00 110%
Interval: 02:00 120%
Rest: 06:00 55%
@ 00:10 In 6 minutes the FTP test begins
@ 05:00 Prepare for your max 20 minute effort
FreeRide: 20:00
@ 00:10 Bring your power up to what you think you can hold for 20 minutes
@ 05:00 How ya doin?
@ 10:00 Half way done! If it's feeling easy now might be time to add 10 watts.
@ 15:00 5 minutes left!
@ 16:00 4 minutes left. Increase your power if you're feeling strong.
@ 17:00 3 minutes left
@ 18:00 2 minutes left
@ 19:00 1 minute left. Go all in!
@ 19:30 30 sec. Go for it.
@ 19:50 10 seconds.
Cooldown: 5:00 50%..30%

42
examples/ftp-test.zwo Normal file
View File

@ -0,0 +1,42 @@
<workout_file>
<author>Zwift</author>
<name>FTP Test (shorter)</name>
<description>The short variation of the standard FTP test starts off with a short warmup, a quick leg opening ramp, and a 5 minute hard effort to get the legs pumping. After a brief rest it's time to give it your all and go as hard as you can for 20 solid minutes. Pace yourself and try to go as hard as you can sustain for the entire 20 minutes - you will be scored on the final 20 minute segment.
Upon saving your ride, you will be notified if your FTP improved.</description>
<sportType>bike</sportType>
<tags/>
<workout>
<Warmup Duration="300" PowerLow="0.30000001" PowerHigh="0.69999999">
<textevent timeoffset="20" message="Welcome to the FTP test"/>
<textevent timeoffset="35" message="Time to get warmed up and"/>
<textevent timeoffset="35" message="get your cadence up to 90-100rpm" y="270"/>
</Warmup>
<SteadyState Duration="20" Power="0.89999998"/>
<SteadyState Duration="20" Power="1.1"/>
<SteadyState Duration="20" Power="1.3"/>
<SteadyState Duration="180.00002" Power="0.60000002"/>
<SteadyState Duration="180.00002" Power="1.1"/>
<SteadyState Duration="120.00001" Power="1.2"/>
<SteadyState Duration="360.00003" Power="0.55000001">
<textevent timeoffset="10" message="In 6 minutes the FTP test begins"/>
<textevent timeoffset="580" message="Prepare for your max 20 minute effort" duration="19"/>
</SteadyState>
<FreeRide Duration="1200" FlatRoad="1">
<textevent timeoffset="10" message="Bring your power up to what you think" duration="20"/>
<textevent timeoffset="10" message="you can hold for 20 minutes" duration="20" y="270"/>
<textevent timeoffset="300" message="How ya doin?"/>
<textevent timeoffset="600" message="Half way done! If it&apos;s feeling easy"/>
<textevent timeoffset="600" message="now might be time to add 10 watts." y="270"/>
<textevent timeoffset="900.00006" message="5 minutes left!"/>
<textevent timeoffset="960.00006" message="4 minutes left. Increase your"/>
<textevent timeoffset="960.00006" message="power if you&apos;re feeling strong." y="270"/>
<textevent timeoffset="1020.0001" message="3 minutes left"/>
<textevent timeoffset="1080" message="2 minutes left"/>
<textevent timeoffset="1140" message="1 minute left. Go all in!"/>
<textevent timeoffset="1170" message="30 sec. Go for it."/>
<textevent timeoffset="1190" message="10 seconds."/>
</FreeRide>
<Cooldown Duration="300" PowerLow="0.5" PowerHigh="0.30000001"/>
</workout>
</workout_file>

View File

@ -29,3 +29,17 @@ export class IntensityRange {
return this._end; return this._end;
} }
} }
export class FreeIntensity {
get value() {
return 0;
}
get start() {
return 0;
}
get end() {
return 0;
}
}

View File

@ -1,6 +1,6 @@
import { IntervalType } from "./parser/tokenizer"; import { IntervalType } from "./parser/tokenizer";
import { Duration } from "./Duration"; import { Duration } from "./Duration";
import { Intensity, IntensityRange } from "./Intensity"; import { FreeIntensity, Intensity, IntensityRange } from "./Intensity";
export type Workout = { export type Workout = {
name: string; name: string;
@ -12,7 +12,7 @@ export type Workout = {
export type Interval = { export type Interval = {
type: IntervalType; type: IntervalType;
duration: Duration; duration: Duration;
intensity: Intensity | IntensityRange; intensity: Intensity | IntensityRange | FreeIntensity;
cadence?: number; cadence?: number;
comments: Comment[]; comments: Comment[];
}; };

View File

@ -1,6 +1,7 @@
import * as xml from "xml"; import * as xml from "xml";
import { Interval, Workout, Comment } from "./ast"; import { Interval, Workout, Comment } from "./ast";
import { detectRepeats, RepeatedInterval } from "./detectRepeats"; import { detectRepeats, RepeatedInterval } from "./detectRepeats";
import { FreeIntensity } from "./Intensity";
// Zwift Workout XML generator // Zwift Workout XML generator
@ -44,6 +45,19 @@ const generateSteadyStateInterval = ({ duration, intensity, cadence, comments }:
}; };
}; };
const generateFreeRideInterval = ({ duration, comments }: Interval): xml.XmlObject => {
return {
FreeRide: [
{
_attr: {
Duration: duration.seconds,
},
},
...generateTextEvents(comments),
],
};
};
const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject => { const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject => {
const [on, off] = repInterval.intervals; const [on, off] = repInterval.intervals;
return { return {
@ -76,6 +90,8 @@ const generateInterval = (interval: Interval | RepeatedInterval): xml.XmlObject
return generateRangeInterval("Warmup", interval); return generateRangeInterval("Warmup", interval);
} else if (intensity.start > intensity.end) { } else if (intensity.start > intensity.end) {
return generateRangeInterval("Cooldown", interval); return generateRangeInterval("Cooldown", interval);
} else if (intensity instanceof FreeIntensity) {
return generateFreeRideInterval(interval);
} else { } else {
return generateSteadyStateInterval(interval); return generateSteadyStateInterval(interval);
} }

View File

@ -207,6 +207,28 @@ Cooldown: 5:30 70%..45%
`); `);
}); });
it("parses free-ride intervals", () => {
expect(
parse(`
Name: My Workout
FreeRide: 5:00
`).intervals,
).toMatchInlineSnapshot(`
Array [
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": FreeIntensity {},
"type": "FreeRide",
},
]
`);
});
const parseInterval = (interval: string) => parse(`Name: My Workout\n${interval}`).intervals[0]; const parseInterval = (interval: string) => parse(`Name: My Workout\n${interval}`).intervals[0];
it("requires duration and power parameters to be specified", () => { it("requires duration and power parameters to be specified", () => {

View File

@ -1,8 +1,8 @@
import { Interval, Workout, Comment } from "../ast"; import { Interval, Workout, Comment } from "../ast";
import { Duration } from "../Duration"; import { Duration } from "../Duration";
import { Intensity, IntensityRange } from "../Intensity"; import { FreeIntensity, Intensity, IntensityRange } from "../Intensity";
import { ParseError } from "./ParseError"; import { ParseError } from "./ParseError";
import { SourceLocation, Token } from "./tokenizer"; import { IntervalType, SourceLocation, Token } from "./tokenizer";
type Header = Partial<Omit<Workout, "intervals">>; type Header = Partial<Omit<Workout, "intervals">>;
@ -78,41 +78,44 @@ const parseIntervalComments = (tokens: Token[]): [Comment[], Token[]] => {
return [comments, tokens]; return [comments, tokens];
}; };
type IntervalData = Omit<Interval, "type">; const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => {
let duration;
const parseIntervalParams = (tokens: Token[], loc: SourceLocation): [IntervalData, Token[]] => { let cadence;
const data: Partial<IntervalData> = {}; let intensity;
while (tokens[0]) { while (tokens[0]) {
const token = tokens[0]; const token = tokens[0];
if (token.type === "duration") { if (token.type === "duration") {
data.duration = new Duration(token.value); duration = new Duration(token.value);
tokens.shift(); tokens.shift();
} else if (token.type === "cadence") { } else if (token.type === "cadence") {
data.cadence = token.value; cadence = token.value;
tokens.shift(); tokens.shift();
} else if (token.type === "intensity") { } else if (token.type === "intensity") {
data.intensity = new Intensity(token.value); intensity = new Intensity(token.value);
tokens.shift(); tokens.shift();
} else if (token.type === "intensity-range") { } else if (token.type === "intensity-range") {
data.intensity = new IntensityRange(token.value[0], token.value[1]); intensity = new IntensityRange(token.value[0], token.value[1]);
tokens.shift(); tokens.shift();
} else { } else {
break; break;
} }
} }
if (!("duration" in data)) { if (!duration) {
throw new ParseError("Duration not specified", loc); throw new ParseError("Duration not specified", loc);
} }
if (!("intensity" in data)) { if (!intensity) {
throw new ParseError("Power not specified", loc); if (type === "FreeRide") {
intensity = new FreeIntensity();
} else {
throw new ParseError("Power not specified", loc);
}
} }
const [comments, rest] = parseIntervalComments(tokens); const [comments, rest] = parseIntervalComments(tokens);
data.comments = comments;
return [data as IntervalData, rest]; return [{ type, duration, intensity, cadence, comments }, rest];
}; };
const parseIntervals = (tokens: Token[]): Interval[] => { const parseIntervals = (tokens: Token[]): Interval[] => {
@ -121,14 +124,8 @@ const parseIntervals = (tokens: Token[]): Interval[] => {
while (tokens[0]) { while (tokens[0]) {
const token = tokens.shift() as Token; const token = tokens.shift() as Token;
if (token.type === "interval") { if (token.type === "interval") {
const [{ duration, intensity, cadence, comments }, rest] = parseIntervalParams(tokens, token.loc); const [interval, rest] = parseIntervalParams(token.value, tokens, token.loc);
intervals.push({ intervals.push(interval);
type: token.value,
duration,
intensity,
cadence,
comments,
});
tokens = rest; tokens = rest;
} else { } else {
throw new ParseError(`Unexpected token ${tokenToString(token)}`, token.loc); throw new ParseError(`Unexpected token ${tokenToString(token)}`, token.loc);

View File

@ -1,13 +1,13 @@
import { ParseError } from "./ParseError"; import { ParseError } from "./ParseError";
export type HeaderType = "Name" | "Author" | "Description"; export type HeaderType = "Name" | "Author" | "Description";
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown"; export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide";
const isHeaderType = (value: string): value is HeaderType => { const isHeaderType = (value: string): value is HeaderType => {
return ["Name", "Author", "Description"].includes(value); return ["Name", "Author", "Description"].includes(value);
}; };
const isIntervalType = (value: string): value is IntervalType => { const isIntervalType = (value: string): value is IntervalType => {
return ["Warmup", "Rest", "Interval", "Cooldown"].includes(value); return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide"].includes(value);
}; };
// 0-based row and column indexes. First line is 0th. // 0-based row and column indexes. First line is 0th.

View File

@ -78,6 +78,69 @@ exports[`Generate ZWO examples/darth-vader.txt 1`] = `
</workout_file>" </workout_file>"
`; `;
exports[`Generate ZWO examples/ftp-test.txt 1`] = `
"<workout_file>
<name>FTP Test (shorter)</name>
<author>Zwift</author>
<description>The short variation of the standard FTP test starts off with a short warmup,
a quick leg opening ramp, and a 5 minute hard effort to get the legs pumping.
After a brief rest it&apos;s time to give it your all and go as hard as you can for 20 solid minutes.
Pace yourself and try to go as hard as you can sustain for the entire 20 minutes -
you will be scored on the final 20 minute segment.
Upon saving your ride, you will be notified if your FTP improved.</description>
<sportType>bike</sportType>
<Warmup Duration=\\"300\\" PowerLow=\\"0.3\\" PowerHigh=\\"0.7\\">
<textevent timeoffset=\\"20\\" message=\\"Welcome to the FTP test\\">
</textevent>
<textevent timeoffset=\\"35\\" message=\\"Time to get warmed up and get your cadence up to 90-100rpm\\">
</textevent>
</Warmup>
<SteadyState Duration=\\"20\\" Power=\\"0.9\\">
</SteadyState>
<SteadyState Duration=\\"20\\" Power=\\"1.1\\">
</SteadyState>
<SteadyState Duration=\\"20\\" Power=\\"1.3\\">
</SteadyState>
<SteadyState Duration=\\"180\\" Power=\\"0.6\\">
</SteadyState>
<SteadyState Duration=\\"180\\" Power=\\"1.1\\">
</SteadyState>
<SteadyState Duration=\\"120\\" Power=\\"1.2\\">
</SteadyState>
<SteadyState Duration=\\"360\\" Power=\\"0.55\\">
<textevent timeoffset=\\"10\\" message=\\"In 6 minutes the FTP test begins\\">
</textevent>
<textevent timeoffset=\\"300\\" message=\\"Prepare for your max 20 minute effort\\">
</textevent>
</SteadyState>
<FreeRide Duration=\\"1200\\">
<textevent timeoffset=\\"10\\" message=\\"Bring your power up to what you think you can hold for 20 minutes\\">
</textevent>
<textevent timeoffset=\\"300\\" message=\\"How ya doin?\\">
</textevent>
<textevent timeoffset=\\"600\\" message=\\"Half way done! If it&apos;s feeling easy now might be time to add 10 watts.\\">
</textevent>
<textevent timeoffset=\\"900\\" message=\\"5 minutes left!\\">
</textevent>
<textevent timeoffset=\\"960\\" message=\\"4 minutes left. Increase your power if you&apos;re feeling strong.\\">
</textevent>
<textevent timeoffset=\\"1020\\" message=\\"3 minutes left\\">
</textevent>
<textevent timeoffset=\\"1080\\" message=\\"2 minutes left\\">
</textevent>
<textevent timeoffset=\\"1140\\" message=\\"1 minute left. Go all in!\\">
</textevent>
<textevent timeoffset=\\"1170\\" message=\\"30 sec. Go for it.\\">
</textevent>
<textevent timeoffset=\\"1190\\" message=\\"10 seconds.\\">
</textevent>
</FreeRide>
<Cooldown Duration=\\"300\\" PowerLow=\\"0.5\\" PowerHigh=\\"0.3\\">
</Cooldown>
</workout_file>"
`;
exports[`Generate ZWO examples/halvfems.txt 1`] = ` exports[`Generate ZWO examples/halvfems.txt 1`] = `
"<workout_file> "<workout_file>
<name>Halvfems</name> <name>Halvfems</name>
@ -205,6 +268,26 @@ Zone Distribution:
" "
`; `;
exports[`Generate stats examples/ftp-test.txt 1`] = `
"
Total duration: 45 minutes
Average intensity: 36%
Normalized intensity: 71%
TSS #1: 21
TSS #2: 37
Zone Distribution:
35 min - Z1: Recovery
4 min - Z2: Endurance
0 min - Z3: Tempo
0 min - Z4: Threshold
3 min - Z5: VO2 Max
2 min - Z6: Anaerobic
"
`;
exports[`Generate stats examples/halvfems.txt 1`] = ` exports[`Generate stats examples/halvfems.txt 1`] = `
" "
Total duration: 62 minutes Total duration: 62 minutes

View File

@ -9,6 +9,7 @@ const createZwo = (filename: string) => generateZwo(parse(fs.readFileSync(filena
const filenames = [ const filenames = [
"examples/comments.txt", "examples/comments.txt",
"examples/darth-vader.txt", "examples/darth-vader.txt",
"examples/ftp-test.txt",
"examples/halvfems.txt", "examples/halvfems.txt",
"examples/threshold-pushing.txt", "examples/threshold-pushing.txt",
]; ];