Compare commits

...

22 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
15 changed files with 782 additions and 55 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,
- 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: There are a few alternative editors online:
@ -117,10 +117,9 @@ console.log(stats(workout));
- More restricted syntax for text (with quotes) - More restricted syntax for text (with quotes)
- Concatenate similar intervals - Concatenate similar intervals
- Distinguish between terrain-sensitive and insensitive free-ride. - Distinguish between terrain-sensitive and insensitive free-ride.
- Syntax for comments placed relative to previous ones, e.g. `@ +00:10`
- Detect overlaps of comments.
[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

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

@ -1,6 +1,6 @@
{ {
"name": "zwiftout", "name": "zwiftout",
"version": "1.0.0", "version": "2.3.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,6 +47,26 @@ 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. // To match Zwift, which gives 64 TSS for 1h of freeride.

View File

@ -11,7 +11,7 @@ const generateTextEvents = (comments: Comment[]): xml.XmlObject[] => {
}; };
const generateRangeInterval = ( const generateRangeInterval = (
tagName: "Warmup" | "Cooldown", tagName: "Warmup" | "Cooldown" | "Ramp",
{ duration, intensity, cadence, comments }: Interval, { duration, intensity, cadence, comments }: Interval,
): xml.XmlObject => { ): xml.XmlObject => {
return { 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") { if (interval.type === "repeat") {
return generateRepeatInterval(interval); return generateRepeatInterval(interval);
} }
const { intensity } = interval; const { intensity } = interval;
if (intensity.start < intensity.end) { if (index === 0 && intensity.start < intensity.end) {
return generateRangeInterval("Warmup", interval); return generateRangeInterval("Warmup", interval);
} else if (intensity.start > intensity.end) { } else if (index === allIntervals.length - 1 && 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

@ -17,13 +17,10 @@ export const parseCliOptions = (): CliOptions => {
default: false, 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, return argParser.parse_args();
// convert filenames array to just a single string.
const { file, ...rest } = argParser.parse_args();
return {
file: file[0],
...rest,
};
}; };

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 { 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(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 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 [
@ -258,10 +259,147 @@ Cooldown: 5:30 70%..45%
}, },
"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(`
@ -555,17 +693,13 @@ Rest: 5:00 50%
`); `);
}); });
it("parses intervals with negative comment offsets", () => { it("parses last comment with negative offset", () => {
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 {
@ -604,18 +738,197 @@ Rest: 5:00 50%
}, },
"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 { Object {
"loc": Object { "loc": Object {
"col": 4, "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 { "offset": Duration {
"seconds": 30, "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 { Object {
"loc": Object { "loc": Object {
@ -623,18 +936,123 @@ Rest: 5:00 50%
"row": 8, "row": 8,
}, },
"offset": Duration { "offset": Duration {
"seconds": 180, "seconds": 320,
}, },
"text": "Cool down well after all of this.", "text": "Comment #6 another 10 seconds later",
}, },
], ],
"duration": Duration { "duration": Duration {
"seconds": 300, "seconds": 600,
}, },
"intensity": ConstantIntensity { "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", "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"`); ).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 { Interval, Workout, Comment } from "../ast";
import { Duration } from "../Duration"; import { Duration } from "../Duration";
import { ConstantIntensity, FreeIntensity, RangeIntensity } from "../Intensity"; import { ConstantIntensity, FreeIntensity, RangeIntensity, RangeIntensityEnd } from "../Intensity";
import { ParseError } from "./ParseError"; import { ParseError } from "./ParseError";
import { IntervalType, SourceLocation, Token } from "./tokenizer"; import { IntervalType, OffsetToken, SourceLocation, Token } from "./tokenizer";
type Header = Partial<Omit<Workout, "intervals">>; type Header = Partial<Omit<Workout, "intervals">>;
@ -57,12 +58,18 @@ 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: Comment[] = []; const comments: PartialComment[] = [];
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 !== "duration") { if (!offset || offset.type !== "offset") {
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,
@ -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); throw new ParseError(`Expected [comment text] instead got ${tokenToString(text)}`, text?.loc || offset.loc);
} }
comments.push({ comments.push({
// when offset is negative, recalculate it based on interval length offsetToken: offset,
offset: new Duration(offset.value >= 0 ? offset.value : intervalDuration.seconds + offset.value),
text: text.value, text: text.value,
loc: offset.loc, loc: offset.loc,
}); });
@ -82,7 +88,55 @@ 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[]] => {
@ -104,6 +158,9 @@ 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"; export type IntervalType = "Warmup" | "Rest" | "Interval" | "Cooldown" | "FreeRide" | "Ramp";
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"].includes(value); return ["Warmup", "Rest", "Interval", "Cooldown", "FreeRide", "Ramp"].includes(value);
}; };
// 0-based row and column indexes. First line is 0th. // 0-based row and column indexes. First line is 0th.
@ -36,17 +36,36 @@ 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 = HeaderToken | IntervalToken | TextToken | NumberToken | RangeIntensityToken | CommentStartToken; export type Token =
| 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);
@ -68,10 +87,14 @@ 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 };
} }
@ -89,21 +112,36 @@ 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, minus, offset, commentText] = line.match(/^(\s*@\s*)(-?)([0-9:]+)(.*?)$/) || []; const [, commentHead, sign, 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,14 +1,24 @@
import { Workout, Interval } from "../ast"; import { Workout, Interval } from "../ast";
import { ValidationError } from "./ValidationError"; import { ValidationError } from "./ValidationError";
const validateCommentOffsets = (interval: Interval) => { const validateCommentOffsets = ({ comments, duration }: Interval) => {
for (const comment of interval.comments) { for (let i = 0; i < comments.length; i++) {
if (comment.offset.seconds >= interval.duration.seconds) { const comment = comments[i];
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 { pipe, sum } from "ramda"; import { map, 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,12 +32,6 @@ 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( pipe(intervalsToIntensityNumbers, rollingAverages, map(fourthPower), average, fourthRoot)(intervals),
intervalsToIntensityNumbers,
rollingAverages,
(averages) => averages.map(fourthPower),
average,
fourthRoot,
)(intervals),
); );
}; };

View File

@ -226,6 +226,31 @@ 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>
@ -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`] = ` exports[`Generate stats examples/threshold-pushing.txt 1`] = `
" "
Total duration: 79 minutes Total duration: 79 minutes

View File

@ -12,6 +12,7 @@ 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", () => {