feat: perfiles de jitter intercambiables desde Telegram

Cinco perfiles con distinto punto en el compromiso entre variedad visible y
minutos de presencia de mas. Todos dan saldo 0. Se cambian en caliente con
/perfil, con botones, y al cambiar se vuelven a tirar los dados de todos los
dias del horizonte que no se hayan fichado ya.

                 min/dia  madrugon  entrada 09:00
    discreto       2,8      1,3     2 minutos, moda 75%
    natural        4,1      1,8     5 minutos, moda 51%   <- por defecto
    camuflado      5,5      2,1     4 minutos, moda 43%
    disperso       7,9      3,0     6 minutos, moda 29%
    sin_madrugar   5,7      1,3     2 minutos, moda 77%
    (historico real del usuario:    4 minutos, moda 38%)

La distincion util no es solo el coste total sino DONDE cae. Adelantar la
entrada de la mañana alarga el dia; adelantar la vuelta de comer solo acorta la
comida y se sale a la misma hora. De ahi "sin_madrugar": entrada de la mañana lo
mas pegada posible a las 9 (madrugon 1,26 min/dia, el mas bajo de todos, por
debajo incluso de "discreto") y toda la variedad en la pausa y las salidas.

Para eso los perfiles admiten afinar por tramo con sufijo _w<n>:
entry_median_w0 gana a entry_median para el tramo de la mañana.

tools/perfiles.py mide cada perfil sobre los 226 dias laborables del calendario
real; las cifras de arriba salen de ahi y van guardadas como metadatos para que
/perfil las muestre sin recalcular.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-09-24 19:41:50 +02:00
parent a496b1c672
commit a1eef6472f
6 changed files with 339 additions and 30 deletions

View File

@@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from app import jitter
from app import jitter, profiles
from app.schedule import Calendar
logger = logging.getLogger(__name__)
@@ -46,7 +46,19 @@ class Planner:
self.cfg = cfg
self.store = store
self.tz = ZoneInfo(cfg.get("timezone", "Europe/Madrid"))
self.j = cfg["jitter"]
@property
def perfil(self) -> str:
"""Perfil activo. Se lee del store en cada plan, para poder cambiarlo en caliente."""
if self.store:
nombre = self.store.get("perfil", self.cfg.get("perfil", profiles.DEFECTO))
if profiles.existe(nombre):
return nombre
return self.cfg.get("perfil", profiles.DEFECTO)
@property
def j(self) -> dict:
return profiles.params(self.perfil)
# ------------------------------------------------------------------ drift
def _drift_key(self, window_idx: int, typ: int) -> str:
@@ -55,7 +67,8 @@ class Planner:
def _drift(self, window_idx: int, typ: int, rng: random.Random) -> float:
key = self._drift_key(window_idx, typ)
prev = float(self.store.get(key, 0.0)) if self.store else 0.0
nxt = jitter.next_drift(prev, self.j["drift_sigma"], self.j["drift_clamp"], rng)
j = self.j
nxt = jitter.next_drift(prev, j["drift_sigma"], j["drift_clamp"], rng)
if self.store:
self.store.put(key, nxt)
return nxt
@@ -69,6 +82,7 @@ class Planner:
return []
rng = rng or random.Random()
j = self.j
out: list[PlannedCheck] = []
idx = 0
@@ -78,19 +92,24 @@ class Planner:
# ENTRADA: siempre antes de la hora teorica. El tope se acota ademas
# con el margen de entrada que declara el propio tipo de jornada.
tope = min(self.j["entry_cap"], max(w.entry_margin * 60 // 2, 60)) if w.entry_margin \
else self.j["entry_cap"]
cap = profiles.valor(j, "entry", "cap", wi)
tope = min(cap, max(w.entry_margin * 60 // 2, 60)) if w.entry_margin else cap
off = jitter.offset_seconds(
self.j["entry_floor"], self.j["entry_median"], self.j["entry_sigma"],
profiles.valor(j, "entry", "floor", wi),
profiles.valor(j, "entry", "median", wi),
profiles.valor(j, "entry", "sigma", wi),
tope, self._drift(wi, ENTRADA, rng), rng)
off = max(off, self.j["entry_guard"])
off = max(off, j["entry_guard"])
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
idx += 1
# SALIDA: siempre despues
off = jitter.offset_seconds(
self.j["exit_floor"], self.j["exit_median"], self.j["exit_sigma"],
self.j["exit_cap"], self._drift(wi, SALIDA, rng), rng)
profiles.valor(j, "exit", "floor", wi),
profiles.valor(j, "exit", "median", wi),
profiles.valor(j, "exit", "sigma", wi),
profiles.valor(j, "exit", "cap", wi),
self._drift(wi, SALIDA, rng), rng)
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
idx += 1