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."
)
+1 -1
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",
-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
+26 -14
View File
@@ -54,28 +54,31 @@ descriptive comparison, not an overtraining diagnosis. Recording-level flags
require reduction to independent sessions; a baseline still needs ten runs.
`efforts` expands the persisted best elapsed windows. Use `where distance = 5 km`
and `min(effort_time)` for a 5 km progression. Whole runs expose `best_1km`,
`best_2km`, `best_5km`, and `best_10km`, which accept second/minute filters and
display elapsed min:sec. No attempt at that distance remains missing. These
and `min(effort_time)` for a 5 km progression. Whole runs expose
`best_time(1 km)`, `best_time(2 km)`, `best_time(5 km)`, and `best_time(10 km)`,
which accept second/minute filters and display elapsed min:sec. Unsupported
distances produce a validation error; supported distances without an attempt remain missing. These
efforts are windows inside runs, not standardized races.
```text
days
during last 16 weeks
long run distance >= 10 km
summarize latest(days_since_long_run), latest(rolling_28d_distance)
summarize latest(days_since_run(distance >= 10 km)), latest(rolling_sum(distance, 28 days))
by day
compare none
chart line
display overlay
```
`days` includes rest days. `sessions`, `days_since_run`, `days_since_long_run`,
`days` includes rest days. `sessions`, `days_since_run()`, `days_since_run(distance >= 10 km)`,
`run_day_streak`, and `long_run_share` expose continuity and concentration.
`long_run_share` is the largest individual run's share of the current seven-day
distance. Daily 7/28-day windows include the current local day plus the previous
6/27 local days, including history before the query. Whole-run rolling metrics
continue to use elapsed 168/672-hour windows. Missing prior long-run history
distance. Daily rolling windows include the current local day plus the previous
N−1 local days, including history before the query. Whole-run rolling expressions
use elapsed N×24-hour windows. `days_since_run(condition)` accepts one existing
numeric, availability or title condition, evaluated against individual runs before
query filters; `days_since_run()` matches any run. Multiple definitions can appear
in the same query. Missing prior qualifying history
does not become a fabricated gap. `latest(field)` selects the latest input by
timestamp regardless of sort order, retaining missing values on that input;
it is useful for current dashboard values and cannot define a baseline band.
@@ -253,9 +256,17 @@ let hr_count = rolling_count(heart_rate, 14 days)
compare previous year
```
Metric functions can appear directly in summaries, filters, sorting and `let`
calculations. For example, `min(best_time(5 km))` selects each month's fastest
5 km effort, while `latest(rolling_sum(distance, 28 days))` selects its latest
trailing distance. The editor obtains function suggestions from the same registry
as the parser. No field or clause is added for a new window or condition.
Rolling functions use complete owner-scoped run history before filters, include
the current run, and exclude the exact lower elapsed-time boundary. They accept
1–365 days and an earlier metric/calculation. Means are unweighted, sums exclude
1–365 days and an earlier metric/calculation. On daily sources, windows use local
calendar days; `rolling_sum(sessions, 7 days)` counts running sessions, while
`rolling_count(distance, 7 days)` counts days with usable distance. Means are unweighted, sums exclude
missing inputs, and counts count usable inputs. Calendar-bucket rolling trends
remain separate operations. `compare previous year` uses the same local-date
interval one year earlier, clamping leap-day boundaries; time charts align by
@@ -316,7 +327,7 @@ Each summary card shows usable-run coverage and its definition.
| Metric | Input unit(s) |
|---|---|
| `distance`, `distance_change`, `rolling_7d_distance`, `rolling_28d_distance` | `km`, `m` |
| `distance`, `distance_change`, `rolling_sum(distance, N days)` | `km`, `m` |
| `duration` | `h`, `min`, `s` |
| `pace` | `/km`, such as `5:30 /km` or `330 /km` |
| `pace_change` | `sec/km`, signed; negative is faster |
@@ -329,13 +340,13 @@ Each summary card shows usable-run coverage and its definition.
| `speed` | `km/h`, `m/s` |
| `gap_since_previous_run` | `days`, `h`, `min` |
| `run_day_streak` | `days` |
| `count`, `rolling_7d_count`, `rolling_28d_count` | `runs` |
| `count`, `rolling_count(distance, N days)` | `runs` |
| `elevation_per_km` | `m/km` |
| `calories` | `kcal` |
| `training_effect` | `score` |
Changes compare with the immediately preceding recorded run before filtering.
Rolling 7/28-day metrics include this run and earlier runs less than 168/672 hours
Rolling N-day expressions include this run and earlier runs less than N×24 hours
old; the exact boundary is excluded. Streaks count consecutive local calendar days
with a run. Multiple runs on one day don't lengthen the streak.
@@ -362,7 +373,8 @@ matching units. Pace arithmetic uses seconds/km, duration uses hours; division o
equal units gives `ratio`. Scalar multiplication keeps the field's unit. Combined
units appear in validation and completion, e.g. `(km/h/bpm)` for efficiency.
Missing inputs, zero divisors and nonfinite results yield missing values.
Expressions cannot call functions, access attributes, index values or execute code.
Expressions can call the registered metric functions described above. They cannot
call arbitrary functions, access attributes, index values or execute code.
## Filters and periods
+1 -1
View File
@@ -324,7 +324,7 @@ def test_previous_year_comparison(app):
"runs\nsummarize pace\nchart stacked",
"runs\nsummarize pace\ncolor by distance",
"runs\nsummarize pace\nreference heart_rate = 150 bpm",
"segments\nsummarize rolling_7d_distance",
"segments\nsummarize rolling_sum(distance, 7 days)",
"runs\nlet bad = rolling_mean(password, 10 days)\nsummarize bad",
],
)
+2 -2
View File
@@ -63,14 +63,14 @@ def test_predecessor_and_rolling_use_history_before_filters(app):
db.session.commit()
result = analyze(
"during 2026-09-09 to 2026-09-09\nwhere distance >= 5 km\n"
"summarize gap_since_previous_run, rolling_7d_distance, rolling_7d_count, run_day_streak\nby run\ncompare none"
"summarize gap_since_previous_run, rolling_sum(distance, 7 days), rolling_count(distance, 7 days), run_day_streak\nby run\ncompare none"
)
assert result["count"] == 1
assert result["rows"][0]["raw_values"] == [1, 11, 2, 2]
assert result["rows"][0]["previous_run"]["title"] == "intermediate"
# The exact 168-hour boundary is excluded from the rolling window.
result = analyze(
"during 2026-09-08 to 2026-09-08\nsummarize rolling_7d_distance\nby run\ncompare none"
"during 2026-09-08 to 2026-09-08\nsummarize rolling_sum(distance, 7 days)\nby run\ncompare none"
)
assert result["rows"][0]["raw_values"] == [1]
+113
View File
@@ -0,0 +1,113 @@
from datetime import UTC, datetime
import pytest
from app.analysis_migration import migrate, rewrite
from app.extensions import db
from app.models import Activity, SavedAnalysis, User
from app.stats import METRICS, explore, parse_query, query_text
from app.stats_dsl import QueryError
pytest_plugins = ('test_app',)
def parsed(text):
return parse_query({'query': text})
def test_removed_fields_and_clause_are_rejected():
removed = ['best_5km', 'best_10km', 'days_since_long_run', 'days_since_run',
'rolling_7d_distance', 'rolling_28d_count']
for field in removed:
assert field not in METRICS
with pytest.raises(QueryError):
parsed(f'runs\nsummarize {field}')
with pytest.raises(QueryError):
parsed('days\nlong run distance >= 10 km\nsummarize distance')
def test_expressions_in_filters_sort_calculations_and_named_summaries(app):
text = ('runs\nduring all time\nlet doubled = best_time(5000 m) * 2\n'
'where best_time(5000 m) < 31 min\n'
'summarize min(best_time(5000 m)) as "best_time(5 km)", doubled\n'
'sort by best_time(5000 m) asc\nby run\ncompare none')
q = parsed(text)
canonical = query_text(q)
assert 'analysis_expr_' not in canonical
assert 'as "best_time(5 km)"' in canonical
assert parsed(canonical) == q
with app.app_context():
row = db.session.get(Activity, 1)
row.best_efforts = [{'distance': 5000, 'seconds': 1800}]
db.session.commit()
result = explore(1, 'UTC', q)
assert result['count'] == 1
assert [c['value'] for c in result['cards']] == ['30:00', '60:00']
def test_multiple_since_conditions_use_individual_runs_and_prior_history(app):
with app.app_context():
db.session.query(Activity).delete()
db.session.add_all([
Activity(owner_id=owner, source_key=f'{owner}-{index}', title='Race ( A)',
sport='running', started_at=datetime(2026, 9, day, tzinfo=UTC),
distance_m=km * 1000, duration_s=1800, avg_hr=140)
for index, (owner, day, km) in enumerate([(1, 1, 20), (1, 3, 10), (1, 4, 6), (1, 4, 6), (2, 5, 30)])
])
db.session.commit()
q = parsed('days\nduring 2026-09-04 to 2026-09-05\nwhere distance >= 0 km\n'
'summarize latest(days_since_run(distance >= 10 km)), '
'latest(days_since_run(distance >= 20 km)), latest(days_since_run())\n'
'by day\ncompare none')
result = explore(1, 'UTC', q)
assert [g['raw_values'] for g in result['groups']] == [[1, 3, 0], [2, 4, 1]]
q = parsed('days\nduring 2026-09-05 to 2026-09-05\n'
'summarize latest(days_since_run(title contains "Race ( A)"))\n'
'by day\ncompare none')
assert parsed(query_text(q)) == q
assert explore(1, 'UTC', q)['groups'][0]['raw_values'] == [1]
@pytest.mark.parametrize('expression', ['best_time(3 km)', 'rolling_sum(distance, 0 days)',
'rolling_mean(unknown, 14 days)', 'days_since_run(distance >= 10 bpm)'])
def test_invalid_expression_reports_original_line(expression):
with pytest.raises(QueryError) as error:
parsed(f'days\nduring last 16 weeks\nsummarize latest({expression})')
assert error.value.line == 3
def test_migration_preserves_labels_settings_other_owners_and_is_idempotent(app):
old = ('days\nduring last 16 weeks\nlong run distance >= 20 km\n'
'summarize latest(days_since_long_run) as "days_since_long_run", '
'latest(rolling_7d_count)\nby day\ncompare none')
with app.app_context():
own = SavedAnalysis(owner_id=1, name='Continuity', query=old, pinned=True,
position=7, width='full', notes='Keep these notes')
other = SavedAnalysis(owner_id=2, name='Other owner', query=old)
db.session.add_all([own, other])
db.session.commit()
assert migrate(db.session.get(User, 1)) == 1
assert 'days_since_run(distance >= 20 km)' in own.query
assert 'rolling_sum(sessions, 7 days)' in own.query
assert 'as "days_since_long_run"' in own.query
assert own.pinned and own.position == 7 and own.width == 'full'
assert own.notes == 'Keep these notes' and other.query == old
assert migrate(db.session.get(User, 1)) == 0
assert rewrite(own.query) == own.query
def test_daily_rolling_session_count_includes_multiple_runs_and_rest_days(app):
with app.app_context():
db.session.query(Activity).delete()
db.session.add_all([
Activity(owner_id=1, source_key=str(index), title='Run', sport='running',
started_at=datetime(2026, 9, 1, index, tzinfo=UTC),
distance_m=5000, duration_s=1800)
for index in range(2)
])
db.session.commit()
q = parsed('days\nduring 2026-09-02 to 2026-09-02\n'
'summarize latest(rolling_sum(sessions, 28 days))\nby day\ncompare none')
result = explore(1, 'UTC', q)
assert result['groups'][0]['raw_values'] == [2]
assert result['cards'][0]['unit'] == 'runs'
+3 -3
View File
@@ -128,7 +128,7 @@ def test_daily_windows_include_rest_days_and_prior_history(app):
add_run("2026-09-03", 5000)
db.session.commit()
q = parsed("days\nduring 2026-09-02 to 2026-09-09\n"
"summarize latest(rolling_7d_distance), latest(run_day_streak), latest(days_since_long_run)\n"
"summarize latest(rolling_sum(distance, 7 days)), latest(run_day_streak), latest(days_since_run(distance >= 10 km))\n"
"by day\ncompare none")
result = explore(1, "UTC", q)
assert len(result["groups"]) == 8
@@ -137,7 +137,7 @@ def test_daily_windows_include_rest_days_and_prior_history(app):
assert result["cards"][0]["value"] == "5.0"
assert result["rows"][0]["id"] is None
assert result["coverage"]["sessions"] == 2
q["long_run"] = 20
q = parsed(query_text(q).replace("distance >= 10 km", "distance >= 20 km"))
assert explore(1, "UTC", q)["cards"][-1]["value"] == "—"
@@ -183,7 +183,7 @@ def test_efforts_and_performance_metrics_preserve_missing_distances(app):
result = explore(1, "UTC", q)
assert result["cards"][0]["value"] == "30:00"
assert result["rows"][0]["id"] == 1
q = parsed("runs\nduring all time\nsummarize min(best_5km), min(best_10km)\nby month\ncompare none")
q = parsed("runs\nduring all time\nsummarize min(best_time(5 km)), min(best_time(10 km))\nby month\ncompare none")
assert [c["value"] for c in explore(1, "UTC", q)["cards"]] == ["30:00", "—"]