Compare commits

..

37 Commits

Author SHA1 Message Date
Rene Saarsoo da62a0f5c6 v2.3.0 2022-03-30 14:11:23 +03:00
Rene Saarsoo 1a0d26f4ca Read file from stdin 2022-03-30 14:10:57 +03:00
Rene Saarsoo c9ab304460 v2.2.0 2021-11-08 17:43:44 +02:00
Rene Saarsoo e32b65926f Recognize ..XX% range intervals 2021-07-20 13:54:01 +03:00
Rene Saarsoo e49a1738ec v2.1.1 2021-03-21 17:53:55 +02:00
Rene Saarsoo 29cdd41966 2.1.0 2021-03-21 15:06:14 +02:00
Rene Saarsoo 6bbdea1908 Generate <Ramp> tags in ZWO
- When range-interval at start: generate <Warmup>
- When range-interval at end: generate <Cooldown>
- Otherwise use <Ramp>
2021-03-21 15:04:18 +02:00
Rene Saarsoo cec3c57477 Recognize "Ramp" intervals 2021-03-21 14:51:32 +02:00
Rene Saarsoo 198d678ccd v2.0.0 2021-03-04 15:38:10 +02:00
Rene Saarsoo 5de1049834 Mark positive offsets as done 2021-03-02 23:35:26 +02:00
Rene Saarsoo 93684069f0 New meaning of negative offsets (breaking change)
Instead of always being relative to interval end,
negative offsets are now relative to next comment
(or the interval end, when there is no next comment).
2021-03-02 23:32:05 +02:00
Rene Saarsoo 574272602f Positive comment offset syntax: +01:00 2021-03-02 21:58:34 +02:00
Rene Saarsoo ba92dd50d1 Use separate offset token for comments instead of duration 2021-03-02 21:37:51 +02:00
Rene Saarsoo 99dd16199d Simplify normalizedIntensity calculation 2021-01-27 12:53:27 +02:00
Rene Saarsoo a61f80cde4 Link to zwo-sucks blog post 2021-01-14 22:09:42 +02:00
Rene Saarsoo 67f09cc0bd v1.1.0 2021-01-13 21:29:12 +02:00
Rene Saarsoo 7d934408fd Remove "Detect overlaps of comments" from TODO 2020-12-26 17:50:41 +02:00
Rene Saarsoo 22f71ba51f Skip uninteresting snapshots 2020-12-26 17:50:10 +02:00
Rene Saarsoo 9012a38917 Check that comments don't extend past interval end 2020-12-26 17:48:29 +02:00
Rene Saarsoo dd6410d896 Ensure comments are at least 10 seconds apart 2020-12-26 17:41:53 +02:00
Rene Saarsoo bfae2b12e0 Detect overlapping comments 2020-12-26 17:31:01 +02:00
Rene Saarsoo 5aa11ddb3d Add <Ramp> to TODO list 2020-12-25 20:37:06 +02:00
Rene Saarsoo 6fd4b1c75f v1.0.0 2020-12-25 20:26:56 +02:00
Rene Saarsoo 6200dc75c1 Assign the same TSS score to FreeRide as Zwift does 2020-12-25 20:25:54 +02:00
Rene Saarsoo 9a2e3dafc9 Add missing loc props to tests 2020-12-25 20:19:20 +02:00
Rene Saarsoo f2b293ba21 Expose ValidationError.loc 2020-12-25 19:59:21 +02:00
Rene Saarsoo 7b30277143 Export also ValidationError & combined exception union 2020-12-25 19:55:25 +02:00
Rene Saarsoo effa11ad75 Expose SourceLocation data type 2020-12-25 15:43:04 +02:00
Rene Saarsoo c958e5e007 Expose ParseError publicly 2020-12-25 15:33:01 +02:00
Rene Saarsoo c6aa02bb06 Expose SourceLocation in ParseError object 2020-12-25 15:31:08 +02:00
Rene Saarsoo 97f6beb1a8 More TODO items 2020-12-20 22:00:20 +02:00
Rene Saarsoo 7910e74367 Remove chunkRangeIntervals() export 2020-11-23 05:27:59 +02:00
Rene Saarsoo c1cbdc28e8 v0.5.0 2020-11-22 22:35:32 +02:00
Rene Saarsoo a0bf62998e Expose intensityValueToZoneType() function 2020-11-22 22:34:46 +02:00
Rene Saarsoo fb1eba6db0 Add another workout example 2020-11-22 21:32:53 +02:00
Rene Saarsoo 38fb79b2a3 Additional Range interval tests 2020-11-22 20:39:06 +02:00
Rene Saarsoo 92b21d2095 Additional Freeride test data 2020-11-22 20:33:28 +02:00
23 changed files with 866 additions and 293 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 can easily make errors in XML syntax, rendering the file invalid,
- The syntax is quite inconsistent, making it hard to memoize.
- [it's really a bad format.][zwo-sucks]
There are a few alternative editors online:
@ -115,8 +115,11 @@ console.log(stats(workout));
- Repeats (and nested repeats)
- Unsupported params: message duration & y-position
- More restricted syntax for text (with quotes)
- Concatenate similar intervals
- Distinguish between terrain-sensitive and insensitive free-ride.
[zwift]: https://zwift.com/
[zwofactory]: https://zwofactory.com/
[simple zwo creator]: https://zwifthacks.com/app/simple-zwo-creator/
[workout-editor]: https://nene.github.io/workout-editor/
[zwo-sucks]: http://nene.github.io/2021/01/14/zwo-sucks

14
examples/ramps.txt Normal file
View File

@ -0,0 +1,14 @@
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

@ -0,0 +1,25 @@
Name: Threshold Dev
Description: From 10-12WK FTP Builder
Author: Zwift
Warmup: 10:00 50%..65%
Warmup: 5:00 50%..100%
Interval: 2:00 65%
Interval: 2:00 81%
Interval: 1:00 95%
Rest: 5:00 50%
Interval: 12:00 81%
Rest: 8:00 50%
Interval: 12:00 81%
Rest: 8:00 50%
Interval: 5:00 95%
Rest: 2:00 50%
Interval: 5:00 95%
Rest: 2:00 50%
Interval: 5:00 95%
Rest: 2:00 50%
Interval: 5:00 95%
Rest: 2:00 50%

View File

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

View File

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

View File

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

View File

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

View File

@ -7,9 +7,13 @@ export { parseCliOptions } from "./parseCliOptions";
export { Workout, Interval, Comment } from "./ast";
export { Duration } from "./Duration";
export { Intensity, ConstantIntensity, RangeIntensity, FreeIntensity } from "./Intensity";
export { ZoneType } 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
export { totalDuration } from "./stats/totalDuration";
export { maximumIntensity } from "./stats/maximumIntensity";
export { chunkRangeIntervals } from "./utils/chunkRangeIntervals";

View File

@ -17,13 +17,10 @@ export const parseCliOptions = (): CliOptions => {
default: false,
});
argParser.add_argument("file", { nargs: 1 });
argParser.add_argument("file", {
nargs: "?",
default: 0, // Default to reading STDIN
});
// 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,
};
return argParser.parse_args();
};

