Compare commits
No commits in common. "1cf3d5997d7f13597e86284792b8f149f321257d" and "5cac1ededd6f321536019b1527053c49831133a1" have entirely different histories.
1cf3d5997d
...
5cac1ededd
|
|
@ -17,29 +17,28 @@ Phase 0c: Multi-Layer Architecture
|
||||||
Version: 1.0
|
Version: 1.0
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from typing import Any, Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
|
||||||
from datetime import datetime, timedelta, date
|
from datetime import datetime, timedelta, date
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
import statistics
|
import statistics
|
||||||
|
|
||||||
from data_layer.nutrition_body_merge import build_merged_daily_nutrition_body_rows
|
|
||||||
from data_layer.nutrition_metrics import estimate_tdee_kcal_from_latest_weight
|
|
||||||
|
|
||||||
# Lag-Korrelation (Issue #53): gleiche TDEE-Logik wie nutrition_metrics / nutrition_viz
|
|
||||||
MIN_PAIRS_LAG_CORR = 15
|
|
||||||
LAG_CORR_LOOKBACK_DAYS = 120
|
|
||||||
|
|
||||||
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
def calculate_lag_correlation(profile_id: str, var1: str, var2: str, max_lag_days: int = 14) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson-Korrelation mit Lag-Sweep (Issue 53, Data-Layer).
|
Calculate lagged correlation between two variables
|
||||||
|
|
||||||
C1: Tagesbilanz (kcal − TDEE wie ``estimate_tdee_kcal_from_latest_weight``) vs. ΔGewicht [t→t+L], L≥1.
|
Args:
|
||||||
C2: Protein (g) vs. ΔMager [t→t+L] aus ``build_merged_daily_nutrition_body_rows``, L≥1.
|
var1: 'energy', 'protein', 'training_load'
|
||||||
C3: Summe ``duration_min`` pro Tag vs. HRV oder Ruhepuls am Tag t+L (L≥0).
|
var2: 'weight', 'lbm', 'hrv', 'rhr'
|
||||||
|
max_lag_days: Maximum lag to test
|
||||||
|
|
||||||
Rückgabe enthält u. a. ``best_lag`` / ``best_lag_days``, ``correlation``, ``interpretation``,
|
Returns:
|
||||||
optional ``lag_details`` (r, n je Lag), mindestens ``MIN_PAIRS_LAG_CORR`` Paare am besten Lag.
|
{
|
||||||
|
'best_lag': X, # days
|
||||||
|
'correlation': 0.XX, # -1 to 1
|
||||||
|
'direction': 'positive'/'negative'/'none',
|
||||||
|
'confidence': 'high'/'medium'/'low',
|
||||||
|
'data_points': N
|
||||||
|
}
|
||||||
"""
|
"""
|
||||||
v1 = (var1 or "").strip().lower()
|
v1 = (var1 or "").strip().lower()
|
||||||
if v1 in ("energy", "energy_balance"):
|
if v1 in ("energy", "energy_balance"):
|
||||||
|
|
@ -71,347 +70,83 @@ def _normalize_lag_payload(raw: Optional[Dict]) -> Optional[Dict]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _iso_date_key(d: Any) -> str:
|
|
||||||
if d is None:
|
|
||||||
return ""
|
|
||||||
if hasattr(d, "isoformat"):
|
|
||||||
return str(d.isoformat())[:10]
|
|
||||||
s = str(d)
|
|
||||||
return s[:10] if len(s) >= 10 else s
|
|
||||||
|
|
||||||
|
|
||||||
def _parse_iso_to_date(ds: str) -> Optional[date]:
|
|
||||||
if not ds or len(ds) < 10:
|
|
||||||
return None
|
|
||||||
try:
|
|
||||||
return date.fromisoformat(ds[:10])
|
|
||||||
except ValueError:
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _pearson_r(xs: List[float], ys: List[float]) -> Optional[float]:
|
|
||||||
"""Pearson-Korrelation; mindestens ``MIN_PAIRS_LAG_CORR`` Paare."""
|
|
||||||
n = len(xs)
|
|
||||||
if n < MIN_PAIRS_LAG_CORR or n != len(ys):
|
|
||||||
return None
|
|
||||||
mx = sum(xs) / n
|
|
||||||
my = sum(ys) / n
|
|
||||||
num = sum((xs[i] - mx) * (ys[i] - my) for i in range(n))
|
|
||||||
dx = sum((xs[i] - mx) ** 2 for i in range(n))
|
|
||||||
dy = sum((ys[i] - my) ** 2 for i in range(n))
|
|
||||||
if dx <= 1e-12 or dy <= 1e-12:
|
|
||||||
return None
|
|
||||||
r = num / ((dx**0.5) * (dy**0.5))
|
|
||||||
return float(max(-1.0, min(1.0, r)))
|
|
||||||
|
|
||||||
|
|
||||||
def _direction_from_r(r: float) -> str:
|
|
||||||
if r > 0.05:
|
|
||||||
return "positive"
|
|
||||||
if r < -0.05:
|
|
||||||
return "negative"
|
|
||||||
return "none"
|
|
||||||
|
|
||||||
|
|
||||||
def _lag_confidence(n_pairs: int, r: float) -> str:
|
|
||||||
return calculate_correlation_confidence(n_pairs, abs(r))
|
|
||||||
|
|
||||||
|
|
||||||
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_energy_weight(profile_id: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson: Tagesbilanz (kcal − TDEE wie nutrition_metrics) vs. Gewichtsdifferenz
|
Correlate energy balance with weight change
|
||||||
vom Tag t zu Tag t+L (L = 0 … max_lag). Bestes Lag nach maximalem |r|.
|
Test lags: 0, 3, 7, 10, 14 days
|
||||||
"""
|
"""
|
||||||
tdee = estimate_tdee_kcal_from_latest_weight(profile_id)
|
|
||||||
if tdee is None or float(tdee) <= 0:
|
|
||||||
return {
|
|
||||||
"best_lag": None,
|
|
||||||
"correlation": None,
|
|
||||||
"direction": "none",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"interpretation": "Keine TDEE-Schätzung möglich (Gewicht/Demografie).",
|
|
||||||
"reason": "no_tdee",
|
|
||||||
}
|
|
||||||
|
|
||||||
tdee_f = float(tdee)
|
|
||||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::date AS d, SUM(kcal)::float AS kcal
|
|
||||||
FROM nutrition_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND kcal IS NOT NULL
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
kcal_rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::date AS d, weight::float AS weight
|
|
||||||
FROM weight_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND weight IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
w_rows = cur.fetchall()
|
|
||||||
|
|
||||||
kcal_by: Dict[str, float] = {}
|
# Get energy balance data (daily calories - estimated TDEE)
|
||||||
for r in kcal_rows:
|
cur.execute("""
|
||||||
kcal_by[_iso_date_key(r["d"])] = float(r["kcal"] or 0)
|
SELECT n.date, n.kcal, w.weight
|
||||||
weight_by: Dict[str, float] = {}
|
FROM nutrition_log n
|
||||||
for r in w_rows:
|
LEFT JOIN weight_log w ON w.profile_id = n.profile_id
|
||||||
weight_by[_iso_date_key(r["d"])] = float(r["weight"])
|
AND w.date = n.date
|
||||||
|
WHERE n.profile_id = %s
|
||||||
|
AND n.date >= CURRENT_DATE - INTERVAL '90 days'
|
||||||
|
ORDER BY n.date
|
||||||
|
""", (profile_id,))
|
||||||
|
|
||||||
balance_by = {d: kcal_by[d] - tdee_f for d in kcal_by}
|
data = cur.fetchall()
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
if len(data) < 30:
|
||||||
lag_details: List[Dict[str, Any]] = []
|
|
||||||
|
|
||||||
max_l = max(0, min(int(max_lag), 28))
|
|
||||||
# Lag 0: ΔGewicht am selben Tag ist immer 0 → sinnvoll erst ab Tag 1
|
|
||||||
for lag in range(1, max_l + 1):
|
|
||||||
xs: List[float] = []
|
|
||||||
ys: List[float] = []
|
|
||||||
for ds in sorted(balance_by.keys()):
|
|
||||||
d0 = _parse_iso_to_date(ds)
|
|
||||||
if d0 is None:
|
|
||||||
continue
|
|
||||||
d1 = d0 + timedelta(days=lag)
|
|
||||||
ds1 = d1.isoformat()
|
|
||||||
w0 = weight_by.get(ds)
|
|
||||||
w1 = weight_by.get(ds1)
|
|
||||||
if w0 is None or w1 is None:
|
|
||||||
continue
|
|
||||||
xs.append(balance_by[ds])
|
|
||||||
ys.append(w1 - w0)
|
|
||||||
r = _pearson_r(xs, ys)
|
|
||||||
n_p = len(xs)
|
|
||||||
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
|
|
||||||
if r is None:
|
|
||||||
continue
|
|
||||||
if best is None or abs(r) > abs(best[1]):
|
|
||||||
best = (lag, r, n_p)
|
|
||||||
|
|
||||||
if best is None:
|
|
||||||
return {
|
return {
|
||||||
"best_lag": None,
|
'best_lag': None,
|
||||||
"correlation": None,
|
'correlation': None,
|
||||||
"direction": "none",
|
'direction': 'none',
|
||||||
"confidence": "insufficient",
|
'confidence': 'low',
|
||||||
"data_points": 0,
|
'data_points': len(data),
|
||||||
"interpretation": "Zu wenige gepaarte Tage mit Ernährung, Gewicht und gewähltem Lag.",
|
'reason': 'Insufficient data (<30 days)'
|
||||||
"reason": "insufficient_pairs",
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"tdee_kcal_used": round(tdee_f, 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
lag_b, r_b, n_b = best
|
# Calculate 7d rolling energy balance
|
||||||
direction = _direction_from_r(r_b)
|
# (Simplified - actual implementation would need TDEE estimation)
|
||||||
conf = _lag_confidence(n_b, r_b)
|
|
||||||
interp = (
|
|
||||||
f"Tagesbilanz (kcal − TDEE ~{tdee_f:.0f}) vs. Gewichtsänderung nach {lag_b} Tagen: "
|
|
||||||
f"r ≈ {r_b:.2f} ({direction}). "
|
|
||||||
f"Basierend auf {n_b} Kalendertagen mit vollständigen Paaren."
|
|
||||||
)
|
|
||||||
|
|
||||||
|
# For now, return placeholder
|
||||||
return {
|
return {
|
||||||
"best_lag": lag_b,
|
'best_lag': 7,
|
||||||
"correlation": round(r_b, 4),
|
'correlation': -0.45, # Placeholder
|
||||||
"direction": direction,
|
'direction': 'negative', # Higher deficit = lower weight (expected)
|
||||||
"confidence": conf,
|
'confidence': 'medium',
|
||||||
"data_points": n_b,
|
'data_points': len(data)
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"tdee_kcal_used": round(tdee_f, 0),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_protein_lbm(profile_id: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""Correlate protein intake with LBM trend"""
|
||||||
Pearson: Protein (g/Tag) vs. Magermasse-Differenz (kg) vom Tag t zu t+L.
|
# TODO: Implement full correlation calculation
|
||||||
Datenbasis: nutrition_body_merge (Caliper-LBM forward-filled wie Ernährungs-Verlauf).
|
|
||||||
"""
|
|
||||||
merged = build_merged_daily_nutrition_body_rows(profile_id)
|
|
||||||
if not merged:
|
|
||||||
return {
|
return {
|
||||||
"best_lag": None,
|
'best_lag': 0,
|
||||||
"correlation": None,
|
'correlation': 0.32, # Placeholder
|
||||||
"direction": "none",
|
'direction': 'positive',
|
||||||
"confidence": "insufficient",
|
'confidence': 'medium',
|
||||||
"data_points": 0,
|
'data_points': 28
|
||||||
"interpretation": "Keine zusammengeführten Ernährungs-/Körperdaten.",
|
|
||||||
"reason": "no_merged_rows",
|
|
||||||
}
|
|
||||||
|
|
||||||
protein_by: Dict[str, float] = {}
|
|
||||||
lbm_by: Dict[str, float] = {}
|
|
||||||
for row in merged:
|
|
||||||
ds = _iso_date_key(row.get("date"))
|
|
||||||
if not ds:
|
|
||||||
continue
|
|
||||||
pg = row.get("protein_g")
|
|
||||||
lm = row.get("lean_mass")
|
|
||||||
if pg is not None:
|
|
||||||
protein_by[ds] = float(pg)
|
|
||||||
if lm is not None:
|
|
||||||
lbm_by[ds] = float(lm)
|
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
|
||||||
lag_details: List[Dict[str, Any]] = []
|
|
||||||
max_l = max(0, min(int(max_lag), 28))
|
|
||||||
|
|
||||||
for lag in range(1, max_l + 1):
|
|
||||||
xs: List[float] = []
|
|
||||||
ys: List[float] = []
|
|
||||||
for ds in sorted(protein_by.keys()):
|
|
||||||
if ds not in lbm_by:
|
|
||||||
continue
|
|
||||||
d0 = _parse_iso_to_date(ds)
|
|
||||||
if d0 is None:
|
|
||||||
continue
|
|
||||||
d1 = d0 + timedelta(days=lag)
|
|
||||||
ds1 = d1.isoformat()
|
|
||||||
if ds1 not in lbm_by:
|
|
||||||
continue
|
|
||||||
xs.append(protein_by[ds])
|
|
||||||
ys.append(lbm_by[ds1] - lbm_by[ds])
|
|
||||||
r = _pearson_r(xs, ys)
|
|
||||||
n_p = len(xs)
|
|
||||||
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
|
|
||||||
if r is None:
|
|
||||||
continue
|
|
||||||
if best is None or abs(r) > abs(best[1]):
|
|
||||||
best = (lag, r, n_p)
|
|
||||||
|
|
||||||
if best is None:
|
|
||||||
return {
|
|
||||||
"best_lag": None,
|
|
||||||
"correlation": None,
|
|
||||||
"direction": "none",
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"interpretation": "Zu wenige Tage mit Protein und Magermasse (Caliper) für die gewählten Lags.",
|
|
||||||
"reason": "insufficient_pairs",
|
|
||||||
"lag_details": lag_details,
|
|
||||||
}
|
|
||||||
|
|
||||||
lag_b, r_b, n_b = best
|
|
||||||
direction = _direction_from_r(r_b)
|
|
||||||
conf = _lag_confidence(n_b, r_b)
|
|
||||||
interp = (
|
|
||||||
f"Protein (g/Tag) vs. Magermasse-Änderung nach {lag_b} Tagen: r ≈ {r_b:.2f} ({direction}). "
|
|
||||||
f"{n_b} gepaarte Tage."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"best_lag": lag_b,
|
|
||||||
"correlation": round(r_b, 4),
|
|
||||||
"direction": direction,
|
|
||||||
"confidence": conf,
|
|
||||||
"data_points": n_b,
|
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
def _correlate_load_vitals(profile_id: str, vital: str, max_lag: int) -> Optional[Dict]:
|
||||||
"""
|
"""
|
||||||
Pearson: Tages-Trainingslast (Summe duration_min) vs. Vitals (HRV ms oder Ruhepuls)
|
Correlate training load with HRV or RHR
|
||||||
am Kalendertag t+Lag (typisch: Belastung am Vortag, Vitalwert am Folgetag bei Lag ≥ 1).
|
Test lags: 1, 2, 3 days
|
||||||
"""
|
"""
|
||||||
col = "hrv" if vital == "hrv" else "resting_hr"
|
# TODO: Implement full correlation calculation
|
||||||
cutoff = (datetime.now() - timedelta(days=LAG_CORR_LOOKBACK_DAYS)).strftime("%Y-%m-%d")
|
if vital == 'hrv':
|
||||||
|
|
||||||
with get_db() as conn:
|
|
||||||
cur = get_cursor(conn)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT date::text AS d, COALESCE(SUM(duration_min), 0)::float AS minutes
|
|
||||||
FROM activity_log
|
|
||||||
WHERE profile_id = %s AND date >= %s::date
|
|
||||||
AND duration_min IS NOT NULL AND duration_min > 0
|
|
||||||
GROUP BY date
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
load_rows = cur.fetchall()
|
|
||||||
cur.execute(
|
|
||||||
f"""
|
|
||||||
SELECT date::text AS d, {col}::float AS v
|
|
||||||
FROM vitals_baseline
|
|
||||||
WHERE profile_id = %s AND date >= %s::date AND {col} IS NOT NULL
|
|
||||||
ORDER BY date
|
|
||||||
""",
|
|
||||||
(profile_id, cutoff),
|
|
||||||
)
|
|
||||||
vit_rows = cur.fetchall()
|
|
||||||
|
|
||||||
load_by = {str(r["d"])[:10]: float(r["minutes"] or 0) for r in load_rows}
|
|
||||||
vital_by = {str(r["d"])[:10]: float(r["v"]) for r in vit_rows}
|
|
||||||
|
|
||||||
best: Optional[Tuple[int, float, int]] = None
|
|
||||||
lag_details: List[Dict[str, Any]] = []
|
|
||||||
max_l = max(0, min(int(max_lag), 28))
|
|
||||||
vlabel = "HRV (ms)" if vital == "hrv" else "Ruhepuls (bpm)"
|
|
||||||
|
|
||||||
for lag in range(0, max_l + 1):
|
|
||||||
xs: List[float] = []
|
|
||||||
ys: List[float] = []
|
|
||||||
for ds in sorted(load_by.keys()):
|
|
||||||
d0 = _parse_iso_to_date(ds)
|
|
||||||
if d0 is None:
|
|
||||||
continue
|
|
||||||
d1 = d0 + timedelta(days=lag)
|
|
||||||
ds1 = d1.isoformat()
|
|
||||||
if ds1 not in vital_by:
|
|
||||||
continue
|
|
||||||
xs.append(load_by[ds])
|
|
||||||
ys.append(vital_by[ds1])
|
|
||||||
r = _pearson_r(xs, ys)
|
|
||||||
n_p = len(xs)
|
|
||||||
lag_details.append({"lag": lag, "n_pairs": n_p, "r": None if r is None else round(r, 4)})
|
|
||||||
if r is None:
|
|
||||||
continue
|
|
||||||
if best is None or abs(r) > abs(best[1]):
|
|
||||||
best = (lag, r, n_p)
|
|
||||||
|
|
||||||
if best is None:
|
|
||||||
return {
|
return {
|
||||||
"best_lag": None,
|
'best_lag': 1,
|
||||||
"correlation": None,
|
'correlation': -0.38, # Negative = high load reduces HRV (expected)
|
||||||
"direction": "none",
|
'direction': 'negative',
|
||||||
"confidence": "insufficient",
|
'confidence': 'medium',
|
||||||
"data_points": 0,
|
'data_points': 25
|
||||||
"interpretation": f"Zu wenige gepaarte Tage mit Training und {vlabel}.",
|
|
||||||
"reason": "insufficient_pairs",
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"vital": vital,
|
|
||||||
}
|
}
|
||||||
|
else: # rhr
|
||||||
lag_b, r_b, n_b = best
|
|
||||||
direction = _direction_from_r(r_b)
|
|
||||||
conf = _lag_confidence(n_b, r_b)
|
|
||||||
interp = (
|
|
||||||
f"Trainingsminuten/Tag vs. {vlabel} nach {lag_b} Tagen Lag: r ≈ {r_b:.2f} ({direction}). "
|
|
||||||
f"{n_b} Paare."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"best_lag": lag_b,
|
'best_lag': 1,
|
||||||
"correlation": round(r_b, 4),
|
'correlation': 0.42, # Positive = high load increases RHR (expected)
|
||||||
"direction": direction,
|
'direction': 'positive',
|
||||||
"confidence": conf,
|
'confidence': 'medium',
|
||||||
"data_points": n_b,
|
'data_points': 25
|
||||||
"interpretation": interp,
|
|
||||||
"lag_details": lag_details,
|
|
||||||
"vital": vital,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,6 @@ from data_layer.correlations import calculate_lag_correlation, calculate_top_dri
|
||||||
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
from data_layer.fitness_viz import get_fitness_dashboard_viz_bundle
|
||||||
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
from data_layer.nutrition_viz import get_nutrition_history_viz_bundle
|
||||||
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
from data_layer.recovery_viz import get_recovery_dashboard_viz_bundle
|
||||||
from data_layer.utils import safe_float
|
|
||||||
|
|
||||||
|
|
||||||
def _take_kpis(tiles: Any, max_n: int = 4) -> List[Dict[str, Any]]:
|
def _take_kpis(tiles: Any, max_n: int = 4) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -91,9 +90,11 @@ def get_history_overview_viz_bundle(profile_id: str, days: int) -> Dict[str, Any
|
||||||
c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14)
|
c3_rhr = calculate_lag_correlation(profile_id, "load", "rhr", 14)
|
||||||
c3 = None
|
c3 = None
|
||||||
if c3_hrv and c3_rhr:
|
if c3_hrv and c3_rhr:
|
||||||
a1 = abs(safe_float(c3_hrv.get("correlation"), 0.0))
|
c3 = (
|
||||||
a2 = abs(safe_float(c3_rhr.get("correlation"), 0.0))
|
c3_hrv
|
||||||
c3 = c3_hrv if a1 >= a2 else c3_rhr
|
if abs(float(c3_hrv.get("correlation") or 0)) >= abs(float(c3_rhr.get("correlation") or 0))
|
||||||
|
else c3_rhr
|
||||||
|
)
|
||||||
if c3 is c3_hrv:
|
if c3 is c3_hrv:
|
||||||
c3 = dict(c3)
|
c3 = dict(c3)
|
||||||
c3["metric"] = "HRV"
|
c3["metric"] = "HRV"
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
from typing import Any, Dict, List, Optional
|
from typing import Any, Dict, List, Optional
|
||||||
|
|
||||||
from db import get_db, get_cursor, r2d
|
from db import get_db, get_cursor, r2d
|
||||||
from caliper_composition import as_date, compute_lean_fat_kg, nearest_weight_kg_from_map
|
from caliper_composition import compute_lean_fat_kg, nearest_weight_kg_from_map
|
||||||
|
|
||||||
|
|
||||||
def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, Any]]:
|
def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, Any]]:
|
||||||
|
|
@ -20,42 +20,21 @@ def build_merged_daily_nutrition_body_rows(profile_id: str) -> List[Dict[str, An
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
cur.execute("SELECT * FROM nutrition_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
||||||
nutr: Dict[Any, Dict[str, Any]] = {}
|
nutr = {r["date"]: r2d(r) for r in cur.fetchall()}
|
||||||
for r in cur.fetchall():
|
|
||||||
rd = r2d(r)
|
|
||||||
dk = as_date(rd.get("date"))
|
|
||||||
if dk is not None:
|
|
||||||
nutr[dk] = rd
|
|
||||||
cur.execute("SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
cur.execute("SELECT date, weight FROM weight_log WHERE profile_id=%s ORDER BY date", (profile_id,))
|
||||||
wlog: Dict[Any, Any] = {}
|
wlog = {r["date"]: r["weight"] for r in cur.fetchall()}
|
||||||
for r in cur.fetchall():
|
|
||||||
rd = r2d(r)
|
|
||||||
dk = as_date(rd.get("date"))
|
|
||||||
if dk is not None:
|
|
||||||
wlog[dk] = rd["weight"]
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",
|
"SELECT date, lean_mass, body_fat_pct FROM caliper_log WHERE profile_id=%s ORDER BY date",
|
||||||
(profile_id,),
|
(profile_id,),
|
||||||
)
|
)
|
||||||
cals = [r2d(r) for r in cur.fetchall()]
|
cals = sorted([r2d(r) for r in cur.fetchall()], key=lambda x: x["date"])
|
||||||
cals = sorted(
|
|
||||||
[c for c in cals if as_date(c.get("date")) is not None],
|
|
||||||
key=lambda x: as_date(x["date"]),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Alle Keys sind datetime.date — vermeidet TypeError bei Vergleichen (str vs date)
|
all_dates = sorted(set(list(nutr.keys()) + list(wlog.keys())))
|
||||||
all_dates = sorted(set(nutr.keys()) | set(wlog.keys()))
|
|
||||||
mi = 0
|
mi = 0
|
||||||
last_cal: Dict[str, Any] = {}
|
last_cal: Dict[str, Any] = {}
|
||||||
cal_by_date: Dict[Any, Dict[str, Any]] = {}
|
cal_by_date: Dict[Any, Dict[str, Any]] = {}
|
||||||
for d in all_dates:
|
for d in all_dates:
|
||||||
while mi < len(cals):
|
while mi < len(cals) and cals[mi]["date"] <= d:
|
||||||
cd = as_date(cals[mi].get("date"))
|
|
||||||
if cd is None:
|
|
||||||
mi += 1
|
|
||||||
continue
|
|
||||||
if cd > d:
|
|
||||||
break
|
|
||||||
last_cal = cals[mi]
|
last_cal = cals[mi]
|
||||||
mi += 1
|
mi += 1
|
||||||
if last_cal:
|
if last_cal:
|
||||||
|
|
|
||||||
|
|
@ -1115,9 +1115,6 @@ def get_weight_energy_correlation_chart(
|
||||||
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
|
corr_data = calculate_lag_correlation(profile_id, "energy_balance", "weight", max_lag)
|
||||||
|
|
||||||
if not corr_data or corr_data.get('correlation') is None:
|
if not corr_data or corr_data.get('correlation') is None:
|
||||||
msg = "Nicht genug Daten für Korrelationsanalyse"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
return {
|
||||||
"chart_type": "scatter",
|
"chart_type": "scatter",
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -1126,15 +1123,14 @@ def get_weight_energy_correlation_chart(
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
"data_points": 0,
|
||||||
"message": msg,
|
"message": "Nicht genug Daten für Korrelationsanalyse"
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used") if isinstance(corr_data, dict) else None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# Ein Punkt: bestes Lag (max. |r|) — Berechnung in data_layer.correlations (Issue 53)
|
# Create lag vs correlation data for chart
|
||||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
# For simplicity, show best lag point as single data point
|
||||||
|
best_lag = corr_data.get('best_lag_days', 0)
|
||||||
correlation = corr_data.get('correlation', 0)
|
correlation = corr_data.get('correlation', 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1154,13 +1150,10 @@ def get_weight_energy_correlation_chart(
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": corr_data.get('confidence', 'low'),
|
"confidence": corr_data.get('confidence', 'low'),
|
||||||
"correlation": round(float(correlation), 3),
|
"correlation": round(correlation, 3),
|
||||||
"best_lag_days": best_lag,
|
"best_lag_days": best_lag,
|
||||||
"interpretation": corr_data.get('interpretation', ''),
|
"interpretation": corr_data.get('interpretation', ''),
|
||||||
"data_points": corr_data.get('data_points', 0),
|
"data_points": corr_data.get('data_points', 0)
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"tdee_kcal_used": corr_data.get("tdee_kcal_used"),
|
|
||||||
"layer_1": "correlations._correlate_energy_weight",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1187,9 +1180,6 @@ def get_lbm_protein_correlation_chart(
|
||||||
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
|
corr_data = calculate_lag_correlation(profile_id, "protein", "lbm", max_lag)
|
||||||
|
|
||||||
if not corr_data or corr_data.get('correlation') is None:
|
if not corr_data or corr_data.get('correlation') is None:
|
||||||
msg = "Nicht genug Daten für LBM-Protein Korrelation"
|
|
||||||
if isinstance(corr_data, dict):
|
|
||||||
msg = str(corr_data.get("interpretation") or corr_data.get("reason") or msg)
|
|
||||||
return {
|
return {
|
||||||
"chart_type": "scatter",
|
"chart_type": "scatter",
|
||||||
"data": {
|
"data": {
|
||||||
|
|
@ -1198,13 +1188,12 @@ def get_lbm_protein_correlation_chart(
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"data_points": corr_data.get("data_points", 0) if isinstance(corr_data, dict) else 0,
|
"data_points": 0,
|
||||||
"message": msg,
|
"message": "Nicht genug Daten für LBM-Protein Korrelation"
|
||||||
"lag_details": corr_data.get("lag_details") if isinstance(corr_data, dict) else None,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
best_lag = corr_data.get('best_lag_days', 0)
|
||||||
correlation = corr_data.get('correlation', 0)
|
correlation = corr_data.get('correlation', 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1224,12 +1213,10 @@ def get_lbm_protein_correlation_chart(
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": corr_data.get('confidence', 'low'),
|
"confidence": corr_data.get('confidence', 'low'),
|
||||||
"correlation": round(float(correlation), 3),
|
"correlation": round(correlation, 3),
|
||||||
"best_lag_days": best_lag,
|
"best_lag_days": best_lag,
|
||||||
"interpretation": corr_data.get('interpretation', ''),
|
"interpretation": corr_data.get('interpretation', ''),
|
||||||
"data_points": corr_data.get('data_points', 0),
|
"data_points": corr_data.get('data_points', 0)
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_protein_lbm",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1253,54 +1240,35 @@ def get_load_vitals_correlation_chart(
|
||||||
"""
|
"""
|
||||||
profile_id = session['profile_id']
|
profile_id = session['profile_id']
|
||||||
|
|
||||||
|
# Try HRV first
|
||||||
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
|
corr_hrv = calculate_lag_correlation(profile_id, "load", "hrv", max_lag)
|
||||||
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
|
corr_rhr = calculate_lag_correlation(profile_id, "load", "rhr", max_lag)
|
||||||
|
|
||||||
def _abs_corr(c):
|
# Use whichever has stronger correlation
|
||||||
if not c or c.get("correlation") is None:
|
if corr_hrv and corr_rhr:
|
||||||
return -1.0
|
corr_data = corr_hrv if abs(corr_hrv.get('correlation', 0)) > abs(corr_rhr.get('correlation', 0)) else corr_rhr
|
||||||
try:
|
metric_name = "HRV" if corr_data == corr_hrv else "RHR"
|
||||||
return abs(float(c["correlation"]))
|
elif corr_hrv:
|
||||||
except (TypeError, ValueError):
|
|
||||||
return -1.0
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) < 0 and _abs_corr(corr_rhr) < 0:
|
|
||||||
msg = "Nicht genug Daten für Load-Vitals Korrelation"
|
|
||||||
h_msg = corr_hrv.get("interpretation") if isinstance(corr_hrv, dict) else None
|
|
||||||
r_msg = corr_rhr.get("interpretation") if isinstance(corr_rhr, dict) else None
|
|
||||||
if h_msg or r_msg:
|
|
||||||
msg = f"HRV: {h_msg or '—'} · RHR: {r_msg or '—'}"
|
|
||||||
return {
|
|
||||||
"chart_type": "scatter",
|
|
||||||
"data": {"labels": [], "datasets": []},
|
|
||||||
"metadata": {
|
|
||||||
"confidence": "insufficient",
|
|
||||||
"data_points": 0,
|
|
||||||
"message": msg,
|
|
||||||
"lag_details_hrv": corr_hrv.get("lag_details") if isinstance(corr_hrv, dict) else None,
|
|
||||||
"lag_details_rhr": corr_rhr.get("lag_details") if isinstance(corr_rhr, dict) else None,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
if _abs_corr(corr_hrv) >= _abs_corr(corr_rhr):
|
|
||||||
corr_data = corr_hrv
|
corr_data = corr_hrv
|
||||||
metric_name = "HRV"
|
metric_name = "HRV"
|
||||||
else:
|
elif corr_rhr:
|
||||||
corr_data = corr_rhr
|
corr_data = corr_rhr
|
||||||
metric_name = "RHR"
|
metric_name = "RHR"
|
||||||
|
else:
|
||||||
if not corr_data or corr_data.get("correlation") is None:
|
|
||||||
return {
|
return {
|
||||||
"chart_type": "scatter",
|
"chart_type": "scatter",
|
||||||
"data": {"labels": [], "datasets": []},
|
"data": {
|
||||||
|
"labels": [],
|
||||||
|
"datasets": []
|
||||||
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": "insufficient",
|
"confidence": "insufficient",
|
||||||
"data_points": 0,
|
"data_points": 0,
|
||||||
"message": str(corr_data.get("interpretation") or "Nicht genug Daten für Load-Vitals Korrelation"),
|
"message": "Nicht genug Daten für Load-Vitals Korrelation"
|
||||||
},
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
best_lag = corr_data.get('best_lag_days', corr_data.get('best_lag', 0))
|
best_lag = corr_data.get('best_lag_days', 0)
|
||||||
correlation = corr_data.get('correlation', 0)
|
correlation = corr_data.get('correlation', 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -1320,13 +1288,11 @@ def get_load_vitals_correlation_chart(
|
||||||
},
|
},
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"confidence": corr_data.get('confidence', 'low'),
|
"confidence": corr_data.get('confidence', 'low'),
|
||||||
"correlation": round(float(correlation), 3),
|
"correlation": round(correlation, 3),
|
||||||
"best_lag_days": best_lag,
|
"best_lag_days": best_lag,
|
||||||
"metric": metric_name,
|
"metric": metric_name,
|
||||||
"interpretation": corr_data.get('interpretation', ''),
|
"interpretation": corr_data.get('interpretation', ''),
|
||||||
"data_points": corr_data.get('data_points', 0),
|
"data_points": corr_data.get('data_points', 0)
|
||||||
"lag_details": corr_data.get("lag_details"),
|
|
||||||
"layer_1": "correlations._correlate_load_vitals",
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1205,34 +1205,6 @@ function chartJsScatterPoints(payload) {
|
||||||
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
|
return raw.map((p) => ({ x: Number(p.x), y: Number(p.y) }))
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Backend metadata.lag_details: [{ lag, n_pairs, r }] — für Lag-Kurve L → r (C3: ggf. lag_details_hrv / lag_details_rhr) */
|
|
||||||
function lagDetailsToCurve(meta) {
|
|
||||||
let ld = meta?.lag_details
|
|
||||||
if (!Array.isArray(ld) || ld.length === 0) {
|
|
||||||
const m = String(meta?.metric || '').toUpperCase()
|
|
||||||
if (m === 'HRV' && Array.isArray(meta?.lag_details_hrv)) ld = meta.lag_details_hrv
|
|
||||||
else if (m === 'RHR' && Array.isArray(meta?.lag_details_rhr)) ld = meta.lag_details_rhr
|
|
||||||
else {
|
|
||||||
const h = meta?.lag_details_hrv
|
|
||||||
const r = meta?.lag_details_rhr
|
|
||||||
const hl = Array.isArray(h) ? h.length : 0
|
|
||||||
const rl = Array.isArray(r) ? r.length : 0
|
|
||||||
if (hl >= rl && hl > 0) ld = h
|
|
||||||
else if (rl > 0) ld = r
|
|
||||||
else ld = []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!Array.isArray(ld) || ld.length === 0) return []
|
|
||||||
return ld
|
|
||||||
.map((d) => ({
|
|
||||||
lag: Number(d?.lag),
|
|
||||||
r: d?.r == null || d?.r === '' ? null : Number(d.r),
|
|
||||||
n_pairs: d?.n_pairs != null ? Number(d.n_pairs) : null,
|
|
||||||
}))
|
|
||||||
.filter((d) => Number.isFinite(d.lag) && d.r != null && Number.isFinite(d.r))
|
|
||||||
.sort((a, b) => a.lag - b.lag)
|
|
||||||
}
|
|
||||||
|
|
||||||
function driverBarFromStatus(st) {
|
function driverBarFromStatus(st) {
|
||||||
const s = String(st || '').toLowerCase()
|
const s = String(st || '').toLowerCase()
|
||||||
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
|
if (s.includes('hinder')) return { v: -1, fill: 'var(--danger)' }
|
||||||
|
|
@ -1268,13 +1240,10 @@ function chartJsBarRows(payload, fallbackDrivers) {
|
||||||
function CorrelationScatterTile({ title, accent, payload }) {
|
function CorrelationScatterTile({ title, accent, payload }) {
|
||||||
const meta = payload?.metadata || {}
|
const meta = payload?.metadata || {}
|
||||||
const pts = chartJsScatterPoints(payload)
|
const pts = chartJsScatterPoints(payload)
|
||||||
const curve = lagDetailsToCurve(meta)
|
|
||||||
const hasChart = pts.length > 0 && meta.correlation != null
|
const hasChart = pts.length > 0 && meta.correlation != null
|
||||||
const r = Number(meta.correlation)
|
const r = Number(meta.correlation)
|
||||||
const strength =
|
const strength =
|
||||||
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
|
!Number.isFinite(r) ? 'bad' : Math.abs(r) >= 0.35 ? 'good' : Math.abs(r) >= 0.15 ? 'warn' : 'bad'
|
||||||
const bestLag = meta.best_lag_days != null ? Number(meta.best_lag_days) : null
|
|
||||||
const maxLagAxis = curve.length ? Math.max(14, ...curve.map((d) => d.lag), bestLag || 0) : 28
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1288,76 +1257,12 @@ function CorrelationScatterTile({ title, accent, payload }) {
|
||||||
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
|
<div style={{ fontSize: 11, fontWeight: 700, color: 'var(--text1)', marginBottom: 4 }}>{title}</div>
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
|
<div style={{ fontSize: 10, color: 'var(--text3)', lineHeight: 1.35, marginBottom: 6 }}>
|
||||||
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
|
r = {meta.correlation != null ? Number(meta.correlation).toFixed(3) : '—'}
|
||||||
{meta.best_lag_days != null ? ` · bestes Lag ${meta.best_lag_days} T` : ''}
|
{meta.best_lag_days != null ? ` · Lag ${meta.best_lag_days} T` : ''}
|
||||||
{meta.metric ? ` · ${meta.metric}` : ''}
|
{meta.metric ? ` · ${meta.metric}` : ''}
|
||||||
{meta.confidence ? ` · ${meta.confidence}` : ''}
|
{meta.confidence ? ` · ${meta.confidence}` : ''}
|
||||||
</div>
|
</div>
|
||||||
{!hasChart ? (
|
{!hasChart ? (
|
||||||
<>
|
<div style={{ fontSize: 11, color: 'var(--text3)' }}>{meta.message || 'Keine Daten für diese Korrelation.'}</div>
|
||||||
<div style={{ fontSize: 11, color: 'var(--text3)', marginBottom: curve.length ? 8 : 0 }}>
|
|
||||||
{meta.message || 'Keine Daten für diese Korrelation.'}
|
|
||||||
</div>
|
|
||||||
{curve.length > 0 && (
|
|
||||||
<div style={{ fontSize: 10, color: 'var(--text3)', marginBottom: 6 }}>
|
|
||||||
Lag-Sweep (kein Lag mit ≥15 Paaren): r über Lags — nur zur Einordnung.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{curve.length > 0 && (
|
|
||||||
<ResponsiveContainer width="100%" height={120}>
|
|
||||||
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} label={{ value: 'Lag (T)', fontSize: 9, fill: 'var(--text3)', offset: -2 }} />
|
|
||||||
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} label={{ value: 'r', fontSize: 9, fill: 'var(--text3)', angle: -90 }} />
|
|
||||||
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
|
||||||
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
|
||||||
/>
|
|
||||||
<Line type="monotone" dataKey="r" stroke={accent} strokeWidth={2} dot={{ r: 3, fill: accent }} isAnimationActive={false} />
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : curve.length >= 1 ? (
|
|
||||||
<>
|
|
||||||
<div style={{ fontSize: 9, color: 'var(--text3)', marginBottom: 4 }}>
|
|
||||||
Kurve: Pearson-r je Lag (Tage); starker Punkt = gewähltes bestes Lag.
|
|
||||||
</div>
|
|
||||||
<ResponsiveContainer width="100%" height={132}>
|
|
||||||
<ComposedChart data={curve} margin={{ top: 4, right: 6, bottom: 4, left: -14 }}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="var(--border)" />
|
|
||||||
<XAxis dataKey="lag" type="number" domain={[0, maxLagAxis]} tick={{ fontSize: 9, fill: 'var(--text3)' }} />
|
|
||||||
<YAxis dataKey="r" domain={[-1, 1]} tick={{ fontSize: 9, fill: 'var(--text3)' }} width={36} />
|
|
||||||
<ReferenceLine y={0} stroke="var(--text3)" strokeDasharray="4 4" />
|
|
||||||
<Tooltip
|
|
||||||
contentStyle={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 8, fontSize: 10 }}
|
|
||||||
formatter={(v, _n, item) => [`r = ${Number(v).toFixed(3)}`, `Lag ${item?.payload?.lag} T · n = ${item?.payload?.n_pairs ?? '—'}`]}
|
|
||||||
/>
|
|
||||||
<Line
|
|
||||||
type="monotone"
|
|
||||||
dataKey="r"
|
|
||||||
stroke={accent}
|
|
||||||
strokeWidth={2}
|
|
||||||
isAnimationActive={false}
|
|
||||||
dot={(props) => {
|
|
||||||
const { cx, cy, payload: pl } = props
|
|
||||||
if (cx == null || cy == null || !pl) return null
|
|
||||||
const isBest = bestLag != null && Number(pl.lag) === bestLag
|
|
||||||
return (
|
|
||||||
<circle
|
|
||||||
cx={cx}
|
|
||||||
cy={cy}
|
|
||||||
r={isBest ? 6 : 3.5}
|
|
||||||
fill={isBest ? 'var(--surface)' : accent}
|
|
||||||
stroke={isBest ? accent : 'none'}
|
|
||||||
strokeWidth={isBest ? 2.5 : 0}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</ComposedChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<ResponsiveContainer width="100%" height={118}>
|
<ResponsiveContainer width="100%" height={118}>
|
||||||
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
|
<ScatterChart margin={{ top: 2, right: 4, bottom: 2, left: -18 }}>
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user