From a1eef6472f8a9ef4d7f604372a7caf0b1216f997 Mon Sep 17 00:00:00 2001 From: omar Date: Thu, 24 Sep 2026 19:41:50 +0200 Subject: [PATCH] feat: perfiles de jitter intercambiables desde Telegram MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: 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) --- README.md | 45 +++++++++++------ app/bot.py | 67 +++++++++++++++++++++++-- app/daemon.py | 12 ++++- app/planner.py | 37 ++++++++++---- app/profiles.py | 122 ++++++++++++++++++++++++++++++++++++++++++++++ tools/perfiles.py | 86 ++++++++++++++++++++++++++++++++ 6 files changed, 339 insertions(+), 30 deletions(-) create mode 100644 app/profiles.py create mode 100644 tools/perfiles.py diff --git a/README.md b/README.md index 9af75d1..341fd33 100644 --- a/README.md +++ b/README.md @@ -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`: `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) diff --git a/app/bot.py b/app/bot.py index d162530..5802968 100644 --- a/app/bot.py +++ b/app/bot.py @@ -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 = ["Perfil de jitter", + "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}{profiles.titulo(n)} — {st['coste']:.1f} min/día " + f"(madrugón {st['madrugon']:.1f}) · {st['mins']} minutos, moda {st['moda']}%") + lines.append(f" {profiles.resumen(n)}") + lines.append("\nmadrugón = de esos minutos, los que te levantas antes " + "por la entrada de la mañana. Tu histórico real: 4 minutos, moda 38%.") + 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 {profiles.titulo(n)}. 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\nReplanificados los días futuros.", + 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 diff --git a/app/daemon.py b/app/daemon.py index 09d142e..d9758da 100644 --- a/app/daemon.py +++ b/app/daemon.py @@ -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): diff --git a/app/planner.py b/app/planner.py index 70bc71a..7ecfd74 100644 --- a/app/planner.py +++ b/app/planner.py @@ -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 diff --git a/app/profiles.py b/app/profiles.py new file mode 100644 index 0000000..d0eb84a --- /dev/null +++ b/app/profiles.py @@ -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`: `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}"]) diff --git a/tools/perfiles.py b/tools/perfiles.py new file mode 100644 index 0000000..a9e64ec --- /dev/null +++ b/tools/perfiles.py @@ -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()