"""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)