Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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`
Expand Down
9 changes: 9 additions & 0 deletions config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,15 @@
"target_tsb": -25.0,
"alb_lower_bound": -40.0
},
"energy_guidelines": {
"chart_id": "custom_kj_chart",
"fitness_key": "kJ fitness",
"fatigue_key": "kJ fatigue",
"target_balance": -500.0,
"alb_lower_bound": -800.0,
"ctl_days": 42,
"atl_days": 7
},
"workout_templates": {
"endurance": {
"name": "Endurance",
Expand Down
110 changes: 79 additions & 31 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,12 @@ class IntervalsAPI:
def __init__(self, athlete_id, api_key):
if not athlete_id or not api_key:
raise ValueError("API credentials (ATHLETE_ID, API_KEY) not found in environment variables.")

# Accept athlete IDs with or without the leading 'i' slug from the web interface
athlete_id = str(athlete_id)
if athlete_id.startswith("i"):
athlete_id = athlete_id[1:]

self.auth = ("API_KEY", api_key)
self.athlete_url = f"{self.BASE_URL}/api/v1/athlete/{athlete_id}"
def get_current_state(self, for_date: date):
Expand All @@ -29,6 +35,32 @@ 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()
# Depending on the API version, series may be nested under different keys
series = chart.get('series') or chart.get('data', {})

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:
Expand Down Expand Up @@ -56,47 +88,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)

reason = "TSB Driven"
final_tss = tss_for_tsb_goal
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

if final_tss > tss_cap_from_alb:
final_tss = tss_cap_from_alb
load_cap_from_alb = current_fatigue - alb_lower

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) ---
# ==============================================================================
Expand Down Expand Up @@ -287,10 +320,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'}")

Expand Down