Bike-only Zwift .zwo generator with human-readable preview + confirmation loop. - scripts/zwo_builder.py: spec<->.zwo build/validate/parse/render/TSS estimate - scripts/zwo.py: CLI (build, from-template, validate, parse, render, templates) - references/zwo-format.md: condensed offline format reference - assets/example-vo2max.zwo + tests/test_zwo.py (9 tests) - Publishes via hosting skill; files result into Knowledge Wiki Format ground truth: h4l/zwift-workout-file-reference
74 lines
3.1 KiB
Markdown
74 lines
3.1 KiB
Markdown
# Zwift .zwo Format Reference (condensed)
|
||
|
||
Offline copy of the essentials from
|
||
<https://github.com/h4l/zwift-workout-file-reference>. A `.zwo` file is XML; there
|
||
is **no official Zwift schema**. This is the subset Zwift reliably accepts.
|
||
|
||
## Document structure
|
||
|
||
```xml
|
||
<?xml version="1.0" encoding="UTF-8"?>
|
||
<workout_file>
|
||
<author>IT Guy</author>
|
||
<name>VO2 Max 5x3</name>
|
||
<description>5 × 3 min @ 115 % FTP</description>
|
||
<sportType>bike</sportType>
|
||
<tags><tag name="vo2max"/></tags>
|
||
<workout>
|
||
<Warmup Duration="600" PowerLow="0.50" PowerHigh="0.75"/>
|
||
<IntervalsT Repeat="5" OnDuration="180" OffDuration="180"
|
||
OnPower="1.15" OffPower="0.55"/>
|
||
<Cooldown Duration="300" PowerLow="0.75" PowerHigh="0.40"/>
|
||
</workout>
|
||
</workout_file>
|
||
```
|
||
|
||
## Conventions
|
||
|
||
- **Power** is a fraction of FTP (`0.50` = 50 %, `1.15` = 115 %). `units="power"`
|
||
is the implicit default.
|
||
- **Duration** is always in **seconds**.
|
||
- `<sportType>` values seen: `bike` (61 %), `run` (37 %), `swim`, `ride`. We use `bike`.
|
||
- Text cues are children of a step: `<textevent timeoffset="30" message="Push!"/>`.
|
||
|
||
## Step elements (child of `<workout>`)
|
||
|
||
| Element | Attributes (relevant) | Meaning |
|
||
|---|---|---|
|
||
| `Warmup` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp Low→High |
|
||
| `Cooldown` | `Duration`, `PowerLow`, `PowerHigh`, `Cadence` | linear ramp High→Low |
|
||
| `SteadyState` | `Duration`, `Power`, (`PowerLow`,`PowerHigh`), `Cadence` | constant target |
|
||
| `SolidState` | `Duration`, `Power` | constant power, no ERG variation |
|
||
| `Ramp` | `Duration`, `PowerLow`, `PowerHigh` | explicit ramp |
|
||
| `IntervalsT` | `Repeat`, `OnDuration`, `OffDuration`, `OnPower`, `OffPower`, `Cadence`, `CadenceResting`, `OverUnder` | N repeated on/off pairs |
|
||
| `FreeRide` | `Duration`, `FlatRoad` | ERG off, rider's choice |
|
||
| `MaxEffort` | `Duration` | all-out, ERG off |
|
||
| `RestDay` | — | placeholder |
|
||
| `textevent` | `timeoffset`, `message`, `duration` | on-screen cue |
|
||
|
||
## Top-level metadata elements (child of `<workout_file>`)
|
||
|
||
`activitySaveName`, `author`, `author_alias`, `authorIcon`, `category`,
|
||
`categoryIndex`, `description`, `durationType`, `entid`, `ftpOverride`,
|
||
`ftpMaleOverride`, `ftpFemaleOverride`, `name`, `nameImperial`, `nameMetric`,
|
||
`painIndex`, `setFtpAtPercentage`, `ShowCP20`, `Skippable`, `sportType`,
|
||
`subcategory`, `tags`, `test_details`, `visibleAfterTime`, `visibleOutsidePlan`,
|
||
`workout`, `workoutLength`, `WorkoutPlan`.
|
||
|
||
**Minimal viable set we emit:** `author`, `name`, `description`, `sportType`,
|
||
`tags`, `workout`.
|
||
|
||
## Importing a .zwo into Zwift
|
||
|
||
1. Copy the file into `~/Documents/Zwift/Workouts/<userID>/` (folder name is your
|
||
numeric Zwift ID), **or**
|
||
2. Use the Custom Workouts import on zwift.com, **or**
|
||
3. Drop it into the Zwift folder sync (Dropbox/OneDrive) if configured.
|
||
|
||
Then restart Zwift; the workout appears under **Custom Workouts**.
|
||
|
||
## Gotchas
|
||
|
||
- Malformed XML or unescaped `&`/quotes → Zwift silently ignores the file.
|
||
- Power outside ~0.20–1.50 FTP is almost always a unit mistake.
|
||
- Duplicate/unknown attributes are tolerated but ignored; keep it minimal. |