diff --git a/README.md b/README.md index ce01165..6304863 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,8 @@ This planner is built on the well-established principles of training load manage - **TSB (Training Stress Balance):** Calculated as `CTL - ATL`, this represents your "form" or "freshness." A negative TSB is typical during a build phase. This script aims to hold your TSB at a specific target level. - **ALB (Acute Load Balance):** Defined as `ATL (at the start of the day) - Daily TSS`. This acts as a "guard rail" to prevent excessively large single-day jumps in training stress, ensuring a smooth and sustainable progression. + +- **kJ Fitness/Fatigue (Optional):** If you maintain a custom Intervals.icu chart with 42‑day and 7‑day weighted averages of daily kilojoules, the script can read those values and compute energy-based guidelines for the next day's workload. These work like TSB and ALB but operate purely on kJ instead of TSS. ## Features @@ -102,6 +104,14 @@ This file holds all the non-sensitive parameters for your training plan. Edit th - `target_tsb`: The daily TSB you want to hold during your training block. - `alb_lower_bound`: The "floor" for daily training aggressiveness. A value of `-10` allows daily TSS to exceed the morning's ATL by about 10 points. + +#### `energy_guidelines` (optional) + +- `chart_id`: The identifier of your custom Intervals.icu chart containing kJ fitness and fatigue. +- `fitness_key` / `fatigue_key`: Series names inside that chart for the 42‑day and 7‑day moving averages of daily kJ. +- `target_balance`: Desired `fitness - fatigue` level expressed in kJ. +- `alb_lower_bound`: Minimum acceptable value for `fatigue - today's load` in kJ. +- `ctl_days` & `atl_days`: Time constants for the kJ fitness and fatigue averages (default 42/7). #### `workout_templates` diff --git a/config.json b/config.json index 1a85115..450967c 100644 --- a/config.json +++ b/config.json @@ -6,6 +6,15 @@ "target_tsb": -25.0, "alb_lower_bound": -40.0 }, + "energy_guidelines": { + "chart_id": "732435", + "fitness_key": "1", + "fatigue_key": "2", + "target_balance": -500.0, + "alb_lower_bound": -800.0, + "ctl_days": 42, + "atl_days": 7 + }, "workout_templates": { "endurance": { "name": "Endurance", diff --git a/custom_chart.json b/custom_chart.json new file mode 100644 index 0000000..8bef07a --- /dev/null +++ b/custom_chart.json @@ -0,0 +1,91 @@ +{ + "id": 732435, + "athlete_id": "i219371", + "type": "FITNESS_CHART", + "visibility": "PRIVATE", + "name": "WorkLoad", + "description": null, + "image": null, + "content": { + "id": "ijhre6nh", + "name": "WorkLoad", + "plots": [ + { + "id": 1, + "agg": "fitness_avg", + "min": 0, + "band": 0, + "fill": "rgba(102,51,204,0.3)", + "text": "Fitness", + "type": "line", + "field": "work", + "scale": "kJ", + "stack": "", + "title": "Total work", + "extras": [], + "filter": "round0", + "radius": 3, + "stroke": "#0e7bf180", + "filters": [], + "i18nKey": "Work", + "markerValue": "top", + "strokeWidth": 1, + "i18nTitleKey": "Total work", + "invertSubWellness": false + }, + { + "id": 2, + "agg": "fatique_avg", + "min": 0, + "band": 0, + "fill": "rgba(102,51,204,0.3)", + "text": "Fatigue", + "type": "line", + "field": "work", + "scale": "kJ", + "stack": "", + "title": "Total work", + "extras": [], + "filter": "round0", + "radius": 3, + "stroke": "#dd04a77d", + "filters": [], + "i18nKey": "Work", + "markerValue": "top", + "strokeWidth": 1, + "i18nTitleKey": "Total work", + "invertSubWellness": false + } + ], + "title": null, + "height": 220, + "yAxisMax": null, + "yAxisMin": null, + "y2AxisMax": null, + "y2AxisMin": null, + "y3AxisMax": null, + "y3AxisMin": null, + "yAxisLabel": null, + "y2AxisLabel": null, + "stackTo100Percent": null + }, + "usage_count": 0, + "index": 2, + "hide_script": false, + "hidden_by_id": null, + "updated": "2025-08-17T23:55:38.361+00:00", + "from_athlete": { + "id": null, + "name": null, + "profile_medium": "/no-profile-pic.png", + "city": null, + "state": null, + "country": null, + "timezone": null, + "sex": null, + "bio": null, + "website": null, + "email": null + }, + "from_id": null +} \ No newline at end of file diff --git a/main.py b/main.py index 688a04e..a913648 100644 --- a/main.py +++ b/main.py @@ -29,6 +29,30 @@ def get_current_state(self, for_date: date): return {"ctl": data.get('ctl'), "atl": data.get('atl')} except requests.exceptions.RequestException as e: print(f"ERROR: Could not connect to Intervals.icu API: {e}"); return None except json.JSONDecodeError: print(f"ERROR: Could not decode JSON response from API."); return None + + def get_custom_chart_state(self, chart_id, fitness_key, fatigue_key, for_date: date): + """Fetches custom chart values such as kJ fitness and kJ fatigue.""" + date_str = for_date.isoformat() + url = f"{self.athlete_url}/custom-item/{chart_id}?oldest={date_str}&newest={date_str}" + try: + response = requests.get(url, auth=self.auth, timeout=10) + response.raise_for_status() + chart = response.json() + series = chart.get('series', {}) + fitness_series = series.get(fitness_key, {}).get('data', []) + fatigue_series = series.get(fatigue_key, {}).get('data', []) + if not fitness_series or not fatigue_series: + return None + return { + "fitness": fitness_series[0].get('y'), + "fatigue": fatigue_series[0].get('y') + } + except requests.exceptions.RequestException as e: + print(f"ERROR: Could not fetch custom chart data: {e}") + return None + except json.JSONDecodeError: + print("ERROR: Could not decode JSON response for custom chart.") + return None def create_workout(self, workout_data: dict): url = f"{self.athlete_url}/events" try: @@ -56,47 +80,48 @@ def get_events(self, start_date: date, end_date: date): return [] # ============================================================================== -# --- CALCULATION ENGINE (Corrected) --- +# --- CALCULATION ENGINE --- # ============================================================================== -def calculate_next_day_tss(current_ctl, current_atl, goals_config): - """Calculates the target TSS and returns a dictionary with calculation details.""" - - # Get the configured time constants. - c = goals_config.get('ctl_days', 42) - a = goals_config.get('atl_days', 7) - target_tsb = goals_config.get('target_tsb', 0) - - # --- NEW: Generalized formula for any time constants --- - # This formula solves for the TSS needed tomorrow to hit the target TSB. - # It is derived from the core PMC equations: - # TSB_tomorrow = CTL_tomorrow - ATL_tomorrow - # CTL_tomorrow = CTL_today * (C-1)/C + TSS * 1/C - # ATL_tomorrow = ATL_today * (A-1)/A + TSS * 1/A - numerator = target_tsb - (current_ctl * (c - 1) / c) + (current_atl * (a - 1) / a) - denominator = (1 / c) - (1 / a) - - # Avoid division by zero if c == a - tss_for_tsb_goal = numerator / denominator if denominator != 0 else 0 - # --- END OF CORRECTION --- +def calculate_next_day_load(current_fitness, current_fatigue, goals_config): + """Calculates target load (e.g., kJ) using configurable balance guidelines.""" - tss_cap_from_alb = current_atl - goals_config['alb_lower_bound'] + c = goals_config.get('ctl_days') or goals_config.get('fitness_days', 42) + a = goals_config.get('atl_days') or goals_config.get('fatigue_days', 7) + target_balance = goals_config.get('target_tsb') or goals_config.get('target_balance', 0) + alb_lower = goals_config.get('alb_lower_bound', 0) + + numerator = target_balance - (current_fitness * (c - 1) / c) + (current_fatigue * (a - 1) / a) + denominator = (1 / c) - (1 / a) + load_for_balance_goal = numerator / denominator if denominator != 0 else 0 - reason = "TSB Driven" - final_tss = tss_for_tsb_goal + load_cap_from_alb = current_fatigue - alb_lower - if final_tss > tss_cap_from_alb: - final_tss = tss_cap_from_alb + final_load = load_for_balance_goal + reason = "Balance Driven" + if final_load > load_cap_from_alb: + final_load = load_cap_from_alb reason = "ALB Driven" - final_tss = max(0, final_tss) + final_load = max(0, final_load) return { - "final_tss": final_tss, - "tss_for_tsb_goal": tss_for_tsb_goal, - "tss_cap_from_alb": tss_cap_from_alb, + "final_load": final_load, + "load_for_balance_goal": load_for_balance_goal, + "load_cap_from_alb": load_cap_from_alb, "reason": reason } + +def calculate_next_day_tss(current_ctl, current_atl, goals_config): + """Wrapper to maintain TSS-specific terminology for existing functionality.""" + result = calculate_next_day_load(current_ctl, current_atl, goals_config) + return { + "final_tss": result["final_load"], + "tss_for_tsb_goal": result["load_for_balance_goal"], + "tss_cap_from_alb": result["load_cap_from_alb"], + "reason": result["reason"], + } + # ============================================================================== # --- Lightweight Projection Function (Unchanged) --- # ============================================================================== @@ -287,10 +312,25 @@ def main_handler(event, context): print(f"Fetching current state for user's local date: {today.isoformat()} ({user_timezone_str})") state = api.get_current_state(for_date=today) if not state: print("Halting script due to API error."); return - + current_ctl, current_atl = state['ctl'], state['atl'] print(f"Current State -> CTL: {current_ctl:.2f}, ATL: {current_atl:.2f}") + # Optional: fetch kJ-based fitness/fatigue from a custom chart + energy_cfg = config.get('energy_guidelines') + if energy_cfg: + chart_id = energy_cfg.get('chart_id') + fitness_key = energy_cfg.get('fitness_key', 'kJ fitness') + fatigue_key = energy_cfg.get('fatigue_key', 'kJ fatigue') + energy_state = api.get_custom_chart_state(chart_id, fitness_key, fatigue_key, today) + if energy_state: + kj_details = calculate_next_day_load(energy_state['fitness'], energy_state['fatigue'], energy_cfg) + print( + f"kJ Guidelines -> Target load for tomorrow: {kj_details['final_load']:.1f} ({kj_details['reason']})" + ) + else: + print("WARNING: Could not retrieve kJ fitness/fatigue data from custom chart.") + days_to_target = estimate_days_to_target(current_ctl, current_atl, config['training_goals']) print(f"Estimation -> Days to reach target CTL: {days_to_target if days_to_target != -1 else 'N/A'}")