Replace fixed DSL fields with parameterized metric expressions

This commit is contained in:
Peter Stockings
2026-10-04 10:24:12 +11:00
parent 418e03583a
commit d660b86bb0
18 changed files with 402 additions and 137 deletions
+12 -38
View File
@@ -68,26 +68,6 @@ EXTRA_METRICS = {
"bpm",
"Average heart rate minus the previous run's average, before filtering.",
),
"rolling_7d_distance": (
"7-day distance",
"km",
"Distance in the preceding 168 hours, including this run. Calculated before filters.",
),
"rolling_28d_distance": (
"28-day distance",
"km",
"Distance in the preceding 672 hours, including this run. Calculated before filters.",
),
"rolling_7d_count": (
"7-day run count",
"runs",
"Number of runs in the preceding 168 hours, including this run, before filters.",
),
"rolling_28d_count": (
"28-day run count",
"runs",
"Number of runs in the preceding 672 hours, including this run, before filters.",
),
"run_day_streak": (
"Consecutive running days",
"days",
@@ -122,10 +102,6 @@ EXTRA_UNITS = {
"distance_change": {"km": 1, "m": 0.001},
"pace_change": {"sec/km": 1},
"heart_rate_change": {"bpm": 1},
"rolling_7d_distance": {"km": 1, "m": 0.001},
"rolling_28d_distance": {"km": 1, "m": 0.001},
"rolling_7d_count": {"runs": 1},
"rolling_28d_count": {"runs": 1},
"run_day_streak": {"days": 1},
"elevation_per_km": {"m/km": 1},
"max_heart_rate": {"bpm": 1},
@@ -231,8 +207,10 @@ def evaluate(tree, values):
def parse_analysis(text, metrics, defaults):
if not isinstance(text, str) or len(text) > 4000:
raise QueryError("Keep the query under 4,000 characters.")
from .stats_expressions import calculation, lower
from .stats_features import extract, validate
text, expressions = lower(text)
text, features = extract(text)
catalog, units = dict(metrics), {**UNITS, **EXTRA_UNITS}
lines = text.splitlines()
@@ -280,6 +258,13 @@ def parse_analysis(text, metrics, defaults):
for index, source in enumerate(lines):
line, number = source.strip(), index + 1
for name, expression in expressions.get(number, []):
spec, conversions = calculation(name, expression, catalog, units, defaults, number)
if len(derived) >= 32:
raise QueryError("Use at most 32 metric expressions.", number)
catalog[name] = (expression, spec["unit"], f"Per-input expression: {expression}.")
units[name] = conversions
derived.append(spec)
if not line or line.startswith("#"):
continue
if match := re.fullmatch(r"let (\w+) = (.+)", line):
@@ -288,30 +273,19 @@ def parse_analysis(text, metrics, defaults):
not re.fullmatch(r"[a-z][a-z0-9_]{0,39}", name)
or name.startswith("analysis_")
or name in catalog
or len(derived) >= 8
or sum(not s.get("inline") for s in derived) >= 8
):
raise QueryError(
"Choose a new lowercase metric name; at most eight calculations.", number
)
window = re.fullmatch(r"rolling_(mean|sum|count)\((\w+), (\d+) days\)", expression)
if window:
function, field, days = window.groups()
if field not in catalog or not 1 <= int(days) <= 365:
raise QueryError(
"Rolling functions need an earlier metric and 1–365 days.", number
)
unit = "runs" if function == "count" else catalog[field][1]
else:
_, unit = expression_tree(expression, catalog, number)
_, unit = expression_tree(expression, catalog, number)
catalog[name] = (
name.replace("_", " ").capitalize(),
unit,
f"Calculated per run: {expression}. Missing inputs or division by zero give missing data.",
)
units[name] = {unit: 1}
units[name] = {"s": 1, "min": 60} if unit == "time" else {unit: 1}
spec = {"name": name, "expression": expression, "unit": unit}
if window:
spec["window"] = {"function": function, "field": field, "days": int(days)}
derived.append(spec)
lines[index] = ""
elif line == "during all time":