Compare commits

..

No commits in common. "master" and "v0.5.0" have entirely different histories.

21 changed files with 289 additions and 826 deletions

View File

@ -18,7 +18,7 @@ Editing .zwo files by hand is also inconvenient:
- you'll have to constantly convert minutes to seconds, - you'll have to constantly convert minutes to seconds,
- you can easily make errors in XML syntax, rendering the file invalid, - you can easily make errors in XML syntax, rendering the file invalid,
- [it's really a bad format.][zwo-sucks] - The syntax is quite inconsistent, making it hard to memoize.
There are a few alternative editors online: There are a few alternative editors online:
@ -115,11 +115,8 @@ console.log(stats(workout));
- 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)
- Concatenate similar intervals
- Distinguish between terrain-sensitive and insensitive free-ride.
[zwift]: https://zwift.com/ [zwift]: https://zwift.com/
[zwofactory]: https://zwofactory.com/ [zwofactory]: https://zwofactory.com/
[simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/ [simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/
[workout-editor]: https://nene.github.io/workout-editor/ [workout-editor]: https://nene.github.io/workout-editor/
[zwo-sucks]: http://nene.github.io/2021/01/14/zwo-sucks

View File

@ -1,14 +0,0 @@
Name: Ramps
Author: R.Saarsoo
Description:
Various kinds of ramp intervals.
Ramp: 5:00 40%..75%
Ramp: 10:00 80%..90%
Ramp: 10:00 90%..80%
Warmup: 10:00 80%..90%
Cooldown: 10:00 90%..80%
Ramp: 5:00 75%..40%

View File

@ -1,6 +1,6 @@
{ {
"name": "zwiftout", "name": "zwiftout",
"version": "2.3.0", "version": "0.5.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"description": "Zwift workout generator command line tool and library", "description": "Zwift workout generator command line tool and library",
"author": "Rene Saarsoo <github@triin.net>", "author": "Rene Saarsoo <github@triin.net>",
@ -21,7 +21,7 @@
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
"bin": { "bin": {
"zwiftout": "bin/zwiftout.js" "zwiftout": "./bin/zwiftout.js"
}, },
"dependencies": { "dependencies": {
"argparse": "^2.0.1", "argparse": "^2.0.1",

View File

@ -47,38 +47,17 @@ export class RangeIntensity implements Intensity {
} }
} }
export class RangeIntensityEnd implements Intensity {
constructor(private _end: number) {}
get value() {
return this._end;
}
get start(): number {
throw new Error("RangeIntensityEnd has no start");
}
get end() {
return this._end;
}
get zone() {
return intensityValueToZoneType(this.value);
}
}
export class FreeIntensity implements Intensity { export class FreeIntensity implements Intensity {
get value() { get value() {
// To match Zwift, which gives 64 TSS for 1h of freeride. return 0;
return 0.8;
} }
get start() { get start() {
return this.value; return 0;
} }
get end() { get end() {
return this.value; return 0;
} }
get zone() { get zone() {

View File

@ -176,9 +176,9 @@ describe("detectRepeats()", () => {
duration: new Duration(100), duration: new Duration(100),
intensity: new ConstantIntensity(1), intensity: new ConstantIntensity(1),
comments: [ comments: [
{ offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } }, { offset: new Duration(0), text: "Let's start" },
{ offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } }, { offset: new Duration(20), text: "Stay strong!" },
{ offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } }, { offset: new Duration(90), text: "Finish it!" },
], ],
}, },
{ {
@ -186,8 +186,8 @@ describe("detectRepeats()", () => {
duration: new Duration(100), duration: new Duration(100),
intensity: new ConstantIntensity(0.5), intensity: new ConstantIntensity(0.5),
comments: [ comments: [
{ offset: new Duration(0), text: "Huh... have a rest", loc: { row: 4, col: 1 } }, { offset: new Duration(0), text: "Huh... have a rest" },
{ offset: new Duration(80), text: "Ready for next?", loc: { row: 5, col: 1 } }, { offset: new Duration(80), text: "Ready for next?" },
], ],
}, },
{ {
@ -195,9 +195,9 @@ describe("detectRepeats()", () => {
duration: new Duration(100), duration: new Duration(100),
intensity: new ConstantIntensity(1), intensity: new ConstantIntensity(1),
comments: [ comments: [
{ offset: new Duration(0), text: "Bring it on again!", loc: { row: 6, col: 1 } }, { offset: new Duration(0), text: "Bring it on again!" },
{ offset: new Duration(50), text: "Half way", loc: { row: 7, col: 1 } }, { offset: new Duration(50), text: "Half way" },
{ offset: new Duration(90), text: "Almost there!", loc: { row: 8, col: 1 } }, { offset: new Duration(90), text: "Almost there!" },
], ],
}, },
{ {
@ -205,9 +205,9 @@ describe("detectRepeats()", () => {
duration: new Duration(100), duration: new Duration(100),
intensity: new ConstantIntensity(0.5), intensity: new ConstantIntensity(0.5),
comments: [ comments: [
{ offset: new Duration(30), text: "Wow... you did it!", loc: { row: 9, col: 1 } }, { offset: new Duration(30), text: "Wow... you did it!" },
{ offset: new Duration(40), text: "Nice job.", loc: { row: 10, col: 1 } }, { offset: new Duration(40), text: "Nice job." },
{ offset: new Duration(50), text: "Until next time...", loc: { row: 11, col: 1 } }, { offset: new Duration(50), text: "Until next time..." },
], ],
}, },
]; ];
@ -220,20 +220,20 @@ describe("detectRepeats()", () => {
{ type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] }, { type: "Rest", duration: new Duration(100), intensity: new ConstantIntensity(0.5), comments: [] },
], ],
comments: [ comments: [
{ offset: new Duration(0), text: "Let's start", loc: { row: 1, col: 1 } }, { offset: new Duration(0), text: "Let's start" },
{ offset: new Duration(20), text: "Stay strong!", loc: { row: 2, col: 1 } }, { offset: new Duration(20), text: "Stay strong!" },
{ offset: new Duration(90), text: "Finish it!", loc: { row: 3, col: 1 } }, { offset: new Duration(90), text: "Finish it!" },
{ offset: new Duration(100), text: "Huh... have a rest", loc: { row: 4, col: 1 } }, { offset: new Duration(100), text: "Huh... have a rest" },
{ offset: new Duration(180), text: "Ready for next?", loc: { row: 5, col: 1 } }, { offset: new Duration(180), text: "Ready for next?" },
{ offset: new Duration(200), text: "Bring it on again!", loc: { row: 6, col: 1 } }, { offset: new Duration(200), text: "Bring it on again!" },
{ offset: new Duration(250), text: "Half way", loc: { row: 7, col: 1 } }, { offset: new Duration(250), text: "Half way" },
{ offset: new Duration(290), text: "Almost there!", loc: { row: 8, col: 1 } }, { offset: new Duration(290), text: "Almost there!" },
{ offset: new Duration(330), text: "Wow... you did it!", loc: { row: 9, col: 1 } }, { offset: new Duration(330), text: "Wow... you did it!" },
{ offset: new Duration(340), text: "Nice job.", loc: { row: 10, col: 1 } }, { offset: new Duration(340), text: "Nice job." },
{ offset: new Duration(350), text: "Until next time...", loc: { row: 11, col: 1 } }, { offset: new Duration(350), text: "Until next time..." },
], ],
}, },
]); ]);

View File

@ -11,7 +11,7 @@ const generateTextEvents = (comments: Comment[]): xml.XmlObject[] => {
}; };
const generateRangeInterval = ( const generateRangeInterval = (
tagName: "Warmup" | "Cooldown" | "Ramp", tagName: "Warmup" | "Cooldown",
{ duration, intensity, cadence, comments }: Interval, { duration, intensity, cadence, comments }: Interval,
): xml.XmlObject => { ): xml.XmlObject => {
return { return {
@ -79,22 +79,16 @@ const generateRepeatInterval = (repInterval: RepeatedInterval): xml.XmlObject =>
}; };
}; };
const generateInterval = ( const generateInterval = (interval: Interval | RepeatedInterval): xml.XmlObject => {
interval: Interval | RepeatedInterval,
index: number,
allIntervals: (Interval | RepeatedInterval)[],
): xml.XmlObject => {
if (interval.type === "repeat") { if (interval.type === "repeat") {
return generateRepeatInterval(interval); return generateRepeatInterval(interval);
} }
const { intensity } = interval; const { intensity } = interval;
if (index === 0 && intensity.start < intensity.end) { if (intensity.start < intensity.end) {
return generateRangeInterval("Warmup", interval); return generateRangeInterval("Warmup", interval);
} else if (index === allIntervals.length - 1 && intensity.start > intensity.end) { } else if (intensity.start > intensity.end) {
return generateRangeInterval("Cooldown", interval); return generateRangeInterval("Cooldown", interval);
} else if (intensity.start !== intensity.end) {
return generateRangeInterval("Ramp", interval);
} else if (intensity.zone === "free") { } else if (intensity.zone === "free") {
return generateFreeRideInterval(interval); return generateFreeRideInterval(interval);
} else { } else {

View File

@ -8,12 +8,8 @@ export { Workout, Interval, Comment } from "./ast";
export { Duration } from "./Duration"; export { Duration } from "./Duration";
export { Intensity, ConstantIntensity, RangeIntensity, FreeIntensity } from "./Intensity"; export { Intensity, ConstantIntensity, RangeIntensity, FreeIntensity } from "./Intensity";
export { ZoneType, intensityValueToZoneType } from "./ZoneType"; export { ZoneType, intensityValueToZoneType } from "./ZoneType";
export { SourceLocation } from "./parser/tokenizer";
import { ParseError } from "./parser/ParseError";
import { ValidationError } from "./parser/ValidationError";
export type ZwiftoutException = ParseError | ValidationError;
export { ParseError, ValidationError };
// utils // utils
export { totalDuration } from "./stats/totalDuration"; export { totalDuration } from "./stats/totalDuration";
export { maximumIntensity } from "./stats/maximumIntensity"; export { maximumIntensity } from "./stats/maximumIntensity";
export { chunkRangeIntervals } from "./utils/chunkRangeIntervals";

View File

@ -17,10 +17,13 @@ export const parseCliOptions = (): CliOptions => {
default: false, default: false,
}); });
argParser.add_argument("file", { argParser.add_argument("file", { nargs: 1 });
nargs: "?",
default: 0, // Default to reading STDIN
});
return argParser.parse_args(); // As we only allow one file as input,
// convert filenames array to just a single string.
const { file, ...rest } = argParser.parse_args();
return {
file: file[0],
...rest,
};
}; };

