From 109162889c39ccfc31a8a9a8644e413b711992b2 Mon Sep 17 00:00:00 2001 From: IT Guy Date: Sun, 20 Sep 2026 07:47:05 +0000 Subject: [PATCH 1/2] feat(skills): add zwift-workout skill (.zwo builder, validator, preview, publishing) 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 --- skills/zwift-workout/SKILL.md | 110 ++++ .../zwift-workout/assets/example-vo2max.zwo | 16 + skills/zwift-workout/references/zwo-format.md | 74 +++ skills/zwift-workout/scripts/zwo.py | 178 +++++++ skills/zwift-workout/scripts/zwo_builder.py | 484 ++++++++++++++++++ skills/zwift-workout/tests/test_zwo.py | 131 +++++ 6 files changed, 993 insertions(+) create mode 100644 skills/zwift-workout/SKILL.md create mode 100644 skills/zwift-workout/assets/example-vo2max.zwo create mode 100644 skills/zwift-workout/references/zwo-format.md create mode 100644 skills/zwift-workout/scripts/zwo.py create mode 100644 skills/zwift-workout/scripts/zwo_builder.py create mode 100644 skills/zwift-workout/tests/test_zwo.py diff --git a/skills/zwift-workout/SKILL.md b/skills/zwift-workout/SKILL.md new file mode 100644 index 0000000..a3344d5 --- /dev/null +++ b/skills/zwift-workout/SKILL.md @@ -0,0 +1,110 @@ +--- +name: zwift-workout +description: Create, preview, edit, validate and publish Zwift .zwo workout files for cycling (bike-only). Use when the user asks to build a Zwift workout, generate a .zwo file, convert a training idea into a Zwift session, preview/adjust an existing workout, or publish a workout for import into Zwift. +--- + +# 🚴 Zwift Workout (.zwo) Builder + +Builds valid Zwift `.zwo` files from a small JSON spec, renders a human-readable +preview for confirmation, and publishes the finished file. **Bike-only.** + +## When to use + +- "Bau mir ein Zwift-Workout …" / "Create a .zwo for 5Γ—3min VO2max" +- "Zeig mir das Workout" / "Γ€nder die Intervalle auf 4Γ—5min" +- "Lade das Workout in Zwift" (publish to hosting for import) + +## Core workflow (always follow this order) + +1. **Draft the spec.** Either pick a deterministic template + (`zwo.py templates` β†’ `endurance | sweetspot | vo2max | sprint`) or write a + JSON spec from the user's request. Keep power as FTP fractions + (`0.88` = 88 %, `1.15` = 115 %); durations in **seconds**. +2. **Render the preview and ask for confirmation.** Run + `python3 {baseDir}/scripts/zwo.py render ` (or `build … --preview`) + and show the table to the user. **Do not publish before the user confirms.** +3. **Apply changes.** Edit the JSON spec and re-render; repeat until confirmed. +4. **Build + publish on confirmation.** Run + `python3 {baseDir}/scripts/zwo.py build -o .zwo --publish`. + This validates, writes the file, and uploads it via the `hosting` skill + (`https://hosting.haumdaucher.de/docs/…`). +5. **Deliver.** Post the hosting link (and the `.zwo` as `MEDIA:` if the + user wants the file directly). Optionally offer a QR code (hosting skill rule). +6. **File the result into the wiki** under `syntheses/` or `concepts/` per the + Knowledge Wiki protocol (see below). + +## JSON spec format + +```json +{ + "name": "VO2 Max 5x3", + "author": "IT Guy", + "description": "5 Γ— 3 min @ 115 % FTP, 3 min Pause.", + "sportType": "bike", + "tags": ["vo2max", "structured"], + "steps": [ + {"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75}, + {"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180, + "onPower": 1.15, "offPower": 0.55}, + {"type": "Cooldown", "duration": 300, "powerLow": 0.75, "powerHigh": 0.40} + ] +} +``` + +### Supported step types (bike) + +| `type` | Required fields | Optional | +|---|---|---| +| `Warmup` | `duration`, `powerLow`, `powerHigh` | `cadence` | +| `Cooldown` | `duration`, `powerLow`, `powerHigh` | `cadence` | +| `SteadyState` | `duration`, `power` | `cadence` | +| `Ramp` | `duration`, `powerLow`, `powerHigh` | β€” | +| `IntervalsT` | `repeat`, `onDuration`, `offDuration`, `onPower`, `offPower` | `cadence`, `cadenceResting` | +| `FreeRide` | `duration` | `flatRoad` | +| `MaxEffort` | `duration` | β€” | +| `SolidState` | `duration`, `power` | β€” | +| `RestDay` | β€” | β€” | + +Power values are **fractions of FTP**; duration is in **seconds**. See +`references/zwo-format.md` for the full tag/attribute reference. + +## CLI + +```bash +python3 {baseDir}/scripts/zwo.py templates # list templates +python3 {baseDir}/scripts/zwo.py from-template vo2max --title "VO2 Max 5x3" --publish +python3 {baseDir}/scripts/zwo.py render my.json # preview only +python3 {baseDir}/scripts/zwo.py build my.json -o out.zwo --preview --publish +python3 {baseDir}/scripts/zwo.py validate existing.zwo # check a file +python3 {baseDir}/scripts/zwo.py parse existing.zwo --json # .zwo -> spec (editable) +``` + +## Guardrails + +- **Power range:** `0.20 – 1.50` FTP. Outside β†’ hard error (catches unit mistakes). +- **Durations:** integer seconds, `> 0`; `< 5 s` and `> 4 h` produce warnings. +- **Structure:** exactly one ``, root ``, `name` required, + `sportType` must be `bike`. +- **Round-trip check:** output XML is re-parsed with `xml.etree` before writing; + text fields are escaped, so umlauts/quotes never corrupt the file. +- **Never publish without explicit user confirmation** of the rendered preview. + +## Reference & fixtures + +- `references/zwo-format.md` β€” condensed tag/attribute reference (offline copy). +- `assets/example-vo2max.zwo` β€” known-good fixture; use for regression tests. + +## Knowledge Wiki filing + +After a workout is confirmed and published, file it per the Karpathy LLM Wiki +protocol: create/update a `concepts/` or `syntheses/` page recording the workout +name, structure, power targets, hosting URL, and rationale, then append a +`log.md` entry (`ingest`/`synthesis`). Cross-link the Fitness agent page. + +## Inter-agent synergy + +`agents/fitness.md` owns training intent (Tacx Flux 2, FTP 275 W). This skill is +the execution layer for structured cycling sessions. For evidence-based +protocols (e.g. SIT 30 s / 4 min), consult the Knowledge Wiki first via +`wiki_search`. A future refactor can share one spec module with `garmin-workout` +(one source spec β†’ Garmin workout + `.zwo`). \ No newline at end of file diff --git a/skills/zwift-workout/assets/example-vo2max.zwo b/skills/zwift-workout/assets/example-vo2max.zwo new file mode 100644 index 0000000..a6f9a25 --- /dev/null +++ b/skills/zwift-workout/assets/example-vo2max.zwo @@ -0,0 +1,16 @@ + + + IT Guy + VO2 Max 5x3 + 5 x 3 min @ 115 % FTP, 3 min Pause. + bike + + + + + + + + + + \ No newline at end of file diff --git a/skills/zwift-workout/references/zwo-format.md b/skills/zwift-workout/references/zwo-format.md new file mode 100644 index 0000000..d38542e --- /dev/null +++ b/skills/zwift-workout/references/zwo-format.md @@ -0,0 +1,74 @@ +# Zwift .zwo Format Reference (condensed) + +Offline copy of the essentials from +. A `.zwo` file is XML; there +is **no official Zwift schema**. This is the subset Zwift reliably accepts. + +## Document structure + +```xml + + + IT Guy + VO2 Max 5x3 + 5 Γ— 3 min @ 115 % FTP + bike + + + + + + + +``` + +## Conventions + +- **Power** is a fraction of FTP (`0.50` = 50 %, `1.15` = 115 %). `units="power"` + is the implicit default. +- **Duration** is always in **seconds**. +- `` values seen: `bike` (61 %), `run` (37 %), `swim`, `ride`. We use `bike`. +- Text cues are children of a step: ``. + +## Step elements (child of ``) + +| 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 ``) + +`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//` (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. \ No newline at end of file diff --git a/skills/zwift-workout/scripts/zwo.py b/skills/zwift-workout/scripts/zwo.py new file mode 100644 index 0000000..6123ed7 --- /dev/null +++ b/skills/zwift-workout/scripts/zwo.py @@ -0,0 +1,178 @@ +#!/usr/bin/env python3 +"""zwo.py β€” CLI for creating, validating, previewing and publishing Zwift .zwo workouts. + +Commands +-------- + build [-o out.zwo] [--preview] [--publish] + from-template [--title T] [-o out.zwo] [--preview] [--publish] + validate + parse [--json] + render + templates + +Exit codes: 0 = ok, 1 = validation/usage error. +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import zwo_builder as zb # noqa: E402 + +HOSTING_SCRIPT = Path("/home/node/mop-skills/mop-skills/skills/hosting/scripts/publish_file.py") + + +def _slug(text: str) -> str: + s = re.sub(r"[^a-z0-9]+", "-", text.lower()).strip("-") + return s or "workout" + + +def _load_spec(path: str) -> dict: + with open(path, encoding="utf-8") as fh: + return json.load(fh) + + +def _publish(zwo_path: Path, title: str) -> str | None: + """Publish the .zwo via the hosting skill. Returns the public URL or None.""" + if not HOSTING_SCRIPT.exists(): + print(f"⚠️ hosting publisher not found at {HOSTING_SCRIPT}", file=sys.stderr) + return None + cmd = [sys.executable, str(HOSTING_SCRIPT), "--file", str(zwo_path), + "--category", "docs", "--slug", _slug(title)] + try: + out = subprocess.run(cmd, capture_output=True, text=True, timeout=120) + except Exception as exc: # noqa: BLE001 + print(f"⚠️ hosting publish failed: {exc}", file=sys.stderr) + return None + url = None + for line in out.stdout.splitlines(): + if line.startswith("URL:"): + url = line.split("URL:", 1)[1].strip() + if not url: + print(out.stdout, out.stderr, file=sys.stderr) + return url + + +def cmd_build(args: argparse.Namespace) -> int: + spec = _load_spec(args.spec) + warnings = zb.validate_spec(spec) + out = Path(args.output or f"{_slug(spec['name'])}.zwo") + zb.write_zwo(spec, str(out)) + print(f"βœ… {out} ({out.stat().st_size} bytes)") + for w in warnings: + print(f"⚠️ {w}") + if args.preview: + print() + print(zb.render_text(spec)) + if args.publish: + url = _publish(out, spec["name"]) + if url: + print(f"πŸ”— {url}") + return 0 + + +def cmd_from_template(args: argparse.Namespace) -> int: + spec = zb.from_template(args.name, args.title) + out = Path(args.output or f"{_slug(spec['name'])}.zwo") + zb.write_zwo(spec, str(out)) + print(f"βœ… {out} ({out.stat().st_size} bytes)") + print() + print(zb.render_text(spec)) + if args.publish: + url = _publish(out, spec["name"]) + if url: + print(f"πŸ”— {url}") + return 0 + + +def cmd_validate(args: argparse.Namespace) -> int: + ok, messages = zb.validate_file(args.file) + if ok: + print(f"βœ… {args.file} is a valid .zwo file") + for m in messages: + print(f"⚠️ {m}") + return 0 + print(f"❌ {args.file} is invalid:") + for m in messages: + print(f" - {m}") + return 1 + + +def cmd_parse(args: argparse.Namespace) -> int: + spec = zb.parse_file(args.file) + if args.json: + print(json.dumps(spec, ensure_ascii=False, indent=2)) + else: + print(zb.render_text(spec)) + return 0 + + +def cmd_render(args: argparse.Namespace) -> int: + if args.file.endswith(".json"): + spec = _load_spec(args.file) + else: + spec = zb.parse_file(args.file) + print(zb.render_text(spec)) + return 0 + + +def cmd_templates(_: argparse.Namespace) -> int: + for t in zb.templates(): + print(t) + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(prog="zwo.py", description="Zwift .zwo workout toolkit (bike-only)") + sub = parser.add_subparsers(dest="command", required=True) + + p = sub.add_parser("build", help="build a .zwo from a JSON spec") + p.add_argument("spec") + p.add_argument("-o", "--output") + p.add_argument("--preview", action="store_true", help="print the human-readable preview") + p.add_argument("--publish", action="store_true", help="upload via the hosting skill") + p.set_defaults(func=cmd_build) + + p = sub.add_parser("from-template", help="build from a built-in deterministic template") + p.add_argument("name") + p.add_argument("--title") + p.add_argument("-o", "--output") + p.add_argument("--publish", action="store_true") + p.set_defaults(func=cmd_from_template) + + p = sub.add_parser("validate", help="validate an existing .zwo file") + p.add_argument("file") + p.set_defaults(func=cmd_validate) + + p = sub.add_parser("parse", help="parse a .zwo into a JSON spec") + p.add_argument("file") + p.add_argument("--json", action="store_true") + p.set_defaults(func=cmd_parse) + + p = sub.add_parser("render", help="render a human-readable preview") + p.add_argument("file") + p.set_defaults(func=cmd_render) + + p = sub.add_parser("templates", help="list built-in templates") + p.set_defaults(func=cmd_templates) + + args = parser.parse_args(argv) + try: + return args.func(args) + except zb.ZwoValidationError as exc: + print(f"❌ validation error: {exc}", file=sys.stderr) + return 1 + except (OSError, json.JSONDecodeError) as exc: + print(f"❌ {exc}", file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) \ No newline at end of file diff --git a/skills/zwift-workout/scripts/zwo_builder.py b/skills/zwift-workout/scripts/zwo_builder.py new file mode 100644 index 0000000..6b9a9f3 --- /dev/null +++ b/skills/zwift-workout/scripts/zwo_builder.py @@ -0,0 +1,484 @@ +"""zwo_builder.py β€” Build, validate, parse and render Zwift ``.zwo`` workout files. + +Bike-only implementation following the community format reference +(https://github.com/h4l/zwift-workout-file-reference). No official Zwift XSD +exists; this module encodes the subset that Zwift reliably accepts. + +Public API +---------- +build_spec(spec) -> xml.etree.ElementTree.Element (raises on error) +spec_to_xml(spec) -> str +validate_spec(spec) -> list[str] (non-fatal warnings) +validate_file(path) -> (ok: bool, messages: list[str]) +parse_file(path) -> dict (spec) +render_text(spec) -> str (human-readable preview) +estimate(spec) -> dict (duration / NP / IF / TSS) +""" + +from __future__ import annotations + +import xml.etree.ElementTree as ET +from typing import Any + +# --------------------------------------------------------------------------- # +# Constants / guardrails +# --------------------------------------------------------------------------- # + +VALID_SPORT_TYPES = {"bike"} +VALID_DURATION_TYPES = {"time"} + +POWER_MIN = 0.20 # 20 % FTP β€” below this is almost certainly a mistake +POWER_MAX = 1.50 # 150 % FTP β€” above this is very unusual for a *.zwo target +DURATION_MIN = 5 # seconds; shorter steps are usually noise +DURATION_MAX = 4 * 3600 # 4 h single step sanity cap + +# spec key -> (XML attribute name, coercion) +_STEP_SPECS: dict[str, dict[str, tuple[str, type]]] = { + "Warmup": { + "duration": ("Duration", int), + "powerLow": ("PowerLow", float), + "powerHigh": ("PowerHigh", float), + "cadence": ("Cadence", int), + }, + "Cooldown": { + "duration": ("Duration", int), + "powerLow": ("PowerLow", float), + "powerHigh": ("PowerHigh", float), + "cadence": ("Cadence", int), + }, + "SteadyState": { + "duration": ("Duration", int), + "power": ("Power", float), + "cadence": ("Cadence", int), + }, + "Ramp": { + "duration": ("Duration", int), + "powerLow": ("PowerLow", float), + "powerHigh": ("PowerHigh", float), + }, + "IntervalsT": { + "repeat": ("Repeat", int), + "onDuration": ("OnDuration", int), + "offDuration": ("OffDuration", int), + "onPower": ("OnPower", float), + "offPower": ("OffPower", float), + "cadence": ("Cadence", int), + "cadenceResting": ("CadenceResting", int), + }, + "FreeRide": { + "duration": ("Duration", int), + "flatRoad": ("FlatRoad", int), + }, + "MaxEffort": { + "duration": ("Duration", int), + }, + "SolidState": { + "duration": ("Duration", int), + "power": ("Power", float), + }, + "RestDay": {}, + "TextEvent": { + "timeoffset": ("timeoffset", int), + "message": ("message", str), + "duration": ("duration", int), + }, +} + +# Elements that carry a target power (used for the TSS estimate) +_POWER_STEPS = {"Warmup", "Cooldown", "SteadyState", "Ramp", "IntervalsT", "SolidState"} + +REQUIRED_STEP_ATTRS: dict[str, tuple[str, ...]] = { + "Warmup": ("duration", "powerLow", "powerHigh"), + "Cooldown": ("duration", "powerLow", "powerHigh"), + "SteadyState": ("duration", "power"), + "Ramp": ("duration", "powerLow", "powerHigh"), + "IntervalsT": ("repeat", "onDuration", "offDuration", "onPower", "offPower"), + "FreeRide": ("duration",), + "MaxEffort": ("duration",), + "SolidState": ("duration", "power"), + "RestDay": (), + "TextEvent": ("timeoffset", "message"), +} + + +class ZwoValidationError(ValueError): + """Raised when a spec cannot produce a valid .zwo file.""" + + +# --------------------------------------------------------------------------- # +# Validation +# --------------------------------------------------------------------------- # + + +def _check_power(value: float, where: str, errors: list[str], warnings: list[str]) -> None: + if value < POWER_MIN: + errors.append(f"{where}: power {value:.2f} (< {POWER_MIN:.2f} FTP) is implausible") + elif value > POWER_MAX: + errors.append(f"{where}: power {value:.2f} (> {POWER_MAX:.2f} FTP) is implausible") + + +def _check_duration(value: int, where: str, errors: list[str], warnings: list[str]) -> None: + if value <= 0: + errors.append(f"{where}: duration must be > 0 seconds") + elif value < DURATION_MIN: + warnings.append(f"{where}: duration {value}s is very short") + elif value > DURATION_MAX: + warnings.append(f"{where}: duration {value}s exceeds the {DURATION_MAX}s single-step cap") + + +def validate_spec(spec: dict[str, Any]) -> list[str]: + """Validate a workout spec. Raise on fatal errors, return warnings.""" + errors: list[str] = [] + warnings: list[str] = [] + + if not isinstance(spec, dict): + raise ZwoValidationError("spec must be a JSON object") + + name = spec.get("name") + if not isinstance(name, str) or not name.strip(): + errors.append("missing required field: name") + + sport = spec.get("sportType", "bike") + if sport not in VALID_SPORT_TYPES: + errors.append(f"sportType {sport!r} not supported (bike-only): {sorted(VALID_SPORT_TYPES)}") + + steps = spec.get("steps") + if not isinstance(steps, list) or not steps: + errors.append("spec must contain a non-empty 'steps' list") + steps = [] + + for i, step in enumerate(steps, start=1): + where = f"step {i}" + if not isinstance(step, dict): + errors.append(f"{where}: must be an object") + continue + stype = step.get("type") + if stype not in _STEP_SPECS: + errors.append(f"{where}: unknown step type {stype!r}") + continue + for key in REQUIRED_STEP_ATTRS.get(stype, ()): + if step.get(key) is None: + errors.append(f"{where} ({stype}): missing required field {key!r}") + # power sanity + for key in ("power", "powerLow", "powerHigh", "onPower", "offPower"): + if key in step and step[key] is not None: + try: + _check_power(float(step[key]), f"{where} ({stype}).{key}", errors, warnings) + except (TypeError, ValueError): + errors.append(f"{where} ({stype}).{key}: not a number") + # ramp direction is only a hint + if stype in ("Warmup",) and step.get("powerHigh") is not None and step.get("powerLow") is not None: + if float(step["powerHigh"]) < float(step["powerLow"]): + warnings.append(f"{where} (Warmup): powerHigh < powerLow") + for key in ("duration", "onDuration", "offDuration"): + if key in step and step[key] is not None: + try: + _check_duration(int(step[key]), f"{where} ({stype}).{key}", errors, warnings) + except (TypeError, ValueError): + errors.append(f"{where} ({stype}).{key}: not an integer") + if stype == "IntervalsT" and step.get("repeat") is not None: + try: + if int(step["repeat"]) < 1: + errors.append(f"{where} (IntervalsT): repeat must be >= 1") + except (TypeError, ValueError): + errors.append(f"{where} (IntervalsT).repeat: not an integer") + + if errors: + raise ZwoValidationError("; ".join(errors)) + return warnings + + +# --------------------------------------------------------------------------- # +# Build +# --------------------------------------------------------------------------- # + + +def _coerce(value: Any, typ: type) -> str: + if typ is int: + return str(int(value)) + if typ is float: + # Zwift accepts plain decimals; trim trailing zeros + return f"{float(value):g}" + return str(value) + + +def build_spec(spec: dict[str, Any]) -> ET.Element: + """Build and re-validate the XML element tree for ``spec``.""" + validate_spec(spec) + + root = ET.Element("workout_file") + ET.SubElement(root, "author").text = str(spec.get("author", "IT Guy")) + ET.SubElement(root, "name").text = str(spec["name"]) + if spec.get("description"): + ET.SubElement(root, "description").text = str(spec["description"]) + ET.SubElement(root, "sportType").text = spec.get("sportType", "bike") + + tags = spec.get("tags") or [] + if tags: + tag_el = ET.SubElement(root, "tags") + for t in tags: + ET.SubElement(tag_el, "tag", {"name": str(t)}) + + workout = ET.SubElement(root, "workout") + for step in spec["steps"]: + stype = step["type"] + attrs: dict[str, str] = {} + for key, (attr, typ) in _STEP_SPECS[stype].items(): + if step.get(key) is not None: + attrs[attr] = _coerce(step[key], typ) + ET.SubElement(workout, stype, attrs) + + return root + + +def spec_to_xml(spec: dict[str, Any]) -> str: + """Serialize a spec to a pretty-printed .zwo XML string (with declaration).""" + root = build_spec(spec) + ET.indent(root, space=" ") + body = ET.tostring(root, encoding="unicode") + return '\n' + body + "\n" + + +def write_zwo(spec: dict[str, Any], path: str) -> str: + """Write ``spec`` to ``path`` after a round-trip parse check. Returns path.""" + xml = spec_to_xml(spec) + # round-trip: refuse to emit XML that cannot be re-parsed + try: + ET.fromstring(xml) + except ET.ParseError as exc: # pragma: no cover - defensive + raise ZwoValidationError(f"generated XML is not well-formed: {exc}") from exc + with open(path, "w", encoding="utf-8") as fh: + fh.write(xml) + return path + + +# --------------------------------------------------------------------------- # +# Parse / validate files +# --------------------------------------------------------------------------- # + + +def _step_from_element(el: ET.Element) -> dict[str, Any]: + stype = el.tag + spec = {"type": stype} + for key, (attr, typ) in _STEP_SPECS.get(stype, {}).items(): + if attr in el.attrib: + try: + spec[key] = typ(el.attrib[attr]) + except ValueError: + spec[key] = el.attrib[attr] + return spec + + +def parse_file(path: str) -> dict[str, Any]: + """Parse an existing .zwo file into our spec representation.""" + tree = ET.parse(path) + root = tree.getroot() + if root.tag != "workout_file": + raise ZwoValidationError(f"root element is <{root.tag}>, expected ") + + def text(tag: str) -> str | None: + el = root.find(tag) + return el.text.strip() if el is not None and el.text else None + + tags: list[str] = [] + tags_el = root.find("tags") + if tags_el is not None: + tags = [t.get("name", "") for t in tags_el.findall("tag")] + + workout = root.find("workout") + steps = [_step_from_element(el) for el in (workout or [])] + + return { + "name": text("name") or "Unnamed", + "author": text("author") or "", + "description": text("description") or "", + "sportType": text("sportType") or "bike", + "tags": tags, + "steps": steps, + } + + +def validate_file(path: str) -> tuple[bool, list[str]]: + """Validate an existing .zwo file. Returns (ok, messages).""" + try: + spec = parse_file(path) + except (ET.ParseError, ZwoValidationError, OSError) as exc: + return False, [f"parse error: {exc}"] + try: + warnings = validate_spec(spec) + except ZwoValidationError as exc: + return False, str(exc).split("; ") + return True, warnings + + +# --------------------------------------------------------------------------- # +# Render (human-readable preview) +# --------------------------------------------------------------------------- # + + +def _fmt_time(seconds: int) -> str: + m, s = divmod(int(seconds), 60) + return f"{m}:{s:02d}" + + +def _pct(value: float) -> str: + return f"{value * 100:g}%" + + +def estimate(spec: dict[str, Any]) -> dict[str, Any]: + """Estimate total duration, normalised power fraction and TSS.""" + total = 0 + weighted = 0.0 # sum(sec * f^2) + untracked = 0 + for step in spec.get("steps", []): + stype = step.get("type") + if stype == "IntervalsT": + reps = int(step.get("repeat", 1)) + on_d, off_d = int(step["onDuration"]), int(step["offDuration"]) + total += reps * (on_d + off_d) + weighted += reps * (on_d * float(step["onPower"]) ** 2 + off_d * float(step["offPower"]) ** 2) + elif stype in _POWER_STEPS: + dur = int(step["duration"]) + if stype in ("Warmup", "Cooldown", "Ramp"): + frac = (float(step["powerLow"]) + float(step["powerHigh"])) / 2 + else: + frac = float(step["power"]) + total += dur + weighted += dur * frac ** 2 + elif stype in ("FreeRide", "MaxEffort"): + total += int(step["duration"]) + untracked += int(step["duration"]) + tss = (100 * weighted / 3600) if total else 0.0 + np_frac = (weighted / total) ** 0.5 if total else 0.0 + return { + "duration_s": total, + "duration": _fmt_time(total), + "np_frac": round(np_frac, 3), + "if": round(np_frac, 3), + "tss": round(tss, 1), + "untracked_s": untracked, + } + + +def _bar(frac: float, width: int = 40, scale: float = 1.5) -> str: + filled = max(1, min(width, round(frac / scale * width))) + return "β–ˆ" * filled + + +def render_text(spec: dict[str, Any]) -> str: + """Human-readable workout preview for confirmation before publishing.""" + est = estimate(spec) + lines: list[str] = [] + lines.append(f"πŸ‹οΈ {spec['name']}") + if spec.get("description"): + lines.append(f" {spec['description']}") + lines.append(f" Sport: {spec.get('sportType', 'bike')} Autor: {spec.get('author', '')}") + lines.append("") + lines.append(f" # {'Step':<13}{'Dauer':>7} Details") + lines.append(" " + "-" * 62) + + for i, step in enumerate(spec["steps"], start=1): + stype = step["type"] + if stype == "IntervalsT": + reps = int(step["repeat"]) + total = reps * (int(step["onDuration"]) + int(step["offDuration"])) + detail = (f"{reps}Γ— {_fmt_time(step['onDuration'])} @{_pct(step['onPower'])}" + f" / {_fmt_time(step['offDuration'])} @{_pct(step['offPower'])}") + dur = _fmt_time(total) + elif stype in ("Warmup", "Cooldown", "Ramp"): + detail = f"{_pct(step['powerLow'])} β†’ {_pct(step['powerHigh'])}" + dur = _fmt_time(int(step["duration"])) + elif stype in ("SteadyState", "SolidState"): + detail = f"konstant @{_pct(step['power'])}" + dur = _fmt_time(int(step["duration"])) + elif stype == "FreeRide": + detail = "freie Fahrt (ERG aus)" + dur = _fmt_time(int(step["duration"])) + elif stype == "MaxEffort": + detail = "All-out (ERG aus)" + dur = _fmt_time(int(step["duration"])) + elif stype == "RestDay": + detail, dur = "Ruhetag", "β€”" + elif stype == "TextEvent": + detail, dur = f"Text: {step.get('message', '')}", "β€”" + else: + detail, dur = "", "β€”" + lines.append(f" {i:<3}{stype:<13}{dur:>7} {detail}") + + lines.append("") + lines.append(" Power-Profil:") + for step in spec["steps"]: + stype = step["type"] + if stype == "IntervalsT": + lines.append(f" ON {_bar(float(step['onPower']))} {_pct(step['onPower'])}") + lines.append(f" OFF {_bar(float(step['offPower']))} {_pct(step['offPower'])}") + elif stype in ("Warmup", "Cooldown", "Ramp"): + lines.append(f" {_bar((float(step['powerLow']) + float(step['powerHigh'])) / 2):<40} " + f"{_pct(step['powerLow'])}β†’{_pct(step['powerHigh'])}") + elif stype in ("SteadyState", "SolidState"): + lines.append(f" {_bar(float(step['power'])):<40} {_pct(step['power'])}") + + lines.append("") + lines.append(f" Gesamt: {est['duration']} NP β‰ˆ {est['np_frac'] * 100:g}% FTP " + f"IF {est['if']:.2f} TSS β‰ˆ {est['tss']}") + if est["untracked_s"]: + lines.append(f" ⚠️ {_fmt_time(est['untracked_s'])} FreeRide/MaxEffort sind nicht in der TSS-SchΓ€tzung enthalten.") + return "\n".join(lines) + + +# --------------------------------------------------------------------------- # +# Deterministic templates ("from-text") +# --------------------------------------------------------------------------- # + +_TEMPLATES: dict[str, dict[str, Any]] = { + "endurance": { + "description": "Ruhige Grundlagenfahrt (Z2).", + "steps": [ + {"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.65}, + {"type": "SteadyState", "duration": 3000, "power": 0.68}, + {"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45}, + ], + }, + "sweetspot": { + "description": "3 Γ— 12 min Sweet Spot (88 % FTP).", + "steps": [ + {"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75}, + {"type": "IntervalsT", "repeat": 3, "onDuration": 720, "offDuration": 240, + "onPower": 0.88, "offPower": 0.55}, + {"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45}, + ], + }, + "vo2max": { + "description": "5 Γ— 3 min VO2max (115 % FTP).", + "steps": [ + {"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75}, + {"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180, + "onPower": 1.15, "offPower": 0.55}, + {"type": "Cooldown", "duration": 300, "powerLow": 0.65, "powerHigh": 0.45}, + ], + }, + "sprint": { + "description": "SIT: 6 Γ— 30 s all-out, 4 min Pause (wiki-verankertes Protokoll).", + "steps": [ + {"type": "Warmup", "duration": 900, "powerLow": 0.50, "powerHigh": 0.80}, + {"type": "IntervalsT", "repeat": 6, "onDuration": 30, "offDuration": 240, + "onPower": 1.50, "offPower": 0.40}, + {"type": "Cooldown", "duration": 420, "powerLow": 0.60, "powerHigh": 0.40}, + ], + }, +} + + +def from_template(name: str, title: str | None = None) -> dict[str, Any]: + """Return a deterministic workout spec from the built-in template set.""" + key = name.strip().lower() + if key not in _TEMPLATES: + raise ZwoValidationError(f"unknown template {name!r}; available: {sorted(_TEMPLATES)}") + spec = {k: (v.copy() if isinstance(v, list) else v) for k, v in _TEMPLATES[key].items()} + spec.update({"name": title or key.title(), "author": "IT Guy", "sportType": "bike", + "tags": ["zwift", key]}) + return spec + + +def templates() -> list[str]: + return sorted(_TEMPLATES) \ No newline at end of file diff --git a/skills/zwift-workout/tests/test_zwo.py b/skills/zwift-workout/tests/test_zwo.py new file mode 100644 index 0000000..87256d4 --- /dev/null +++ b/skills/zwift-workout/tests/test_zwo.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Focused tests for the zwo builder. Run: python3 tests/test_zwo.py (or pytest).""" + +import sys +import tempfile +import xml.etree.ElementTree as ET +from pathlib import Path + +SKILL_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(SKILL_DIR / "scripts")) + +import zwo_builder as zb # noqa: E402 + +SPEC = { + "name": "VO2 Max 5x3", + "author": "IT Guy", + "description": "5 Γ— 3 min @ 115 % FTP", + "sportType": "bike", + "tags": ["vo2max"], + "steps": [ + {"type": "Warmup", "duration": 600, "powerLow": 0.50, "powerHigh": 0.75}, + {"type": "IntervalsT", "repeat": 5, "onDuration": 180, "offDuration": 180, + "onPower": 1.15, "offPower": 0.55}, + {"type": "Cooldown", "duration": 300, "powerLow": 0.75, "powerHigh": 0.40}, + ], +} + + +def test_build_is_wellformed_and_roundtrips(): + xml = zb.spec_to_xml(SPEC) + root = ET.fromstring(xml) + assert root.tag == "workout_file" + assert root.findtext("sportType") == "bike" + workout = root.find("workout") + assert [c.tag for c in workout] == ["Warmup", "IntervalsT", "Cooldown"] + + parsed = zb.parse_file(_write_tmp(xml)) + assert parsed["name"] == "VO2 Max 5x3" + assert parsed["steps"][1]["repeat"] == 5 + assert parsed["steps"][1]["onPower"] == 1.15 + + +def test_power_out_of_range_raises(): + bad = dict(SPEC, steps=[{"type": "SteadyState", "duration": 600, "power": 3.0}]) + try: + zb.build_spec(bad) + except zb.ZwoValidationError as exc: + assert "implausible" in str(exc) + else: + raise AssertionError("expected ZwoValidationError") + + +def test_missing_required_field_raises(): + bad = dict(SPEC, steps=[{"type": "IntervalsT", "repeat": 5, "onDuration": 180, + "offDuration": 180, "onPower": 1.0}]) # offPower missing + try: + zb.build_spec(bad) + except zb.ZwoValidationError as exc: + assert "offPower" in str(exc) + else: + raise AssertionError("expected ZwoValidationError") + + +def test_sport_type_bike_only(): + bad = dict(SPEC, sportType="run") + try: + zb.build_spec(bad) + except zb.ZwoValidationError as exc: + assert "sportType" in str(exc) + else: + raise AssertionError("expected ZwoValidationError") + + +def test_escaping_special_chars(): + spec = dict(SPEC, name='Push & Pull "hart"', steps=[ + {"type": "SteadyState", "duration": 300, "power": 0.9}]) + xml = zb.spec_to_xml(spec) + parsed = ET.fromstring(xml) + assert parsed.findtext("name") == 'Push & Pull "hart"' + + +def test_estimate_tss_and_duration(): + est = zb.estimate(SPEC) + assert est["duration_s"] == 600 + 5 * 360 + 300 + assert 40 < est["tss"] < 120 + assert abs(est["np_frac"] - est["if"]) < 1e-9 + + +def test_templates_available(): + assert set(zb.templates()) >= {"endurance", "sweetspot", "vo2max", "sprint"} + spec = zb.from_template("vo2max", "Test") + assert spec["name"] == "Test" + zb.validate_spec(spec) + + +def test_asset_fixture_valid(): + asset = SKILL_DIR / "assets" / "example-vo2max.zwo" + ok, messages = zb.validate_file(str(asset)) + assert ok, messages + + +def test_render_contains_key_lines(): + text = zb.render_text(SPEC) + assert "VO2 Max 5x3" in text + assert "IntervalsT" in text + assert "TSS" in text + + +def _write_tmp(xml: str) -> str: + fd = tempfile.NamedTemporaryFile("w", suffix=".zwo", delete=False, encoding="utf-8") + fd.write(xml) + fd.close() + return fd.name + + +def _run_all(): + tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)] + failed = 0 + for t in tests: + try: + t() + print(f" βœ… {t.__name__}") + except Exception as exc: # noqa: BLE001 + failed += 1 + print(f" ❌ {t.__name__}: {exc}") + print(f"\n{len(tests) - failed}/{len(tests)} tests passed") + return 1 if failed else 0 + + +if __name__ == "__main__": + raise SystemExit(_run_all()) \ No newline at end of file -- 2.54.0 From d044eba97a59575808bfd947ba731d4450db1ca7 Mon Sep 17 00:00:00 2001 From: IT Guy Date: Sun, 20 Sep 2026 12:31:15 +0000 Subject: [PATCH 2/2] docs(skills): register zwift-workout skill in AGENTS index --- AGENTS.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index da8fea1..b5b4f95 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -88,6 +88,13 @@ Instant chart and diagram generation for chat and Telegram. Relentless, structured interview to stress-test and sharpen plans, architectures, decisions, or ideas before writing code. - **Features:** Decision tree & frontier exploration, Telegram/mobile-optimized batching (1-3 questions per round), facts-first environment research, anti-sycophancy challenge, and final specification/ADR synthesis. +### [zwift-workout](skills/zwift-workout/SKILL.md) +Builds, previews, validates and publishes Zwift `.zwo` workout files (bike-only). +- **Workflow:** draft JSON spec -> human-readable preview -> user confirms -> publish via hosting -> file into Knowledge Wiki. +- **Features:** Step types `Warmup`/`Cooldown`/`SteadyState`/`Ramp`/`IntervalsT`/`FreeRide`/`MaxEffort`/`SolidState`; power as FTP fractions; guardrails (0.20-1.50 FTP, XML escaping, round-trip parse check); TSS/duration estimate; templates (`endurance`, `sweetspot`, `vo2max`, `sprint`). +- **Tools:** CLI at `scripts/zwo.py` (`build`, `from-template`, `validate`, `parse`, `render`, `templates`) + builder at `scripts/zwo_builder.py`. +- **Integration:** Fitness agent / Tacx Flux 2; synergistic with `garmin-workout` (one spec, two targets). + ### [skill-creator](skills/skill-creator/SKILL.md) Guidelines and scripts to help developers build high-quality, token-efficient Claude skills. -- 2.54.0