Replace fixed DSL fields with parameterized metric expressions
This commit is contained in:
@@ -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
@@ -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":
|
||||
|
||||
@@ -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.')
|
||||
@@ -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: {
|
||||
|
||||
@@ -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
@@ -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
@@ -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":
|
||||
|
||||
@@ -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
@@ -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."
|
||||
)
|
||||
|
||||
@@ -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())
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
@@ -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]
|
||||
@@ -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'
|
||||
@@ -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", "—"]
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user