diff --git a/app/__init__.py b/app/__init__.py index 7e66ee9..3551e6b 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -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): diff --git a/app/analysis_engine.py b/app/analysis_engine.py index 7471939..ec4548b 100644 --- a/app/analysis_engine.py +++ b/app/analysis_engine.py @@ -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": diff --git a/app/analysis_migration.py b/app/analysis_migration.py new file mode 100644 index 0000000..5decbd4 --- /dev/null +++ b/app/analysis_migration.py @@ -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.') diff --git a/app/routes.py b/app/routes.py index 377550c..d6179f2 100644 --- a/app/routes.py +++ b/app/routes.py @@ -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: { diff --git a/app/static/stats-editor.js b/app/static/stats-editor.js index 38ce38f..dcfce52 100644 --- a/app/static/stats-editor.js +++ b/app/static/stats-editor.js @@ -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'}]:[]; diff --git a/app/stats.py b/app/stats.py index 10f271b..5fc5e64 100644 --- a/app/stats.py +++ b/app/stats.py @@ -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): diff --git a/app/stats_analysis.py b/app/stats_analysis.py index 8bf77fd..1b94d64 100644 --- a/app/stats_analysis.py +++ b/app/stats_analysis.py @@ -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": diff --git a/app/stats_expressions.py b/app/stats_expressions.py new file mode 100644 index 0000000..14aa42f --- /dev/null +++ b/app/stats_expressions.py @@ -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) diff --git a/app/stats_features.py b/app/stats_features.py index fcf580a..2abc4e3 100644 --- a/app/stats_features.py +++ b/app/stats_features.py @@ -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." ) diff --git a/app/stats_presets.py b/app/stats_presets.py index edf136f..548dad0 100644 --- a/app/stats_presets.py +++ b/app/stats_presets.py @@ -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()) \ No newline at end of file diff --git a/app/training_metrics.py b/app/training_metrics.py index fbaed39..3627065 100644 --- a/app/training_metrics.py +++ b/app/training_metrics.py @@ -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"), diff --git a/app/training_signals.py b/app/training_signals.py index 205d016..264b774 100644 --- a/app/training_signals.py +++ b/app/training_signals.py @@ -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', diff --git a/app/training_sources.py b/app/training_sources.py index 8a02c8f..cb7b1f0 100644 --- a/app/training_sources.py +++ b/app/training_sources.py @@ -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 diff --git a/docs/STATS_DSL.md b/docs/STATS_DSL.md index cfe3cdf..c2e4ffe 100644 --- a/docs/STATS_DSL.md +++ b/docs/STATS_DSL.md @@ -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 diff --git a/tests/test_analysis_workspace.py b/tests/test_analysis_workspace.py index e0bcd23..1a6563c 100644 --- a/tests/test_analysis_workspace.py +++ b/tests/test_analysis_workspace.py @@ -324,10 +324,10 @@ 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", ], ) def test_invalid_analysis_features(source): with pytest.raises(QueryError): - parsed(source) + parsed(source) \ No newline at end of file diff --git a/tests/test_stats_analysis.py b/tests/test_stats_analysis.py index c630533..5670a2f 100644 --- a/tests/test_stats_analysis.py +++ b/tests/test_stats_analysis.py @@ -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] @@ -265,4 +265,4 @@ def test_sensor_zero_missing_and_initial_gap_coverage(app): result = analyze( "during 2026-09-01 to 2026-09-01\nsummarize count(gap_since_previous_run), count(heart_rate), count()\nby all\ncompare none" ) - assert result["groups"][0]["raw_values"] == [0, 0, 1] + assert result["groups"][0]["raw_values"] == [0, 0, 1] \ No newline at end of file diff --git a/tests/test_stats_expressions.py b/tests/test_stats_expressions.py new file mode 100644 index 0000000..2b42227 --- /dev/null +++ b/tests/test_stats_expressions.py @@ -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' diff --git a/tests/test_training_signals.py b/tests/test_training_signals.py index 1f37123..23082d5 100644 --- a/tests/test_training_signals.py +++ b/tests/test_training_signals.py @@ -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", "—"]