Replace fixed DSL fields with parameterized metric expressions

This commit is contained in:
Peter Stockings
2026-10-04 10:24:12 +11:00
parent 418e03583a
commit d660b86bb0
18 changed files with 402 additions and 137 deletions
+3
View File
@@ -90,6 +90,9 @@ def create_app(config=None):
from .training_signals import register_cli as register_training_cli
register_training_cli(app)
from .analysis_migration import register_cli as register_analysis_migration_cli
register_analysis_migration_cli(app)
@login_manager.user_loader
def load_user(user_id):
+18 -24
View File
@@ -16,15 +16,9 @@ from .stats_dsl import QueryError
def catalog_for(q):
catalog = dict(METRICS)
if q["source"] in {"days", "health"}:
for days in (7, 28):
for field in ("distance", "count"):
key = f"rolling_{days}d_{field}"
label, unit, _ = catalog[key]
catalog[key] = (label, unit, f"Complete recorded running {field} over this local day and the preceding {days - 1} local days, before filters. Rest days are included.")
for spec in q["derived"]:
catalog[spec["name"]] = (
spec["name"].replace("_", " ").capitalize(),
spec["expression"] if spec.get("inline") else spec["name"].replace("_", " ").capitalize(),
spec["unit"],
f"Per-run calculation: {spec['expression']}. Missing inputs and zero divisors produce missing data.",
)
@@ -80,7 +74,7 @@ def formatted(number, key, q):
return "—"
spec = q["aggregates"].get(key)
raw = spec["field"] if spec and spec["function"] not in {"count", "stddev", "sum"} else key
if raw in {"pace", "gap"} or raw == "effort_time" or raw.startswith("best_"):
if raw in {"pace", "gap", "effort_time"} or any(s["name"] == raw and s["unit"] == "time" for s in q["derived"]):
return format_value(number, "pace")
if key == "count" or (spec and spec["function"] == "count"):
return f"{number:,.0f}"
@@ -137,14 +131,15 @@ def ordered(rows, ordering):
def prepare(raw, zone, q, catalog):
rows, windows, totals = [], {7: deque(), 28: deque()}, {7: 0.0, 28: 0.0}
rows = []
previous, last_day, streak = None, None, 0
trees = {
spec["name"]: expression_tree(spec["expression"], catalog)[0]
for spec in q["derived"]
if "window" not in spec
if not any(key in spec for key in ("window", "since", "best_distance"))
}
custom_windows = {spec["name"]: deque() for spec in q["derived"] if "window" in spec}
last_matching = {}
for record in raw:
row = SimpleNamespace(**record._mapping)
row.instant = (
@@ -169,10 +164,6 @@ def prepare(raw, zone, q, catalog):
)
row.values.update({key: (row.session_context or {}).get(key)
for key in ("perceived_effort", "fatigue")})
for km in (1, 2, 5, 10):
efforts = [e.get("seconds") for e in row.best_efforts or []
if e.get("distance") == km * 1000 and valid(e.get("seconds"))]
row.values[f"best_{km}km"] = min(efforts) if efforts else None
for field in (
"pace",
"heart_rate",
@@ -196,20 +187,23 @@ def prepare(raw, zone, q, catalog):
):
now, before = row.values[field], previous.values[field] if previous else None
row.values[key] = now - before if valid(now) and valid(before) else None
for days in (7, 28):
window = windows[days]
while window and (row.instant - window[0].instant).total_seconds() >= days * 86400:
totals[days] -= window.popleft().distance_m / 1000
window.append(row)
totals[days] += row.distance_m / 1000
row.values[f"rolling_{days}d_distance"] = max(0.0, totals[days])
row.values[f"rolling_{days}d_count"] = len(window)
if row.day != last_day:
streak = streak + 1 if last_day and (row.day - last_day).days == 1 else 1
last_day = row.day
row.values["run_day_streak"] = streak
for spec in q["derived"]:
name = spec["name"]
if "best_distance" in spec:
efforts = [e.get("seconds") for e in row.best_efforts or []
if e.get("distance") == spec["best_distance"] and valid(e.get("seconds"))]
row.values[name] = min(efforts) if efforts else None
continue
if "since" in spec:
if matches(row, spec["since"]):
last_matching[name] = row.day
last = last_matching.get(name)
row.values[name] = (row.day - last).days if last else None
continue
if "window" not in spec:
row.values[name] = evaluate(trees[name], row.values)
continue
@@ -476,7 +470,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
number, coverage = summarize(current, key, q)
before, _ = summarize(prior, key, q)
title, unit, definition = descriptors[key]
if unit == "runs" and key != "sessions" and not key.startswith("rolling_"):
if unit == "runs" and (key == "count" or q["aggregates"].get(key, {}).get("function") == "count"):
unit = "runs" if q["reduce"] else q["source"]
cards.append(
{
@@ -539,7 +533,7 @@ def explore(owner_id, timezone, q, page=1, selected_bucket=None, export=False, r
return results
title, unit, _ = descriptors[key]
if unit == "runs" and key != "sessions" and not key.startswith("rolling_"):
if unit == "runs" and (key == "count" or q["aggregates"].get(key, {}).get("function") == "count"):
unit = "runs" if q["reduce"] else q["source"]
pace_axis = unit == "/km"
if q["trend"] == "percent_change":
+69
View File
@@ -0,0 +1,69 @@
"""One-time rewrite of saved queries to parameterized metric expressions."""
import re
import click
from .extensions import db
from .models import SavedAnalysis, User
from .stats import parse_query, query_text
def rewrite(query):
threshold = '10'
lines = []
for line in query.splitlines():
match = re.fullmatch(r'\s*long run distance >= (\d+(?:\.\d+)?) km\s*', line)
if match:
threshold = match[1]
else:
lines.append(line)
daily = any(line.strip() in {'days', 'health'} for line in lines)
replacements = {
'days_since_run': 'days_since_run()',
'days_since_long_run': f'days_since_run(distance >= {threshold} km)',
**{f'best_{km}km': f'best_time({km} km)' for km in (1, 2, 5, 10)},
**{f'rolling_{days}d_distance': f'rolling_sum(distance, {days} days)' for days in (7, 28)},
**{f'rolling_{days}d_count': (f'rolling_sum(sessions, {days} days)' if daily
else f'rolling_count(distance, {days} days)') for days in (7, 28)},
}
pattern = re.compile(r'\b(' + '|'.join(replacements) + r')\b(?!\s*\()')
result = []
for line in lines:
if not line.lstrip().startswith('#'):
parts = re.split(r'("(?:[^"\\]|\\.)*")', line)
for index in range(0, len(parts), 2):
parts[index] = pattern.sub(lambda m: replacements[m[0]], parts[index])
line = ''.join(parts)
result.append(line)
return '\n'.join(result)
def migrate(user):
changes = []
for row in db.session.scalars(db.select(SavedAnalysis).where(SavedAnalysis.owner_id == user.id)):
updated = rewrite(row.query)
if updated != row.query:
# Validate every replacement before modifying any persisted query.
canonical = query_text(parse_query({'query': updated}))
parse_query({'query': canonical})
changes.append((row, updated))
for row, updated in changes:
row.query = updated
db.session.commit()
return len(changes)
def register_cli(app):
@app.cli.command('migrate-analysis-expressions')
@click.option('--email', required=True)
def migrate_queries(email):
user = db.session.scalar(db.select(User).filter_by(email=email.strip().lower()))
if not user:
raise click.ClickException('Account not found.')
try:
count = migrate(user)
except ValueError as error:
db.session.rollback()
raise click.ClickException(str(error)) from None
click.echo(f'Updated {count} saved analyses.')
+2
View File
@@ -35,6 +35,7 @@ from .services import activity_data, import_cache, import_upload, route_data
from .stats import DEFAULT, METRICS, explore, parse_query, period_bounds, query_text
from .stats_analysis import EXTRA_UNITS, FUNCTIONS, GROUPS
from .stats_dsl import UNITS
from .stats_expressions import FUNCTIONS as METRIC_FUNCTIONS
web = Blueprint("web", __name__)
DUMMY_HASH = generate_password_hash("not-a-real-account-password")
@@ -241,6 +242,7 @@ def stats():
presets=PRESETS,
language={
"functions": sorted(FUNCTIONS),
"metric_functions": METRIC_FUNCTIONS,
"groups": sorted(GROUPS),
"fields": {
key: {
+6 -3
View File
@@ -31,7 +31,9 @@
['days', 'Daily workload and rest days, using local calendar windows'],
['health', 'Private daily recovery observations from Garmin'],
['reduce by run min 3 min', 'Match recording windows first; one observation per qualifying session'],
['long run distance >= 10 km', 'Choose the qualifying long-run threshold for daily continuity'],
['summarize latest(days_since_run(distance >= 10 km))', 'Days since a run matching your condition, including prior history'],
['summarize min(best_time(5 km))', 'Fastest recorded effort at a distance'],
['summarize latest(rolling_sum(distance, 28 days))', 'Trailing distance using a configurable window'],
['route similar to 1', 'Approximate full-course match to one of your own recorded routes'],
['where elapsed >= 10 min', 'Exclude the first ten elapsed recording minutes'],
['and grade between -2 and 2 %', 'Require approximately flat smoothed terrain'],
@@ -90,7 +92,7 @@
if (stream.match(/[a-zA-Z_][a-zA-Z0-9_]*/)) {
const word = stream.current();
if (names.has(word) || availableMetrics().some(metric=>metric.name===word)) return 'variable-2';
if (language.functions.includes(word)) return 'builtin';
if (language.functions.includes(word) || word in language.metric_functions) return 'builtin';
if (/^(runs|during|last|ending|where|and|or|between|summarize|by|compare|previous|period|none|has|missing|title|contains|sort|asc|desc|to|let|having|rank|take|limit|trend|rolling|cumulative|change|percent_change|plot|vs|chart|bins|all|time|baseline|flag|sd|min|sustained|buckets|above|below|outside|display|overlay|separate|color|size|reference|as)$/.test(word)) return 'keyword';
if (/^(day|days|week|weeks|month|months|km|m|bpm|spm|W|ms|h|min|s|date)$/.test(word)) return 'atom';
return null;
@@ -118,6 +120,7 @@
} else if (/^\s*(summarize|having|rank by|let \w+ =)\s/.test(line) || /^\s*plot\s+(?:\w+ vs )?\w*$/.test(line) || /^\s*sort by\s+\w*$/.test(line) || /^\s*(where|and|or)\s+(?:(has|missing)\s+)?\w*$/.test(line)) {
const used=/^\s*summarize\s/.test(line)?new Set(before.replace(/^\s*summarize\s+/,'').split(',').map(name=>name.trim())):new Set();
list = availableMetrics().filter(metric=>metric.name.startsWith(token)&&!used.has(metric.name)).map(metric=>({text:metric.name + (/\(\w*$/.test(line) && cm.getLine(cursor.line)[cursor.ch] !== ')' ? ')' : ''), detail:`${metric.unit} · run metric`}));
Object.entries(language.metric_functions).filter(([name])=>name.startsWith(token)).forEach(([,spec])=>list.push({text:spec.example,detail:spec.description}));
if (/^\s*(summarize|having|rank by)\s/.test(line) && !/\(\w*$/.test(line)) language.functions.filter(name=>name.startsWith(token)).forEach(name=>list.push({text:name==='count'?'count()':name+'(',detail:'Statistical function · available values only'}));
if (/sort by/.test(line) && 'date'.startsWith(token)) list.push({text:'date',detail:'Local run date'});
if (/^\s*(where|and|or)\s+\w*$/.test(line)) ['has','missing','title'].forEach(word=>{if(word.startsWith(token))list.push({text:word,detail:'Filter recorded data'});});
@@ -130,7 +133,7 @@
list = ['asc','desc'].filter(text=>text.startsWith(token)).map(text=>({text,detail:'Evidence order'}));
} else if (/^\s*(where|and|or)\s+\w+\s+(?:>=|<=|>|<|=|!=|between)\s*$/.test(line)) {
const field=line.trim().split(/\s+/)[1];
const values={distance:'8 km',pace:'5:30 /km',duration:'45 min',heart_rate:'145 bpm',cadence:'175 spm',power:'250 W',elevation:'100 m',ground_contact:'250 ms',stride_length:'1 m',gap_since_previous_run:'7 days',speed:'12 km/h',rolling_7d_distance:'30 km',run_day_streak:'3 days'};
const values={distance:'8 km',pace:'5:30 /km',duration:'45 min',heart_rate:'145 bpm',cadence:'175 spm',power:'250 W',elevation:'100 m',ground_contact:'250 ms',stride_length:'1 m',gap_since_previous_run:'7 days',speed:'12 km/h',run_day_streak:'3 days'};
const between=/between\s*$/.test(line);
const ranges={distance:'8 and 20 km',pace:'5:00 and 6:00 /km',duration:'30 and 60 min',heart_rate:'140 and 150 bpm',cadence:'170 and 185 spm',power:'200 and 300 W',elevation:'50 and 150 m',ground_contact:'230 and 270 ms',stride_length:'0.8 and 1.2 m'};
const metric=availableMetrics().find(metric=>metric.name===field); const example=values[field] || (metric?.units.length ? `5 ${metric.units[0]}` : null); list=example?[{text:between?(ranges[field] || `5 and 10 ${metric.units[0]}`):example,detail:'Example value · adjust to your question'}]:[];
+6 -6
View File
@@ -71,7 +71,6 @@ DEFAULT = {
"source": "runs",
"cohorts": [],
"reduce": None,
"long_run": 10.0,
"route": None,
"comparison": "period",
}
@@ -137,12 +136,11 @@ def query_text(q):
lines = [q["source"], " " + period]
if q["reduce"]:
lines.append(f" reduce by run min {number_text(q['reduce'])} min")
if q["long_run"] != 10:
lines.append(f" long run distance >= {number_text(q['long_run'])} km")
if q["route"]:
lines.append(f" route similar to {q['route']}")
for spec in q["derived"]:
lines.append(f" let {spec['name']} = {spec['expression']}")
if not spec.get("inline"):
lines.append(f" let {spec['name']} = {spec['expression']}")
if q["end"] and not q["start"]:
lines.append(f" ending {q['end']}")
if not q["filter_groups"] or q["minimum"]:
@@ -154,7 +152,7 @@ def query_text(q):
):
if q[key] is not None:
lines.append(f" and {clause} {number_text(q[key])} {unit}")
units = {**UNITS, **EXTRA_UNITS, **{spec["name"]: {spec["unit"]: 1} for spec in q["derived"]}}
units = {**UNITS, **EXTRA_UNITS, **{spec["name"]: ({"s": 1, "min": 60} if spec["unit"] == "time" else {spec["unit"]: 1}) for spec in q["derived"]}}
def clause(condition, prefix="and"):
field, op = condition["field"], condition["op"]
@@ -252,7 +250,9 @@ def query_text(q):
lines.append(
f" cohort {json.dumps(cohort['name'], ensure_ascii=False)} where {cohort['source']}"
)
return "\n".join(lines)
from .stats_expressions import render
return render("\n".join(lines), q["derived"])
def query_metrics(q):
+12 -38
View File
@@ -68,26 +68,6 @@ EXTRA_METRICS = {
"bpm",
"Average heart rate minus the previous run's average, before filtering.",
),
"rolling_7d_distance": (
"7-day distance",
"km",
"Distance in the preceding 168 hours, including this run. Calculated before filters.",
),
"rolling_28d_distance": (
"28-day distance",
"km",
"Distance in the preceding 672 hours, including this run. Calculated before filters.",
),
"rolling_7d_count": (
"7-day run count",
"runs",
"Number of runs in the preceding 168 hours, including this run, before filters.",
),
"rolling_28d_count": (
"28-day run count",
"runs",
"Number of runs in the preceding 672 hours, including this run, before filters.",
),
"run_day_streak": (
"Consecutive running days",
"days",
@@ -122,10 +102,6 @@ EXTRA_UNITS = {
"distance_change": {"km": 1, "m": 0.001},
"pace_change": {"sec/km": 1},
"heart_rate_change": {"bpm": 1},
"rolling_7d_distance": {"km": 1, "m": 0.001},
"rolling_28d_distance": {"km": 1, "m": 0.001},
"rolling_7d_count": {"runs": 1},
"rolling_28d_count": {"runs": 1},
"run_day_streak": {"days": 1},
"elevation_per_km": {"m/km": 1},
"max_heart_rate": {"bpm": 1},
@@ -231,8 +207,10 @@ def evaluate(tree, values):
def parse_analysis(text, metrics, defaults):
if not isinstance(text, str) or len(text) > 4000:
raise QueryError("Keep the query under 4,000 characters.")
from .stats_expressions import calculation, lower
from .stats_features import extract, validate
text, expressions = lower(text)
text, features = extract(text)
catalog, units = dict(metrics), {**UNITS, **EXTRA_UNITS}
lines = text.splitlines()
@@ -280,6 +258,13 @@ def parse_analysis(text, metrics, defaults):
for index, source in enumerate(lines):
line, number = source.strip(), index + 1
for name, expression in expressions.get(number, []):
spec, conversions = calculation(name, expression, catalog, units, defaults, number)
if len(derived) >= 32:
raise QueryError("Use at most 32 metric expressions.", number)
catalog[name] = (expression, spec["unit"], f"Per-input expression: {expression}.")
units[name] = conversions
derived.append(spec)
if not line or line.startswith("#"):
continue
if match := re.fullmatch(r"let (\w+) = (.+)", line):
@@ -288,30 +273,19 @@ def parse_analysis(text, metrics, defaults):
not re.fullmatch(r"[a-z][a-z0-9_]{0,39}", name)
or name.startswith("analysis_")
or name in catalog
or len(derived) >= 8
or sum(not s.get("inline") for s in derived) >= 8
):
raise QueryError(
"Choose a new lowercase metric name; at most eight calculations.", number
)
window = re.fullmatch(r"rolling_(mean|sum|count)\((\w+), (\d+) days\)", expression)
if window:
function, field, days = window.groups()
if field not in catalog or not 1 <= int(days) <= 365:
raise QueryError(
"Rolling functions need an earlier metric and 1–365 days.", number
)
unit = "runs" if function == "count" else catalog[field][1]
else:
_, unit = expression_tree(expression, catalog, number)
_, unit = expression_tree(expression, catalog, number)
catalog[name] = (
name.replace("_", " ").capitalize(),
unit,
f"Calculated per run: {expression}. Missing inputs or division by zero give missing data.",
)
units[name] = {unit: 1}
units[name] = {"s": 1, "min": 60} if unit == "time" else {unit: 1}
spec = {"name": name, "expression": expression, "unit": unit}
if window:
spec["window"] = {"function": function, "field": field, "days": int(days)}
derived.append(spec)
lines[index] = ""
elif line == "during all time":
+110
View File
@@ -0,0 +1,110 @@
"""Parameterized metric expressions, lowered to private calculation names."""
import re
from .stats_dsl import NUMBER, QueryError, parse_dsl
FUNCTIONS = {
'best_time': {'example': 'best_time(5 km)', 'description': 'Fastest recorded elapsed effort at a distance'},
'days_since_run': {'example': 'days_since_run(distance >= 10 km)', 'description': 'Local days since a run matching a condition; empty parentheses match any run'},
'rolling_sum': {'example': 'rolling_sum(distance, 28 days)', 'description': 'Trailing sum before filters'},
'rolling_mean': {'example': 'rolling_mean(heart_rate, 14 days)', 'description': 'Trailing mean before filters'},
'rolling_count': {'example': 'rolling_count(distance, 7 days)', 'description': 'Count usable inputs in a trailing window'},
}
CALL = re.compile(r'\b(' + '|'.join(FUNCTIONS) + r')\s*\(')
def lower(text):
declarations, names, lines = {}, {}, []
for number, line in enumerate(text.splitlines(), 1):
output, index = [], 0
while index < len(line):
if line[index] == '"':
end = index + 1
while end < len(line):
if line[end] == '\\':
end += 2
elif line[end] == '"':
end += 1
break
else:
end += 1
output.append(line[index:end])
index = end
continue
match = CALL.match(line, index) if not line.lstrip().startswith('#') else None
if not match:
output.append(line[index])
index += 1
continue
end, depth, quoted, escaped = match.end(), 1, False, False
while end < len(line):
char = line[end]
if escaped:
escaped = False
elif char == '\\' and quoted:
escaped = True
elif char == '"':
quoted = not quoted
elif not quoted:
depth += (char == '(') - (char == ')')
if depth == 0:
break
end += 1
if depth:
raise QueryError('Close the metric expression with ).', number)
# Preserve whitespace in quoted title conditions.
parts = re.split(r'("(?:[^"\\]|\\.)*")', line[index:end + 1])
for part in range(0, len(parts), 2):
parts[part] = re.sub(r'\s+', ' ', parts[part])
expression = re.sub(r'^(\w+)\s+\(', r'\1(', ''.join(parts).strip())
if expression not in names:
names[expression] = f'analysis_expr_{len(names)}'
declarations.setdefault(number, []).append((names[expression], expression))
output.append(names[expression])
index = end + 1
lines.append(''.join(output))
return '\n'.join(lines), declarations
def calculation(name, expression, catalog, units, defaults, line):
if match := re.fullmatch(r'best_time\(\s*(' + NUMBER + r')\s+(km|m)\s*\)', expression):
distance = float(match[1]) * (1000 if match[2] == 'km' else 1)
if distance not in {1000, 2000, 5000, 10000}:
raise QueryError('Recorded best times support 1, 2, 5 or 10 km.', line)
config, unit, conversions = {'best_distance': distance}, 'time', {'s': 1, 'min': 60}
elif match := re.fullmatch(r'days_since_run\(\s*(.*?)\s*\)', expression):
condition = match[1]
if not condition:
conditions = []
else:
try:
checked = parse_dsl('runs\nwhere ' + condition + '\nsummarize count',
catalog, {**defaults, 'minimum': None}, units)
except QueryError as error:
raise QueryError(str(error), line) from None
conditions = list(checked['filters'])
for field, key, op in [('distance', 'minimum', '>='), ('distance', 'maximum', '<='),
('heart_rate', 'hr_min', '>='), ('heart_rate', 'hr_max', '<=')]:
if checked[key] is not None:
conditions.append({'field': field, 'op': op, 'value': checked[key]})
config, unit, conversions = {'since': conditions}, 'days', {'days': 1, 'h': 1 / 24}
elif match := re.fullmatch(r'rolling_(sum|mean|count)\(\s*(\w+)\s*,\s*(\d+) days\s*\)', expression):
function, field, days = match.groups()
if field not in catalog or not 1 <= int(days) <= 365:
raise QueryError('Rolling functions need an earlier metric and 1–365 days.', line)
unit = 'runs' if function == 'count' else catalog[field][1]
conversions = {'runs': 1} if function == 'count' else units[field]
config = {'window': {'function': function, 'field': field, 'days': int(days)}}
else:
raise QueryError('Use best_time(5 km), days_since_run(distance >= 10 km), or rolling_sum(distance, 28 days).', line)
return {'name': name, 'expression': expression, 'unit': unit, 'inline': True, **config}, conversions
def render(text, specs):
expressions = {s['name']: s['expression'] for s in specs if s.get('inline')}
# Private names are substituted only outside quoted labels and title fragments.
parts = re.split(r'("(?:[^"\\]|\\.)*")', text)
for index in range(0, len(parts), 2):
parts[index] = re.sub(r'\banalysis_expr_\d+\b', lambda m: expressions.get(m[0], m[0]), parts[index])
return ''.join(parts)
+4 -13
View File
@@ -38,7 +38,6 @@ def extract(text):
"cohorts": [],
"comparison": "period",
"reduce": None,
"long_run": 10.0,
"route": None,
}
seen, lines = set(), text.splitlines()
@@ -61,12 +60,6 @@ def extract(text):
if not 1 <= minutes <= 180:
raise QueryError("Use 1–180 matched minutes per run.", number)
options["reduce"] = minutes
elif match := re.fullmatch(r"long run distance >= (\d+(?:\.\d+)?) km", line):
once("long_run", number)
distance = float(match[1])
if not 1 <= distance <= 100:
raise QueryError("Use a long-run threshold of 1–100 km.", number)
options["long_run"] = distance
elif match := re.fullmatch(r"route similar to (\d+)", line):
once("route", number)
options["route"] = int(match[1])
@@ -156,6 +149,8 @@ def extract(text):
def validate(q, catalog):
if q["source"] in {"days", "health"} and any("best_distance" in s for s in q["derived"]):
raise QueryError("best_time requires the runs source.")
if q["flag"] and q["source"] in {"laps", "segments", "intervals"} and not q["reduce"]:
raise QueryError("Reduce recordings to independent runs before using a deviation flag.")
if q["reduce"] and q["source"] not in {"intervals", "segments"}:
@@ -201,10 +196,6 @@ def validate(q, catalog):
"distance_change",
"pace_change",
"heart_rate_change",
"rolling_7d_distance",
"rolling_28d_distance",
"rolling_7d_count",
"rolling_28d_count",
"run_day_streak",
}
fields = {
@@ -226,13 +217,13 @@ def validate(q, catalog):
if q["bin"]:
fields.add(q["bin"]["field"])
for spec in q["derived"]:
if "window" not in spec:
if not any(key in spec for key in ("window", "since", "best_distance")):
fields.update(
node.id
for node in ast.walk(ast.parse(spec["expression"], mode="eval"))
if isinstance(node, ast.Name)
)
if fields & temporal or any("window" in spec for spec in q["derived"]):
if fields & temporal or any(any(key in spec for key in ("window", "since", "best_distance")) for spec in q["derived"]):
raise QueryError(
"Run-history metrics and rolling calculations require the runs source."
)
+2 -2
View File
@@ -47,7 +47,7 @@ PRESETS = [
"name": "Workload context",
"category": "Training",
"description": "Rolling 7- and 28-day distance alongside every run.",
"query": "runs\n during last 12 weeks\n summarize rolling_7d_distance, rolling_28d_distance\n by run\n sort by date asc\n compare none\n chart line\n display overlay",
"query": "runs\n during last 12 weeks\n summarize rolling_sum(distance, 7 days), rolling_sum(distance, 28 days)\n by run\n sort by date asc\n compare none\n chart line\n display overlay",
},
{
"name": "Weekly volume",
@@ -99,4 +99,4 @@ from .training_signals import signal_queries
PRESETS.extend({"name": d["name"], "category": "Deep training signals",
"description": d["notes"], "query": d["query"]}
for d in signal_queries())
for d in signal_queries())
-7
View File
@@ -22,15 +22,8 @@ for key, label in (("grade", "Smoothed grade"), ("grade_variability", "Grade var
metric(key, label, "%", "Recording-derived percentage; missing observations stay missing.")
metric("matched_windows", "Matched windows", "windows", "Usable continuous recording windows.")
metric("sessions", "Sessions", "runs", "Number of recorded running sessions on this local day.")
metric("days_since_run", "Days since running", "days", "Local days since the latest recorded run.")
metric("days_since_long_run", "Days since long run", "days",
"Local days since a recorded run meeting the query's long-run distance threshold.")
metric("effort_time", "Best effort time", "time", "Elapsed recorded best-effort window, in seconds.",
{"s": 1, "min": 60})
for km in (1, 2, 5, 10):
metric(f"best_{km}km", f"Best {km} km", "time",
"Fastest elapsed window within this run, not a standardized race result.",
{"s": 1, "min": 60})
for key, label, unit in (("resting_hr", "Resting HR", "bpm"),
("hrv", "Overnight HRV", "ms"),
("sleep_hours", "Sleep", "h"),
+4 -5
View File
@@ -23,11 +23,10 @@ def signal_queries(pace_low="6:30", pace_high="7:00", long_run=10, route_id=None
'summarize sum(distance) as "Weekly km", count() as "Sessions"\nby week\ncompare none\nchart bar\ndisplay overlay',
"half", "Weeks start Monday. The current week is incomplete. Recorded running only; other training or unimported runs may explain gaps."),
("Performance ladder", "runs\n" + common +
'summarize min(best_5km) as "Best 5 km", min(best_10km) as "Best 10 km"\nby month\ncompare none\nchart line\ndisplay overlay',
'summarize min(best_time(5 km)) as "Best 5 km", min(best_time(10 km)) as "Best 10 km"\nby month\ncompare none\nchart line\ndisplay overlay',
"half", "Fastest recorded elapsed windows inside each month's activities. Missing distances stay missing. These are not standardized races or terrain-matched performances."),
("Long-run continuity", "days\n" + common +
f"long run distance >= {long_run:g} km\n" +
'summarize latest(days_since_long_run) as "Days since long run", latest(rolling_28d_distance) as "28-day km"\nby day\ncompare none\nchart line\ndisplay overlay',
f'summarize latest(days_since_run(distance >= {long_run:g} km)) as "Days since long run", latest(rolling_sum(distance, 28 days)) as "28-day km"\nby day\ncompare none\nchart line\ndisplay overlay',
"half", f"Long run means at least {long_run:g} km; edit this threshold. Daily values advance on rest days. No earlier qualifying run is missing rather than a fabricated gap. The 28-day window includes today and the preceding 27 local days."),
("Endurance fade", "intervals\n" + common +
"reduce by run min 20 min\nwhere elapsed >= 10 min\nand duration >= 25 s\n"
@@ -37,13 +36,13 @@ def signal_queries(pace_low="6:30", pace_high="7:00", long_run=10, route_id=None
'summarize heart_rate_drift as "Post-warm-up decoupling"\nby run\nsort by date asc\ncompare none\nchart line\nreference heart_rate_drift = 0 %',
"half", "Matched moving-time halves after warm-up, with at least ten minutes of usable HR and terrain in each half. Hidden when halves differ more than 10% in speed or one percentage point in mean grade. Filtered windows need not be adjacent; the result remains descriptive."),
("Daily workload and consistency", "days\n" + common +
'summarize latest(rolling_7d_distance), latest(rolling_28d_distance), latest(run_day_streak)\nby day\ncompare none\nchart line\ndisplay overlay',
'summarize latest(rolling_sum(distance, 7 days)), latest(rolling_sum(distance, 28 days)), latest(run_day_streak)\nby day\ncompare none\nchart line\ndisplay overlay',
"half", "Daily workload includes rest days, uses local calendar windows and includes all recorded runs before filters. Streak returns to zero on a rest day."),
("Longest run each week", "runs\n" + common +
'summarize max(distance), count()\nby week\ncompare none\nchart bar',
"half", "Weekly longest recorded distance and session count; inspect the full week's volume for concentration."),
("Short-distance performance", "runs\n" + common +
'summarize min(best_1km), min(best_2km)\nby month\ncompare none\nchart line\ndisplay overlay',
'summarize min(best_time(1 km)), min(best_time(2 km))\nby month\ncompare none\nchart line\ndisplay overlay',
"half", "Fastest recorded 1 km and 2 km elapsed windows; effort and terrain may differ."),
("Recovery context", "health\n" + common +
'summarize resting_hr, hrv, sleep_hours, stress\nby day\ncompare none\nchart line',
+19 -17
View File
@@ -18,41 +18,41 @@ def daily(history, owner_id, zone, start, end, q, catalog):
db.select(DailyHealth).where(DailyHealth.owner_id == owner_id,
DailyHealth.day >= start, DailyHealth.day < end))}
result = []
last_run = last_long = None
last_matching = {}
streak = 0
earlier = [r for r in history if r.day < start]
if earlier:
last_run = max(r.day for r in earlier)
longs = [r.day for r in earlier if r.distance_m >= q["long_run"] * 1000]
last_long = max(longs) if longs else None
day = start - timedelta(days=1)
while by_day[day]:
streak += 1
day -= timedelta(days=1)
from .analysis_engine import matches
since_specs = [s for s in q["derived"] if "since" in s]
for spec in since_specs:
qualifying = [r.day for r in earlier if matches(r, spec["since"])]
last_matching[spec["name"]] = max(qualifying) if qualifying else None
for index in range((end - start).days):
day = start + timedelta(days=index)
rows = by_day[day]
if rows:
last_run = day
streak += 1
else:
streak = 0
if any(r.distance_m >= q["long_run"] * 1000 for r in rows):
last_long = day
values = {key: None for key in catalog}
distance = sum(r.distance_m for r in rows)
duration = sum(r.duration_s for r in rows)
values.update(distance=distance / 1000, duration=duration / 3600,
sessions=len(rows), count=1, run_day_streak=streak,
days_since_run=(day - last_run).days if last_run else None,
days_since_long_run=(day - last_long).days if last_long else None)
for days in (7, 28):
window = [r for d in range(days) for r in by_day[day - timedelta(days=d)]]
values[f"rolling_{days}d_distance"] = sum(r.distance_m for r in window) / 1000
values[f"rolling_{days}d_count"] = len(window)
if days == 7:
total = sum(r.distance_m for r in window)
values["long_run_share"] = max(r.distance_m for r in window) / total * 100 if total else None
sessions=len(rows), count=1, run_day_streak=streak)
window = [r for d in range(7) for r in by_day[day - timedelta(days=d)]]
total = sum(r.distance_m for r in window)
values["long_run_share"] = max(r.distance_m for r in window) / total * 100 if total else None
for spec in since_specs:
name = spec["name"]
if any(matches(r, spec["since"]) for r in rows):
last_matching[name] = day
last = last_matching[name]
values[name] = (day - last).days if last else None
for field in ("perceived_effort", "fatigue"):
known = [r.values[field] for r in rows if valid(r.values.get(field))]
values[field] = sum(known) / len(known) if known else None
@@ -69,6 +69,8 @@ def daily(history, owner_id, zone, start, end, q, catalog):
activity_id=None, activity_ids=[r.id for r in rows])
result.append(row)
for spec in q["derived"]:
if "since" in spec:
continue
if "window" in spec:
config = spec["window"]
from .stats_analysis import statistic