View File

@ -1,9 +1,7 @@
import { SourceLocation } from "./tokenizer"; import { SourceLocation } from "./tokenizer";
export class ParseError extends Error { export class ParseError extends Error {
public loc: SourceLocation; constructor(msg: string, { row, col }: SourceLocation) {
constructor(msg: string, loc: SourceLocation) { super(`${msg} at line ${row + 1} char ${col + 1}`);
super(`${msg} at line ${loc.row + 1} char ${loc.col + 1}`);
this.loc = loc;
} }
} }

View File

@ -1,9 +1,7 @@
import { SourceLocation } from "./tokenizer"; import { SourceLocation } from "./tokenizer";
export class ValidationError extends Error { export class ValidationError extends Error {
public loc: SourceLocation; constructor(msg: string, { row, col }: SourceLocation) {
constructor(msg: string, loc: SourceLocation) { super(`${msg} at line ${row + 1} char ${col + 1}`);
super(`${msg} at line ${loc.row + 1} char ${loc.col + 1}`);
this.loc = loc;
} }
} }

View File

@ -1,45 +0,0 @@
import { Interval, Workout } from "../ast";
import { FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
const fillIntensities = (prevInterval: Interval, interval: Interval): Interval => {
if (!(interval.intensity instanceof RangeIntensityEnd)) {
return interval;
}
if (prevInterval.intensity instanceof FreeIntensity) {
throw new Error("range-intensity-end interval can't be after free-intensity interval");
}
return {
...interval,
intensity: new RangeIntensity(prevInterval.intensity.end, interval.intensity.end),
};
};
// Given: [1, 2, 3, 4]
// Returns: [[1,2], [2,3], [3,4]]
const pairs = <T>(arr: T[]): T[][] => {
const result: T[][] = [];
for (let i = 1; i < arr.length; i++) {
result.push([arr[i - 1], arr[i]]);
}
return result;
};
const fillIntensitiesInIntervals = (intervals: Interval[]): Interval[] => {
if (intervals.length <= 1) {
if (intervals.length === 1 && intervals[0].intensity instanceof RangeIntensityEnd) {
throw new Error("range-intensity-end interval can't be the first interval");
}
return intervals;
}
return [intervals[0], ...pairs(intervals).map(([prev, curr]) => fillIntensities(prev, curr))];
};
export const fillRangeIntensities = (workout: Workout): Workout => {
return {
...workout,
intervals: fillIntensitiesInIntervals(workout.intervals),
};
};

View File

@ -1,7 +1,6 @@
import { Workout } from "../ast"; import { Workout } from "../ast";
import { fillRangeIntensities } from "./fillRangeIntensities";
import { parseTokens } from "./parser"; import { parseTokens } from "./parser";
import { tokenize } from "./tokenizer"; import { tokenize } from "./tokenizer";
import { validate } from "./validate"; import { validate } from "./validate";
export const parse = (source: string): Workout => validate(fillRangeIntensities(parseTokens(tokenize(source)))); export const parse = (source: string): Workout => validate(parseTokens(tokenize(source)));

View File

@ -231,7 +231,6 @@ Name: My Workout
Warmup: 5:30 50%..80% 100rpm Warmup: 5:30 50%..80% 100rpm
Cooldown: 5:30 70%..45% Cooldown: 5:30 70%..45%
Ramp: 5:30 90%..100%
`).intervals, `).intervals,
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
Array [ Array [
@ -259,147 +258,10 @@ Ramp: 5:30 90%..100%
}, },
"type": "Cooldown", "type": "Cooldown",
}, },
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 330,
},
"intensity": RangeIntensity {
"_end": 1,
"_start": 0.9,
},
"type": "Ramp",
},
] ]
`); `);
}); });
it("parses end-of-range intervals after power-range interval", () => {
expect(
parse(`
Name: My Workout
Ramp: 5:00 50%..75%
Ramp: 5:00 ..100%
Ramp: 5:00 ..50%
`).intervals,
).toMatchInlineSnapshot(`
Array [
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 0.75,
"_start": 0.5,
},
"type": "Ramp",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 1,
"_start": 0.75,
},
"type": "Ramp",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 0.5,
"_start": 1,
},
"type": "Ramp",
},
]
`);
});
it("parses end-of-range intervals after normal interval", () => {
expect(
parse(`
Name: My Workout
Interval: 5:00 75%
Ramp: 5:00 ..100%
Ramp: 5:00 ..50%
`).intervals,
).toMatchInlineSnapshot(`
Array [
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": ConstantIntensity {
"_value": 0.75,
},
"type": "Interval",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 1,
"_start": 0.75,
},
"type": "Ramp",
},
Object {
"cadence": undefined,
"comments": Array [],
"duration": Duration {
"seconds": 300,
},
"intensity": RangeIntensity {
"_end": 0.5,
"_start": 1,
},
"type": "Ramp",
},
]
`);
});
it("throws error when end-of-range intervals after free-ride interval", () => {
expect(
() =>
parse(`
Name: My Workout
FreeRide: 5:00
Ramp: 5:00 ..100%
Ramp: 5:00 ..50%
`).intervals,
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be after free-intensity interval"`);
});
it("throws error when end-of-range intervals is the first interval", () => {
expect(
() =>
parse(`
Name: My Workout
Ramp: 5:00 ..50%
`).intervals,
).toThrowErrorMatchingInlineSnapshot(`"range-intensity-end interval can't be the first interval"`);
});
it("parses free-ride intervals", () => { it("parses free-ride intervals", () => {
expect( expect(
parse(` parse(`
@ -693,13 +555,17 @@ Rest: 5:00 50%
`); `);
}); });
it("parses last comment with negative offset", () => { it("parses intervals with negative comment offsets", () => {
expect( expect(
parse(` parse(`
Name: My Workout Name: My Workout
Interval: 10:00 90% Interval: 10:00 90%
@ 0:10 Find your rythm. @ 0:10 Find your rythm.
@ -0:10 Final push. YOU GOT IT! @ -0:10 Final push. YOU GOT IT!
Rest: 5:00 50%
@ -4:30 Great effort!
@ -2:00 Cool down well after all of this.
`), `),
).toMatchInlineSnapshot(` ).toMatchInlineSnapshot(`
Object { Object {
@ -738,197 +604,18 @@ Interval: 10:00 90%
}, },
"type": "Interval", "type": "Interval",
}, },
],
"name": "My Workout",
"tags": Array [],
}
`);
});
it("parses comment with negative offset before absolutely offset comment", () => {
expect(
parse(`
Name: My Workout
Interval: 1:00 90%
@ -0:10 Before last
@ 0:50 Last!
`),
).toMatchInlineSnapshot(`
Object {
"author": "",
"description": "",
"intervals": Array [
Object { Object {
"cadence": undefined, "cadence": undefined,
"comments": Array [ "comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 3,
},
"offset": Duration {
"seconds": 40,
},
"text": "Before last",
},
Object {
"loc": Object {
"col": 4,
"row": 4,
},
"offset": Duration {
"seconds": 50,
},
"text": "Last!",
},
],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.9,
},
"type": "Interval",
},
],
"name": "My Workout",
"tags": Array [],
}
`);
});
it("parses multiple comments with negative offsets in row", () => {
expect(
parse(`
Name: My Workout
Interval: 1:00 90%
@ -0:10 One more before last
@ -0:10 Before last
@ -0:10 Last!
`),
).toMatchInlineSnapshot(`
Object {
"author": "",
"description": "",
"intervals": Array [
Object {
"cadence": undefined,
"comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 3,
},
"offset": Duration {
"seconds": 30,
},
"text": "One more before last",
},
Object {
"loc": Object {
"col": 4,
"row": 4,
},
"offset": Duration {
"seconds": 40,
},
"text": "Before last",
},
Object {
"loc": Object {
"col": 4,
"row": 5,
},
"offset": Duration {
"seconds": 50,
},
"text": "Last!",
},
],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.9,
},
"type": "Interval",
},
],
"name": "My Workout",
"tags": Array [],
}
`);
});
it("parses intervals with positive comment offsets", () => {
expect(
parse(`
Name: My Workout
Interval: 10:00 90%
@ 0:50 First comment
@ +0:10 Comment #2 10 seconds later
@ +0:10 Comment #3 another 10 seconds later
@ 5:00 Half way!
@ +0:10 Comment #5 10 seconds later
@ +0:10 Comment #6 another 10 seconds later
`),
).toMatchInlineSnapshot(`
Object {
"author": "",
"description": "",
"intervals": Array [
Object {
"cadence": undefined,
"comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 3,
},
"offset": Duration {
"seconds": 50,
},
"text": "First comment",
},
Object {
"loc": Object {
"col": 4,
"row": 4,
},
"offset": Duration {
"seconds": 60,
},
"text": "Comment #2 10 seconds later",
},
Object {
"loc": Object {
"col": 4,
"row": 5,
},
"offset": Duration {
"seconds": 70,
},
"text": "Comment #3 another 10 seconds later",
},
Object {
"loc": Object {
"col": 4,
"row": 6,
},
"offset": Duration {
"seconds": 300,
},
"text": "Half way!",
},
Object { Object {
"loc": Object { "loc": Object {
"col": 4, "col": 4,
"row": 7, "row": 7,
}, },
"offset": Duration { "offset": Duration {
"seconds": 310, "seconds": 30,
}, },
"text": "Comment #5 10 seconds later", "text": "Great effort!",
}, },
Object { Object {
"loc": Object { "loc": Object {
@ -936,123 +623,18 @@ Interval: 10:00 90%
"row": 8, "row": 8,
}, },
"offset": Duration { "offset": Duration {
"seconds": 320, "seconds": 180,
}, },
"text": "Comment #6 another 10 seconds later", "text": "Cool down well after all of this.",
}, },
], ],
"duration": Duration { "duration": Duration {
"seconds": 600, "seconds": 300,
}, },
"intensity": ConstantIntensity { "intensity": ConstantIntensity {
"_value": 0.9, "_value": 0.5,
}, },
"type": "Interval", "type": "Rest",
},
],
"name": "My Workout",
"tags": Array [],
}
`);
});
it("treats positive comment offset as relative to interval start when there's no previous comment", () => {
expect(
parse(`
Name: My Workout
Interval: 10:00 90%
@ +1:00 First comment
`),
).toMatchInlineSnapshot(`
Object {
"author": "",
"description": "",
"intervals": Array [
Object {
"cadence": undefined,
"comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 3,
},
"offset": Duration {
"seconds": 60,
},
"text": "First comment",
},
],
"duration": Duration {
"seconds": 600,
},
"intensity": ConstantIntensity {
"_value": 0.9,
},
"type": "Interval",
},
],
"name": "My Workout",
"tags": Array [],
}
`);
});
it("throws error when negative offset is followed by positive offset", () => {
expect(() =>
parse(`
Name: My Workout
Interval: 2:00 90%
@ -0:10 Comment 1
@ +0:30 Comment 2
@ 1:30 Comment 3
`),
).toThrowErrorMatchingInlineSnapshot(`"Negative offset followed by positive offset at line 5 char 5"`);
});
it("works fine when positive offset is followed by negative offset", () => {
expect(
parse(`
Name: My Workout
Interval: 1:00 90%
@ +0:10 Comment 1
@ -0:10 Comment 2
`),
).toMatchInlineSnapshot(`
Object {
"author": "",
"description": "",
"intervals": Array [
Object {
"cadence": undefined,
"comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 3,
},
"offset": Duration {
"seconds": 10,
},
"text": "Comment 1",
},
Object {
"loc": Object {
"col": 4,
"row": 4,
},
"offset": Duration {
"seconds": 50,
},
"text": "Comment 2",
},
],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.9,
},
"type": "Interval",
}, },
], ],
"name": "My Workout", "name": "My Workout",
@ -1082,85 +664,4 @@ Interval: 2:00 90%
`), `),
).toThrowErrorMatchingInlineSnapshot(`"Negative comment offset is larger than interval length at line 5 char 5"`); ).toThrowErrorMatchingInlineSnapshot(`"Negative comment offset is larger than interval length at line 5 char 5"`);
}); });
it("throws error when comment offset is the same as another comment offset", () => {
expect(() =>
parse(`
Name: My Workout
Interval: 2:00 90%
@ 0:00 First comment
@ 1:00 Comment
@ 1:00 Overlapping comment
@ 1:50 Last comment
`),
).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
});
it("throws error when comment offset is greater than next comment offset", () => {
expect(() =>
parse(`
Name: My Workout
Interval: 2:00 90%
@ 0:00 First comment
@ 1:20 Comment
@ 1:00 Misplaced comment
@ 1:50 Last comment
`),
).toThrowErrorMatchingInlineSnapshot(`"Comment overlaps previous comment at line 6 char 5"`);
});
it("throws error when comments too close together", () => {
expect(() =>
parse(`
Name: My Workout
Interval: 2:00 90%
@ 0:00 First comment
@ 0:01 Second Comment
`),
).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
expect(() =>
parse(`
Name: My Workout
Interval: 2:00 90%
@ 0:00 First comment
@ 0:09 Second Comment
`),
).toThrowErrorMatchingInlineSnapshot(`"Less than 10 seconds between comments at line 5 char 5"`);
});
it("triggers no error when comments at least 10 seconds apart", () => {
expect(() =>
parse(`
Name: My Workout
Interval: 2:00 90%
@ 0:00 First comment
@ 0:10 Second Comment
`),
).not.toThrowError();
});
it("throws error when comment does not finish before end of interval", () => {
expect(() =>
parse(`
Name: My Workout
Interval: 1:00 80%
@ 0:51 First comment
Interval: 1:00 90%
`),
).toThrowErrorMatchingInlineSnapshot(
`"Less than 10 seconds between comment start and interval end at line 4 char 5"`,
);
});
it("triggers no error when comment finishes right at interval end", () => {
expect(() =>
parse(`
Name: My Workout
Interval: 1:00 80%
@ 0:50 First comment
Interval: 1:00 90%
`),
).not.toThrowError();
});
}); });

