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

@@ -56,6 +56,7 @@ Se registran solos en el menú del chat al arrancar.
| `/hoy` | plan y estado de hoy, con lo que Timenet ha registrado de verdad |
| `/saldo` | saldo del mes y acumulado |
| `/plan` | plan de hoy, con botón para re-tirar los dados |
| `/perfil [nombre]` | ver y cambiar el perfil de jitter |
| `/proximos [n]` | previsión de los próximos días laborables y sus rangos |
| `/fichar` `/desfichar` | fichaje manual inmediato |
| `/off [hoy\|dd/mm\|dd/mm-dd/mm]` | no fichar esos días |
@@ -72,23 +73,38 @@ Doce minutos antes de cada fichaje llega un aviso con killswitch:
El bot sólo atiende al `chat_id` de `config.json`; cualquier otro chat se ignora.
Perfiles de jitter
------------------
Timenet sólo cuenta dentro de la ventana teórica, así que las horas de fichaje
sólo pueden variar hacia el lado seguro, y alejarse cuesta presencia real. Más
variedad visible = más minutos ahí. Cada perfil elige un punto de ese compromiso.
Los cinco dan **saldo 0**; se cambian en caliente con `/perfil`.
| perfil | min/día | madrugón | entrada 09:00 |
|---|---|---|---|
| discreto | 2,8 | 1,3 | 2 minutos, moda 75 % |
| **natural** (por defecto) | 4,1 | 1,8 | 5 minutos, moda 51 % |
| 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 % |
| *histórico real* | — | — | *4 minutos, moda 38 %* |
`min/día` es la presencia total de más. `madrugón` es la parte de esos minutos
que te levantas antes por la entrada de la mañana — el resto se lo come la pausa
de comer y la salida, que no alargan el día. Por eso existe *sin madrugar*:
entrada de la mañana pegada a las 9 y toda la variedad en la comida y la salida.
Medido sobre los 226 días laborables del año con `tools/perfiles.py`. Un perfil
puede afinar por tramo con sufijo `_w<n>`: `entry_median_w0` gana a `entry_median`
para el tramo de la mañana.
Configuración
-------------
Lo esencial va en `config.json` (ver `sample.config.json`). Los valores por defecto
del jitter están en `app/settings.py`:
| perfil | `entry_before` / `exit_after` (s) | presencia extra | colchón mínimo |
|---|---|---|---|
| holgado | 40-150 / 30-130 | 5,67 min/día | 30 s |
| **equilibrado** (por defecto) | 30-100 / 20-70 | 3,55 min/día | 20 s |
| ajustado | 20-70 / 15-45 | 2,70 min/día | 15 s |
Los tres dan saldo 0; cambia cuánta presencia real cuestan y cuánto retraso del
daemon aguantan antes de perder un minuto.
`geo_jitter_meters` desplaza la posición dentro de un disco de ese radio. Sin él,
las coordenadas salen idénticas al sexto decimal en todos los fichajes.
Lo esencial va en `config.json` (ver `sample.config.json`). `geo_jitter_meters`
desplaza la posición dentro de un disco de ese radio; sin él, las coordenadas
salen idénticas al sexto decimal en todos los fichajes.
Herramientas
------------
@@ -97,6 +113,7 @@ Herramientas
./venv/bin/python3 tools/tn_inspect.py # calendario y saldo, contra la API real
./venv/bin/python3 tools/dryrun.py # simula el año y verifica que no pierde minutos
./venv/bin/python3 tools/test_daemon.py # 19 pruebas con reloj simulado, sin red
./venv/bin/python3 tools/perfiles.py # mide cada perfil sobre el calendario real
```
Modo antiguo (CLI de un disparo)

View File

@@ -13,7 +13,7 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
from telegram.constants import ParseMode
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
from app import jitter
from app import jitter, profiles
logger = logging.getLogger(__name__)
@@ -101,6 +101,7 @@ MENU: list[tuple[str, str]] = [
("hoy", "Plan y estado de hoy"),
("saldo", "Saldo del mes y acumulado"),
("plan", "Ver el plan y re-tirar los dados"),
("perfil", "Ver y cambiar el perfil de jitter"),
("proximos", "Previsión de los próximos días [nº de días]"),
("fichar", "Fichar ENTRADA ahora"),
("desfichar", "Fichar SALIDA ahora"),
@@ -131,7 +132,7 @@ class BotUI:
cmds = {
"start": self.cmd_hoy, "help": self.cmd_help, "ayuda": self.cmd_help,
"hoy": self.cmd_hoy, "saldo": self.cmd_saldo, "plan": self.cmd_plan,
"proximos": self.cmd_proximos,
"proximos": self.cmd_proximos, "perfil": self.cmd_perfil,
"off": self.cmd_off, "on": self.cmd_on,
"fichar": self.cmd_fichar, "desfichar": self.cmd_desfichar,
"pausa": self.cmd_pausa, "reanudar": self.cmd_reanudar,
@@ -276,6 +277,56 @@ class BotUI:
await update.effective_message.reply_text("\n".join(lines),
parse_mode=ParseMode.HTML)
def _perfil_texto(self) -> str:
actual = self.svc.store.get("perfil", profiles.DEFECTO)
lines = ["<b>Perfil de jitter</b>",
"Cuánta variedad tienen las horas de fichaje, y lo que cuesta "
"en minutos de estar ahí. Todos dan saldo 0.\n"]
for n in profiles.nombres():
st = profiles.stats(n)
marca = "▶️ " if n == actual else "· "
lines.append(
f"{marca}<b>{profiles.titulo(n)}</b> — {st['coste']:.1f} min/día "
f"(madrugón {st['madrugon']:.1f}) · {st['mins']} minutos, moda {st['moda']}%")
lines.append(f" <i>{profiles.resumen(n)}</i>")
lines.append("\n<i>madrugón = de esos minutos, los que te levantas antes "
"por la entrada de la mañana. Tu histórico real: 4 minutos, moda 38%.</i>")
return "\n".join(lines)
def _perfil_teclado(self) -> InlineKeyboardMarkup:
actual = self.svc.store.get("perfil", profiles.DEFECTO)
botones = [InlineKeyboardButton(
("✅ " if n == actual else "") + profiles.titulo(n),
callback_data=f"{CB}:perfil:{n}") for n in profiles.nombres()]
filas = [botones[i:i + 2] for i in range(0, len(botones), 2)]
return InlineKeyboardMarkup(filas)
async def cmd_perfil(self, update: Update, ctx) -> None:
if ctx.args:
n = ctx.args[0].strip().lower()
if not profiles.existe(n):
await update.effective_message.reply_text(
"No conozco ese perfil. Son: " + ", ".join(profiles.nombres()))
return
await self.svc.cambiar_perfil(n)
await update.effective_message.reply_text(
f"✅ Perfil <b>{profiles.titulo(n)}</b>. Replanificados los días futuros.",
parse_mode=ParseMode.HTML)
return
await update.effective_message.reply_text(
self._perfil_texto(), parse_mode=ParseMode.HTML,
reply_markup=self._perfil_teclado())
async def _cambiar_perfil(self, q, nombre: str) -> None:
if not profiles.existe(nombre):
await q.answer("Perfil desconocido")
return
await q.answer(f"Cambiando a {profiles.titulo(nombre)}…")
await self.svc.cambiar_perfil(nombre)
await q.edit_message_text(
self._perfil_texto() + "\n\n<b>Replanificados los días futuros.</b>",
parse_mode=ParseMode.HTML, reply_markup=self._perfil_teclado())
async def cmd_off(self, update: Update, ctx) -> None:
days = parse_range(ctx.args, self.svc.today())
if not days:
@@ -348,10 +399,16 @@ class BotUI:
if not self._mine(update):
await q.answer()
return
partes = q.data.split(":")
action = partes[1] if len(partes) > 1 else ""
if action == "perfil":
await self._cambiar_perfil(q, partes[2])
return
try:
_, action, day_s, idx_s = q.data.split(":")
day, idx = date.fromisoformat(day_s), int(idx_s)
except ValueError:
day, idx = date.fromisoformat(partes[2]), int(partes[3])
except (IndexError, ValueError):
await q.answer("Botón no reconocido")
return

View File

@@ -135,7 +135,8 @@ class Service:
logger.info("plan de %s: %s", day, [str(p) for p in plan])
return self.store.get_plan(day)
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: bool = False) -> None:
async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: bool = False,
force_all: bool = False) -> None:
"""Planifica hoy y los proximos dias, para que la prevision sea exacta."""
cal = await asyncio.to_thread(self.executor.calendar)
hoy = self.today()
@@ -143,7 +144,7 @@ class Service:
for i in range(dias):
d = hoy + timedelta(days=i)
antes = bool(self.store.get_plan(d))
await self._plan_un_dia(d, cal, force=force and i == 0)
await self._plan_un_dia(d, cal, force=force_all or (force and i == 0))
if not antes and self.store.get_plan(d):
nuevos += 1
self._planned_for = hoy
@@ -157,6 +158,13 @@ class Service:
await self.ensure_horizon(force=force)
return self.store.get_plan(self.today())
async def cambiar_perfil(self, nombre: str) -> None:
"""Cambia el perfil y vuelve a tirar los dados de todo lo que no se ha fichado."""
self.store.put("perfil", nombre)
self.store.log("perfil", f"cambiado a {nombre}")
logger.info("perfil de jitter cambiado a %s", nombre)
await self.ensure_horizon(force_all=True)
async def cancel_pending_today(self, reason: str) -> None:
day = self.today()
for r in self.store.pending(day):

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

122
app/profiles.py Normal file
View File

@@ -0,0 +1,122 @@
"""Perfiles de jitter intercambiables.
El compromiso de fondo no tiene truco: Timenet solo cuenta dentro de la ventana
teorica, asi que solo se puede variar hacia el lado seguro, y alejarse cuesta
presencia real. Mas variedad visible = mas minutos ahi. Cada perfil elige un punto
de ese compromiso.
Un perfil puede afinar por tramo con sufijo `_w<n>`: `entry_median_w0` gana a
`entry_median` para el tramo 0. Sirve para repartir el coste donde menos molesta:
adelantar la entrada de la mañana ALARGA el dia, mientras que adelantar la vuelta
de comer solo ACORTA la comida y se sale a la misma hora.
"""
from __future__ import annotations
# Valores comunes. Un perfil solo declara lo que cambia.
BASE = {
"entry_floor": 12,
"entry_median": 58,
"entry_sigma": 0.95,
"entry_cap": 330,
"exit_floor": 10,
"exit_median": 42,
"exit_sigma": 0.95,
"exit_cap": 300,
"drift_sigma": 0.30,
"drift_clamp": 0.75,
"entry_guard": 12,
}
PERFILES: dict[str, dict] = {
"discreto": {
"titulo": "Discreto",
"resumen": "Lo más barato. Casi siempre justo antes de la hora.",
"stats": {'coste': 2.82, 'madrugon': 1.29, 'mins': 2, 'moda': 75},
"params": {
"entry_median": 42, "entry_sigma": 0.80, "entry_cap": 240,
"exit_median": 30, "exit_sigma": 0.80, "exit_cap": 210,
},
},
"natural": {
"titulo": "Natural",
"resumen": "Equilibrio entre variedad y minutos de más.",
"stats": {'coste': 4.14, 'madrugon': 1.81, 'mins': 5, 'moda': 51},
"params": {
"entry_median": 58, "entry_sigma": 0.95, "entry_cap": 330,
"exit_median": 42, "exit_sigma": 0.95, "exit_cap": 300,
},
},
"camuflado": {
"titulo": "Camuflado",
"resumen": "Se parece a tu histórico real. Cuesta algo más.",
"stats": {'coste': 5.47, 'madrugon': 2.08, 'mins': 4, 'moda': 43},
"params": {
"entry_median": 75, "entry_sigma": 1.05, "entry_cap": 450,
"exit_median": 55, "exit_sigma": 1.05, "exit_cap": 420,
},
},
"disperso": {
"titulo": "Disperso",
"resumen": "Máxima variedad. Paga bastantes minutos por ella.",
"stats": {'coste': 7.87, 'madrugon': 2.99, 'mins': 6, 'moda': 29},
"params": {
"entry_median": 110, "entry_sigma": 1.15, "entry_cap": 600,
"exit_median": 80, "exit_sigma": 1.15, "exit_cap": 540,
},
},
"sin_madrugar": {
"titulo": "Sin madrugar",
"resumen": "Entrada de la mañana pegada a las 9. La variedad se la come "
"la pausa de comer y la salida, así no te levantas antes.",
"stats": {'coste': 5.68, 'madrugon': 1.26, 'mins': 2, 'moda': 77},
"params": {
# tramo 0 = mañana: lo mas ajustado posible, no alarga el dia
"entry_median_w0": 35, "entry_sigma_w0": 0.70, "entry_cap_w0": 150,
# tramo 1 = vuelta de comer: generoso, solo acorta la comida
"entry_median_w1": 120, "entry_sigma_w1": 1.15, "entry_cap_w1": 600,
# salidas: generosas, no obligan a madrugar
"exit_median": 75, "exit_sigma": 1.10, "exit_cap": 480,
},
},
}
DEFECTO = "natural"
def existe(nombre: str) -> bool:
return nombre in PERFILES
def nombres() -> list[str]:
return list(PERFILES)
def params(nombre: str) -> dict:
"""Parametros efectivos del perfil, con BASE por debajo."""
p = dict(BASE)
p.update(PERFILES.get(nombre, PERFILES[DEFECTO])["params"])
return p
def titulo(nombre: str) -> str:
return PERFILES.get(nombre, {}).get("titulo", nombre)
def resumen(nombre: str) -> str:
return PERFILES.get(nombre, {}).get("resumen", "")
def stats(nombre: str) -> dict:
"""Medidas sobre los 226 dias laborables del año. Ver tools/perfiles.py.
coste = presencia total de mas, min/dia
madrugon = de esos, los que te levantas antes por la entrada de la mañana
mins = minutos distintos visibles en la entrada de las 09:00
moda = cuota del minuto mas frecuente
"""
return PERFILES.get(nombre, {}).get("stats", {})
def valor(p: dict, clase: str, campo: str, wi: int):
"""`entry_median_w0` gana a `entry_median` para el tramo 0."""
return p.get(f"{clase}_{campo}_w{wi}", p[f"{clase}_{campo}"])

86
tools/perfiles.py Normal file
View File

@@ -0,0 +1,86 @@
#!/usr/bin/env python3
"""Mide cada perfil de jitter sobre el calendario real del año. No ficha nada."""
import json, os, random, sys, tempfile
from collections import Counter
from datetime import timedelta
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import profiles, schedule, settings
from app.planner import ENTRADA, Planner
from app.store import Store
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
CAL = os.path.join(ROOT, "data", "calendar_cache.json")
def medir(nombre, cal, cfg, semilla=11):
"""Devuelve (saldo, presencia_extra_min_dia, histograma_entrada, histograma_salida)."""
st = Store(os.path.join(tempfile.mkdtemp(), "m.db"))
st.put("perfil", nombre)
pl = Planner(cfg, st)
rng = random.Random(semilla)
lo, hi = cal.span
ent, sal = Counter(), Counter()
P = W = pres = dias = 0
peor = 10 ** 9
madrugon = 0 # minutos de mas en la ENTRADA DE LA MAÑANA (la que alarga el dia)
d = lo
while d <= hi:
if cal.is_workday(d):
plan = pl.plan_day(cal, d, rng)
dias += 1
for wi, w in enumerate(cal.windows(d)):
e = [x for x in plan if x.window_idx == wi and x.typ == ENTRADA][0]
s = [x for x in plan if x.window_idx == wi and x.typ != ENTRADA][0]
em = e.planned_at.hour * 60 + e.planned_at.minute
sm = s.planned_at.hour * 60 + s.planned_at.minute
W += min(sm, w.end) - max(em, w.start)
P += w.minutes
pres += sm - em
if wi == 0:
madrugon += w.start - em
if w.start == 540:
ent[e.planned_at.strftime("%H:%M")] += 1
if w.end == 1110:
sal[s.planned_at.strftime("%H:%M")] += 1
peor = min(peor, min(x.offset_seconds for x in plan))
d += timedelta(days=1)
st.close()
return dict(saldo=W - P, coste=(pres - P) / dias, madrugon=madrugon / dias,
peor=peor, ent=ent, sal=sal, dias=dias)
def barra(c, ancho=40):
t = sum(c.values())
vis = {m: n / t for m, n in sorted(c.items()) if n / t >= 0.03}
return vis, " ".join("%s %d%%" % (m, round(100 * p)) for m, p in vis.items())
def main():
cfg = settings.load()
cal = schedule.Calendar(json.load(open(CAL)))
print("%-14s %6s %9s %10s %6s %6s" %
("perfil", "saldo", "min/día", "madrugón", "mins", "moda"))
print("-" * 62)
for n in profiles.nombres():
r = medir(n, cal, cfg)
ve, _ = barra(r["ent"])
marca = " ←" if n == profiles.DEFECTO else ""
print("%-14s %+5d %8.2f %9.2f %6d %5d%%%s" %
(n, r["saldo"], r["coste"], r["madrugon"], len(ve),
round(100 * max(ve.values())), marca))
print("\nmadrugón = minutos/día que te levantas antes por la entrada de la mañana")
print("min/día = presencia total de más (mañana + comida + salidas)\n")
for n in profiles.nombres():
r = medir(n, cal, cfg)
_, se = barra(r["ent"])
_, ss = barra(r["sal"])
print("=== %s === %s" % (profiles.titulo(n), profiles.resumen(n)))
print(" entrada 09:00 %s" % se)
print(" salida 18:30 %s" % ss)
print("tu histórico real: entrada 09:00 23% 09:01 8% 09:02 31% 09:03 38%")
if __name__ == "__main__":
main()