View File

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

View File

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

View File

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

View File

@ -231,6 +231,7 @@ Name: My Workout
Warmup: 5:30 50%..80% 100rpm
Cooldown: 5:30 70%..45%
Ramp: 5:30 90%..100%
`).intervals,
).toMatchInlineSnapshot(`
Array [
@ -258,10 +259,147 @@ Cooldown: 5:30 70%..45%
},
"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", () => {
expect(
parse(`
@ -555,17 +693,13 @@ Rest: 5:00 50%
`);
});
it("parses intervals with negative comment offsets", () => {
it("parses last comment with negative offset", () => {
expect(
parse(`
Name: My Workout
Interval: 10:00 90%
@ 0:10 Find your rythm.
@ -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(`
Object {
@ -604,18 +738,197 @@ Rest: 5:00 50%
},
"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 {
"cadence": undefined,
"comments": Array [
Object {
"loc": Object {
"col": 4,
"row": 7,
"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": "Great effort!",
"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 {
"loc": Object {
"col": 4,
"row": 7,
},
"offset": Duration {
"seconds": 310,
},
"text": "Comment #5 10 seconds later",
},
Object {
"loc": Object {
@ -623,18 +936,123 @@ Rest: 5:00 50%
"row": 8,
},
"offset": Duration {
"seconds": 180,
"seconds": 320,
},
"text": "Cool down well after all of this.",
"text": "Comment #6 another 10 seconds later",
},
],
"duration": Duration {
"seconds": 300,
"seconds": 600,
},
"intensity": ConstantIntensity {
"_value": 0.5,
"_value": 0.9,
},
"type": "Rest",
"type": "Interval",
},
],
"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",
@ -664,4 +1082,85 @@ Interval: 2:00 90%
`),
).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,8 +1,9 @@
import { last } from "ramda";
import { Interval, Workout, Comment } from "../ast";
import { Duration } from "../Duration";
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity";
import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
import { ParseError } from "./ParseError";
import { IntervalType, SourceLocation, Token } from "./tokenizer";
import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer";
type Header = Partial<Omit<Workout, "intervals">>;
@ -57,12 +58,18 @@ const parseHeader = (tokens: Token[]): [Header, Token[]] => {
return [header, tokens];
};
type PartialComment = {
offsetToken: OffsetToken;
text: string;
loc: SourceLocation;
};
const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Comment[], Token[]] => {
const comments: Comment[] = [];
const comments: PartialComment[] = [];
while (tokens[0]) {
const [start, offset, text, ...rest] = tokens;
if (start.type === "comment-start") {
if (!offset || offset.type !== "duration") {
if (!offset || offset.type !== "offset") {
throw new ParseError(
`Expected [comment offset] instead got ${tokenToString(offset)}`,
offset?.loc || start.loc,
@ -72,8 +79,7 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
}
comments.push({
// when offset is negative, recalculate it based on interval length
offset: new Duration(offset.value >= 0 ? offset.value : intervalDuration.seconds + offset.value),
offsetToken: offset,
text: text.value,
loc: offset.loc,
});
@ -82,7 +88,55 @@ const parseIntervalComments = (tokens: Token[], intervalDuration: Duration): [Co
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[]] => {
@ -104,6 +158,9 @@ const parseIntervalParams = (type: IntervalType, tokens: Token[], loc: SourceLoc
} else if (token.type === "intensity-range") {
intensity = new RangeIntensity(token.value[0], token.value[1]);
tokens.shift();
} else if (token.type === "intensity-range-end") {
intensity = new RangeIntensityEnd(token.value);
tokens.shift();
} else {
break;
}

View File

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

View File

@ -1,14 +1,24 @@
import { Workout, Interval } from "../ast";
import { ValidationError } from "./ValidationError";
const validateCommentOffsets = (interval: Interval) => {
for (const comment of interval.comments) {
if (comment.offset.seconds >= interval.duration.seconds) {
const validateCommentOffsets = ({ comments, duration }: Interval) => {
for (let i = 0; i < comments.length; i++) {
const comment = comments[i];
if (comment.offset.seconds >= duration.seconds) {
throw new ValidationError(`Comment offset is larger than interval length`, comment.loc);
}
if (comment.offset.seconds < 0) {
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 { pipe, sum } from "ramda";
import { map, pipe, sum } from "ramda";
import { Interval } from "../ast";
import { ConstantIntensity } from "../Intensity";
import { average } from "./average";
@ -32,12 +32,6 @@ const fourthRoot = (x: number) => Math.pow(x, 1 / 4);
export const normalizedIntensity = (intervals: Interval[]): ConstantIntensity => {
return new ConstantIntensity(
pipe(
intervalsToIntensityNumbers,
rollingAverages,
(averages) => averages.map(fourthPower),
average,
fourthRoot,
)(intervals),
pipe(intervalsToIntensityNumbers, rollingAverages, map(fourthPower), average, fourthRoot)(intervals),
);
};

View File

@ -53,6 +53,9 @@ describe("xp()", () => {
});
[
// [50, 4], // Doesn't work :(
[51, 5],
[52, 5],
[53, 5],
[54, 5],
[55, 5],
@ -83,17 +86,25 @@ describe("xp()", () => {
});
[
[51, 5],
[52, 5],
[53, 5],
[54, 5],
[55, 5],
[56, 5],
[57, 5],
[58, 5],
[59, 5],
// [60, 6], Doesn't work. Incorrect code or test data?
// [60, 6], // Doesn't work :(
[61, 6],
[62, 6],
[63, 6],
[64, 6],
[65, 6],
[66, 6],
[67, 6],
[68, 6],
[69, 6],
[2 * 60, 11],
[3 * 60, 17],
].forEach(([seconds, expectedXp]) => {

View File

@ -1,154 +0,0 @@
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

@ -1,49 +0,0 @@
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,6 +226,31 @@ The workouts are alphabetically ordered from easiest to hardest, so enjoy the mi
</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`] = `
"<workout_file>
<name>Threshold pushing</name>
@ -300,10 +325,10 @@ exports[`Generate stats examples/ftp-test.txt 1`] = `
"
Total duration: 45 minutes
Average intensity: 36%
Normalized intensity: 71%
Average intensity: 72%
Normalized intensity: 81%
TSS: 37
TSS: 49
XP: 336
Zone Distribution:
@ -338,6 +363,27 @@ 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`] = `
"
Total duration: 79 minutes

View File

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