View File

@ -1,9 +1,8 @@
import { last } from "ramda";
import { Interval, Workout, Comment } from "../ast"; import { Interval, Workout, Comment } from "../ast";
import { Duration } from "../Duration"; import { Duration } from "../Duration";
import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity"; import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
import { ParseError } from "./ParseError"; import { ParseError } from "./ParseError";
import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer"; import { IntervalType, SourceLocation, Token } from "./tokenizer";
type Header = Partial<Omit<Workout, "intervals">>; type Header = Partial<Omit<Workout, "intervals">>;
@ -58,18 +57,12 @@ const parseHeader = (tokens: Token[]): [Header, Token[]] => {
return [header, tokens]; return [header, tokens];
}; };
type PartialComment = {
offsetToken: OffsetToken;
text: string;
loc: SourceLocation;
};
const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Comment[], Token[]] => { const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Comment[], Token[]] => {
const comments: PartialComment[] = []; const comments: Comment[] = [];
while (tokens[0]) { while (tokens[0]) {
const [start, offset, text, ...rest] = tokens; const [start, offset, text, ...rest] = tokens;
if (start.type === "comment-start") { if (start.type === "comment-start") {
if (!offset || offset.type !== "offset") { if (!offset || offset.type !== "duration") {
throw new ParseError( throw new ParseError(
`Expected [comment offset] instead got ${tokenToString(offset)}`, `Expected [comment offset] instead got ${tokenToString(offset)}`,
offset?.loc || start.loc, offset?.loc || start.loc,
@ -79,7 +72,8 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc); throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
} }
comments.push({ comments.push({
offsetToken: offset, // when offset is negative, recalculate it based on interval length
offset: new Duration(offset.value >= 0 ? offset.value : intervalDuration.seconds + offset.value),
text: text.value, text: text.value,
loc: offset.loc, loc: offset.loc,
}); });
@ -88,55 +82,7 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
break; break;
} }
} }
return [comments, tokens];
return [computeAbsoluteOffsets(comments, intervalDuration), tokens];
};
const computeAbsoluteOffsets = (partialComments: PartialComment[], intervalDuration: Duration): Comment[] => {
const comments: Comment[] = [];
for (let i = 0; i < partialComments.length; i++) {
const pComment = partialComments[i];
const offsetToken = pComment.offsetToken;
// Assume absolute offset by default
let offset: Duration = new Duration(offsetToken.value);
if (offsetToken.kind === "relative-plus") {
// Position relative to previous already-computed comment offset
const previousComment = last(comments);
if (previousComment) {
offset = new Duration(previousComment.offset.seconds + offset.seconds);
}
} else if (offsetToken.kind === "relative-minus") {
// Position relative to next comment or interval end
offset = new Duration(nextCommentOffset(partialComments, i, intervalDuration).seconds - offset.seconds);
}
comments.push({
offset,
loc: pComment.loc,
text: pComment.text,
});
}
return comments;
};
const nextCommentOffset = (partialComments: PartialComment[], i: number, intervalDuration: Duration): Duration => {
const nextComment = partialComments[i + 1];
if (!nextComment) {
return intervalDuration;
}
switch (nextComment.offsetToken.kind) {
case "relative-minus":
return new Duration(
nextCommentOffset(partialComments, i + 1, intervalDuration).seconds - nextComment.offsetToken.value,
);
case "relative-plus":
throw new ParseError("Negative offset followed by positive offset", nextComment.offsetToken.loc);
case "absolute":
default:
return new Duration(nextComment.offsetToken.value);
}
}; };
const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => { const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLocation): [Interval, Token[]] => {
@ -158,9 +104,6 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
} else if (token.type === "intensity-range") { } else if (token.type === "intensity-range") {
intensity = new RangeIntensity(token.value[0], token.value[1]); intensity = new RangeIntensity(token.value[0], token.value[1]);
tokens.shift(); tokens.shift();
} else if (token.type === "intensity-range-end") {
intensity = new RangeIntensityEnd(token.value);
tokens.shift();
} else { } else {
break; break;
} }

