Compare commits

..

15 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
14 changed files with 688 additions and 52 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:
@ -117,10 +117,9 @@ console.log(stats(workout));
- More restricted syntax for text (with quotes)
- Concatenate similar intervals
- Distinguish between terrain-sensitive and insensitive free-ride.
- Syntax for comments placed relative to previous ones, e.g. `@ +00:10`
- Use `<Ramp>` in addition to `<Warmup>` and `<Cooldown>`
[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

@ -1,6 +1,6 @@
{
"name": "zwiftout",
"version": "1.1.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,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 {
get value() {
// 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 = (
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

@ -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

@ -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",

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,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

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