View File

@ -1,13 +1,13 @@
import { ParseError } from "./ParseError"; import { ParseError } from "./ParseError";
export type HeaderType = "Name" | "Author" | "Description" | "Tags"; export type HeaderType = "Name" | "Author" | "Description" | "Tags";
export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide" | "Ramp"; 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", "Tags"].includes(value); return ["Name", "Author", "Description", "Tags"].includes(value);
}; };
const isIntervalType = (value: string): value is IntervalType => { const isIntervalType = (value: string): value is IntervalType => {
return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide", "Ramp"].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.
@ -36,36 +36,17 @@ export type NumberToken = {
value: number; value: number;
loc: SourceLocation; loc: SourceLocation;
}; };
export type OffsetToken = {
type: "offset";
kind: "absolute" | "relative-plus" | "relative-minus";
value: number;
loc: SourceLocation;
};
export type RangeIntensityToken = { export type RangeIntensityToken = {
type: "intensity-range"; type: "intensity-range";
value: [number, number]; value: [number, number];
loc: SourceLocation; loc: SourceLocation;
}; };
export type RangeIntensityEndToken = {
type: "intensity-range-end";
value: number;
loc: SourceLocation;
};
export type CommentStartToken = { export type CommentStartToken = {
type: "comment-start"; type: "comment-start";
value?: undefined; value?: undefined;
loc: SourceLocation; loc: SourceLocation;
}; };
export type Token = export type Token = HeaderToken | IntervalToken | TextToken | NumberToken | RangeIntensityToken | CommentStartToken;
| HeaderToken
| IntervalToken
| TextToken
| NumberToken
| OffsetToken
| RangeIntensityToken
| RangeIntensityEndToken
| CommentStartToken;
const toInteger = (str: string): number => { const toInteger = (str: string): number => {
return parseInt(str.replace(/[^0-9]/, ""), 10); return parseInt(str.replace(/[^0-9]/, ""), 10);
@ -87,14 +68,10 @@ const tokenizeValueParam = (text: string, loc: SourceLocation): Token => {
if (/^[0-9]+rpm$/.test(text)) { if (/^[0-9]+rpm$/.test(text)) {
return { type: "cadence", value: toInteger(text), loc }; return { type: "cadence", value: toInteger(text), loc };
} }
if (/^[0-9]+%\.\.[0-9]+%$/.test(text)) { if (/^[0-9]+%..[0-9]+%$/.test(text)) {
const [from, to] = text.split("..").map(toInteger).map(toFraction); const [from, to] = text.split("..").map(toInteger).map(toFraction);
return { type: "intensity-range", value: [from, to], loc }; return { type: "intensity-range", value: [from, to], loc };
} }
if (/^\.\.[0-9]+%$/.test(text)) {
const [, /* _ */ to] = text.split("..").map(toInteger).map(toFraction);
return { type: "intensity-range-end", value: to, loc };
}
if (/^[0-9]+%$/.test(text)) { if (/^[0-9]+%$/.test(text)) {
return { type: "intensity", value: toFraction(toInteger(text)), loc }; return { type: "intensity", value: toFraction(toInteger(text)), loc };
} }
@ -112,36 +89,21 @@ const tokenizeParams = (text: string, loc: SourceLocation): Token[] => {
}; };
const tokenizeComment = (line: string, row: number): Token[] | undefined => { const tokenizeComment = (line: string, row: number): Token[] | undefined => {
const [, commentHead, sign, offset, commentText] = line.match(/^(\s*@\s*)([-+]?)([0-9:]+)(.*?)$/) || []; const [, commentHead, minus, offset, commentText] = line.match(/^(\s*@\s*)(-?)([0-9:]+)(.*?)$/) || [];
if (!commentHead) { if (!commentHead) {
return undefined; return undefined;
} }
const sign = minus ? -1 : 1;
if (!DURATION_REGEX.test(offset)) { if (!DURATION_REGEX.test(offset)) {
throw new ParseError("Invalid comment offset", { row, col: commentHead.length }); throw new ParseError("Invalid comment offset", { row, col: commentHead.length });
} }
return [ return [
{ type: "comment-start", loc: { row, col: line.indexOf("@") } }, { type: "comment-start", loc: { row, col: line.indexOf("@") } },
{ { type: "duration", value: sign * toSeconds(offset), loc: { row, col: commentHead.length } },
type: "offset",
kind: signToKind(sign),
value: toSeconds(offset),
loc: { row, col: commentHead.length },
},
{ type: "text", value: commentText.trim(), loc: { row, col: commentHead.length + offset.length } }, { type: "text", value: commentText.trim(), loc: { row, col: commentHead.length + offset.length } },
]; ];
}; };
const signToKind = (sign: string) => {
switch (sign) {
case "-":
return "relative-minus";
case "+":
return "relative-plus";
default:
return "absolute";
}
};
const tokenizeHeader = (label: HeaderType, separator: string, paramString: string, row: number): Token[] => { const tokenizeHeader = (label: HeaderType, separator: string, paramString: string, row: number): Token[] => {
const token: HeaderToken = { const token: HeaderToken = {
type: "header", type: "header",

View File

@ -1,24 +1,14 @@
import { Workout, Interval } from "../ast"; import { Workout, Interval } from "../ast";
import { ValidationError } from "./ValidationError"; import { ValidationError } from "./ValidationError";
const validateCommentOffsets = ({ comments, duration }: Interval) => { const validateCommentOffsets = (interval: Interval) => {
for (let i = 0; i < comments.length; i++) { for (const comment of interval.comments) {
const comment = comments[i]; if (comment.offset.seconds >= interval.duration.seconds) {
if (comment.offset.seconds >= duration.seconds) {
throw new ValidationError(`Comment offset is larger than interval length`, comment.loc); throw new ValidationError(`Comment offset is larger than interval length`, comment.loc);
} }
if (comment.offset.seconds < 0) { if (comment.offset.seconds < 0) {
throw new ValidationError(`Negative comment offset is larger than interval length`, comment.loc); throw new ValidationError(`Negative comment offset is larger than interval length`, comment.loc);
} }
if (i > 0 && comment.offset.seconds <= comments[i - 1].offset.seconds) {
throw new ValidationError(`Comment overlaps previous comment`, comment.loc);
}
if (i > 0 && comment.offset.seconds < comments[i - 1].offset.seconds + 10) {
throw new ValidationError(`Less than 10 seconds between comments`, comment.loc);
}
if (comment.offset.seconds + 10 > duration.seconds) {
throw new ValidationError(`Less than 10 seconds between comment start and interval end`, comment.loc);
}
} }
}; };

View File

@ -1,4 +1,4 @@
import { map, pipe, sum } from "ramda"; import { pipe, sum } from "ramda";
import { Interval } from "../ast"; import { Interval } from "../ast";
import { ConstantIntensity } from "../Intensity"; import { ConstantIntensity } from "../Intensity";
import { average } from "./average"; import { average } from "./average";
@ -32,6 +32,12 @@ const fourthRoot = (x: number) => Math.pow(x, 1 / 4);
export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => { export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => {
return new ConstantIntensity( return new ConstantIntensity(
pipe(intervalsToIntensityNumbers, rollingAverages, map(fourthPower), average, fourthRoot)(intervals), pipe(
intervalsToIntensityNumbers,
rollingAverages,
(averages) => averages.map(fourthPower),
average,
fourthRoot,
)(intervals),
); );
}; };

View File

@ -0,0 +1,154 @@
import { Interval } from "../ast";
import { Duration } from "../Duration";
import { ConstantIntensity, RangeIntensity } from "../Intensity";
import { chunkRangeIntervals } from "./chunkRangeIntervals";
describe("chunkRangeIntervals()", () => {
const minute = new Duration(60);
it("does nothing with empty array", () => {
expect(chunkRangeIntervals([], minute)).toEqual([]);
});
it("does nothing with constant-intensity intervals", () => {
const intervals: Interval[] = [
{
type: "Interval",
duration: new Duration(2 * 60),
intensity: new ConstantIntensity(0.7),
comments: [],
},
{
type: "Interval",
duration: new Duration(10 * 60),
intensity: new ConstantIntensity(1),
comments: [],
},
{
type: "Rest",
duration: new Duration(30),
intensity: new ConstantIntensity(0.5),
comments: [],
},
];
expect(chunkRangeIntervals(intervals, minute)).toEqual(intervals);
});
it("converts 1-minute range-interval to 1-minute constant-interval", () => {
expect(
chunkRangeIntervals(
[
{
type: "Warmup",
duration: minute,
intensity: new RangeIntensity(0.5, 1),
comments: [],
},
],
minute,
),
).toMatchInlineSnapshot(`
Array [
Object {
"comments": Array [],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.75,
},
"type": "Warmup",
},
]
`);
});
it("splits 3-minute range-interval to three 1-minute constant-intervals", () => {
expect(
chunkRangeIntervals(
[
{
type: "Warmup",
duration: new Duration(3 * 60),
intensity: new RangeIntensity(0.5, 1),
comments: [],
},
],
minute,
),
).toMatchInlineSnapshot(`
Array [
Object {
"comments": Array [],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.5833333333333334,
},
"type": "Warmup",
},
Object {
"comments": Array [],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.75,
},
"type": "Warmup",
},
Object {
"comments": Array [],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.9166666666666667,
},
"type": "Warmup",
},
]
`);
});
it("splits 1:30 range-interval to 1min & 30sec constant-intervals", () => {
expect(
chunkRangeIntervals(
[
{
type: "Warmup",
duration: new Duration(60 + 30),
intensity: new RangeIntensity(0.5, 1),
comments: [],
},
],
minute,
),
).toMatchInlineSnapshot(`
Array [
Object {
"comments": Array [],
"duration": Duration {
"seconds": 60,
},
"intensity": ConstantIntensity {
"_value": 0.6666666666666666,
},
"type": "Warmup",
},
Object {
"comments": Array [],
"duration": Duration {
"seconds": 30,
},
"intensity": ConstantIntensity {
"_value": 0.9166666666666667,
},
"type": "Warmup",
},
]
`);
});
});

View File

@ -0,0 +1,49 @@
import { chain, curry } from "ramda";
import { Interval } from "../ast";
import { Duration } from "../Duration";
import { ConstantIntensity, Intensity } from "../Intensity";
const chunkDuration = (seconds: number, chunkSize: Duration, intervalDuration: Duration): Duration => {
return seconds + chunkSize.seconds > intervalDuration.seconds
? new Duration(intervalDuration.seconds % chunkSize.seconds)
: chunkSize;
};
const chunkIntensity = (
startSeconds: number,
chunkSize: Duration,
{ start, end }: Intensity,
intervalDuration: Duration,
): ConstantIntensity => {
const endSeconds =
startSeconds + chunkSize.seconds > intervalDuration.seconds
? intervalDuration.seconds
: startSeconds + chunkSize.seconds;
const middleSeconds = (startSeconds + endSeconds) / 2;
return new ConstantIntensity(start + (end - start) * (middleSeconds / intervalDuration.seconds));
};
const chunkInterval = curry((chunkSize: Duration, interval: Interval): Interval[] => {
if (interval.intensity.start === interval.intensity.end) {
return [interval];
}
const intervals: Interval[] = [];
for (let seconds = 0; seconds < interval.duration.seconds; seconds += chunkSize.seconds) {
intervals.push({
...interval,
duration: chunkDuration(seconds, chunkSize, interval.duration),
intensity: chunkIntensity(seconds, chunkSize, interval.intensity, interval.duration),
comments: [], // TODO: for now, ignoring comments
});
}
return intervals;
});
/**
* Breaks intervals that use RangeIntensity into multiple intervals with ConstantIntensity
*/
export const chunkRangeIntervals = (intervals: Interval[], chunkSize: Duration): Interval[] =>
chain(chunkInterval(chunkSize), intervals);

View File

@ -226,31 +226,6 @@ The workouts are alphabetically ordered from easiest to hardest, so enjoy the mi
</workout_file>" </workout_file>"
`; `;
exports[`Generate ZWO examples/ramps.txt 1`] = `
"<workout_file>
<name>Ramps</name>
<author>R.Saarsoo</author>
<description>Various kinds of ramp intervals.</description>
<tags>
</tags>
<sportType>bike</sportType>
<workout>
<Warmup Duration=\\"300\\" PowerLow=\\"0.4\\" PowerHigh=\\"0.75\\">
</Warmup>
<Ramp Duration=\\"600\\" PowerLow=\\"0.8\\" PowerHigh=\\"0.9\\">
</Ramp>
<Ramp Duration=\\"600\\" PowerLow=\\"0.9\\" PowerHigh=\\"0.8\\">
</Ramp>
<Ramp Duration=\\"600\\" PowerLow=\\"0.8\\" PowerHigh=\\"0.9\\">
</Ramp>
<Ramp Duration=\\"600\\" PowerLow=\\"0.9\\" PowerHigh=\\"0.8\\">
</Ramp>
<Cooldown Duration=\\"300\\" PowerLow=\\"0.75\\" PowerHigh=\\"0.4\\">
</Cooldown>
</workout>
</workout_file>"
`;
exports[`Generate ZWO examples/threshold-pushing.txt 1`] = ` exports[`Generate ZWO examples/threshold-pushing.txt 1`] = `
"<workout_file> "<workout_file>
<name>Threshold pushing</name> <name>Threshold pushing</name>
@ -325,10 +300,10 @@ exports[`Generate stats examples/ftp-test.txt 1`] = `
" "
Total duration: 45 minutes Total duration: 45 minutes
Average intensity: 72% Average intensity: 36%
Normalized intensity: 81% Normalized intensity: 71%
TSS: 49 TSS: 37
XP: 336 XP: 336
Zone Distribution: Zone Distribution:
@ -363,27 +338,6 @@ Zone Distribution:
" "
`; `;
exports[`Generate stats examples/ramps.txt 1`] = `
"
Total duration: 50 minutes
Average intensity: 80%
Normalized intensity: 82%
TSS: 56
XP: 300
Zone Distribution:
6 min - Z1: Recovery
4 min - Z2: Endurance
40 min - Z3: Tempo
0 min - Z4: Threshold
0 min - Z5: VO2 Max
0 min - Z6: Anaerobic
0 min - Freeride
"
`;
exports[`Generate stats examples/threshold-pushing.txt 1`] = ` exports[`Generate stats examples/threshold-pushing.txt 1`] = `
" "
Total duration: 79 minutes Total duration: 79 minutes

View File

@ -12,7 +12,6 @@ const filenames = [
"examples/ftp-test.txt", "examples/ftp-test.txt",
"examples/halvfems.txt", "examples/halvfems.txt",
"examples/threshold-pushing.txt", "examples/threshold-pushing.txt",
"examples/ramps.txt",
]; ];
describe("Generate ZWO", () => { describe("Generate ZWO", () => {