diff --git a/app/bot.py b/app/bot.py index 056c921..d162530 100644 --- a/app/bot.py +++ b/app/bot.py @@ -13,6 +13,8 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton, from telegram.constants import ParseMode from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes +from app import jitter + logger = logging.getLogger(__name__) ENTRADA = 0 @@ -234,57 +236,43 @@ class BotUI: async def cmd_proximos(self, update: Update, ctx) -> None: try: - ndias = max(1, min(60, int(ctx.args[0]))) if ctx.args else 14 + ndias = max(1, min(45, int(ctx.args[0]))) if ctx.args else 14 except ValueError: ndias = 14 cal = await asyncio.to_thread(self.svc.executor.calendar) - j = self.svc.cfg["jitter"] hoy = self.svc.today() + icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑", + "skipped": "⤵️", "failed": "🟥"} lines = [f"Previsión · próximos {ndias} días"] laborables = 0 for i in range(ndias): d = hoy + timedelta(days=i) - cabecera = f"{fecha_corta(d)}" + cab = f"{fecha_corta(d)}" ov = self.svc.store.get_override(d) if ov and ov["action"] == "off": - lines.append(f"{cabecera} 🚫 {ov['reason'] or 'desactivado a mano'}") + lines.append(f"{cab} 🚫 {ov['reason'] or 'desactivado a mano'}") continue if not cal.is_workday(d): - lines.append(f"{cabecera} 🌴 {cal.why_not_workday(d)}") + lines.append(f"{cab} 🌴 {cal.why_not_workday(d)}") continue laborables += 1 - if d == hoy and self.svc.store.get_plan(d): - icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑", - "skipped": "⤵️", "failed": "🟥"} - trozos = [] - for r in self.svc.store.get_plan(d): - t = datetime.fromisoformat(r["planned_at"]) - trozos.append(f"{icon.get(r['state'], '·')}{t:%H:%M:%S}") - lines.append(f"{cabecera} {' '.join(trozos)}") - continue - - # dias futuros: el instante exacto se sortea esa madrugada, pero el - # rango en que caera si es exacto - trozos = [] - for w in cal.windows(d): - ini = datetime.combine(d, datetime.min.time()) - ent = ini + timedelta(minutes=w.start) - sal = ini + timedelta(minutes=w.end) - trozos.append( - f"{ent - timedelta(seconds=j['entry_before_max']):%H:%M}" - f"-{ent - timedelta(seconds=j['entry_before_min']):%H:%M}" - f" → {sal + timedelta(seconds=j['exit_after_min']):%H:%M}" - f"-{sal + timedelta(seconds=j['exit_after_max']):%H:%M}") - lines.append(f"{cabecera} {' · '.join(trozos)}") + plan = self.svc.store.get_plan(d) + if plan: + trozos = [f"{icon.get(r['state'], '·')}" + f"{datetime.fromisoformat(r['planned_at']):%H:%M:%S}" + for r in plan] + lines.append(f"{cab} {' '.join(trozos)}") + else: + # mas alla del horizonte que se planifica por adelantado + tramos = " ".join(str(w) for w in cal.windows(d)) + lines.append(f"{cab} {tramos} (sin planificar aún)") b = await asyncio.to_thread(self.svc.executor.balance) lines.append(f"\n{laborables} días laborables · saldo acumulado {hm(b['total'])}") - lines.append("Las horas futuras son el rango en que caerá el fichaje; " - "el segundo exacto se sortea esa madrugada.") await update.effective_message.reply_text("\n".join(lines), parse_mode=ParseMode.HTML) diff --git a/app/daemon.py b/app/daemon.py index c08dfd1..09d142e 100644 --- a/app/daemon.py +++ b/app/daemon.py @@ -27,6 +27,7 @@ logger = logging.getLogger(__name__) ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch MAX_SLEEP = 60.0 REPLAN_HOUR = 4 # hora a la que se planifica el dia +HORIZON_DAYS = 21 # dias que se planifican por adelantado class Service: @@ -41,6 +42,7 @@ class Service: self._wake = asyncio.Event() self._stop = asyncio.Event() self._planned_for: date | None = None + self._gcal = None # ------------------------------------------------------------------ basico def now(self) -> datetime: @@ -78,7 +80,9 @@ class Service: return None try: from app import google_calendar - ev = await asyncio.to_thread(lambda: google_calendar.GoogleCalendar().getEvent(day)) + if self._gcal is None: # una sola autenticacion para todo el horizonte + self._gcal = await asyncio.to_thread(google_calendar.GoogleCalendar) + ev = await asyncio.to_thread(self._gcal.getEvent, day) if ev and isinstance(ev, str) and not ev.startswith("Error"): return ev if ev and str(ev).startswith("Error"): @@ -89,43 +93,70 @@ class Service: return None # --------------------------------------------------------------------- plan - async def ensure_plan(self, force: bool = False) -> list: - day = self.today() - if self._planned_for == day and not force: - return self.store.get_plan(day) - - cal = await asyncio.to_thread(self.executor.calendar) - self._planned_for = day + def _plan_al_dia(self, day: date, cal) -> bool: + """El plan guardado sigue cuadrando con el calendario de Timenet.""" + rows = self.store.get_plan(day) + if not rows: + return False + esperado = [m for w in cal.windows(day) for m in (w.start, w.end)] + real = [] + for r in rows: + t = datetime.fromisoformat(r["target"]) + real.append(t.hour * 60 + t.minute) + return real == esperado + async def _plan_un_dia(self, day: date, cal, force: bool = False) -> list: + """Planifica un dia concreto. Devuelve las filas guardadas.""" if self.is_paused(): - logger.info("servicio en pausa, no se planifica") return [] ov = self.store.get_override(day) if ov and ov["action"] == "off": - logger.info("%s desactivado a mano: %s", day, ov["reason"]) return [] if not cal.is_workday(day): - motivo = cal.why_not_workday(day) - logger.info("%s no laborable: %s", day, motivo) - self.store.log("plan", f"{day} no laborable: {motivo}") + if day == self.today(): + self.store.log("plan", f"{day} no laborable: {cal.why_not_workday(day)}") return [] + # ya planificado y el calendario no ha cambiado + if not force and self._plan_al_dia(day, cal): + return self.store.get_plan(day) + ausencia = await self._personal_absence(day) if ausencia: self.store.set_override(day, "off", f"Google Calendar: {ausencia}") - await self.notify(f"🌴 Hoy no se ficha: {ausencia} (Google Calendar)") + if day == self.today(): + await self.notify(f"🌴 Hoy no se ficha: {ausencia} (Google Calendar)") return [] plan = self.planner.plan_day(cal, day, random.Random()) self.store.save_plan(day, self.planner.rows_for_store(plan)) - self.store.log("plan", f"{day} planificado: " + - ", ".join(f"{p.label[:3]} {p.planned_at:%H:%M:%S}" for p in plan)) logger.info("plan de %s: %s", day, [str(p) for p in plan]) - self.wake() return self.store.get_plan(day) + async def ensure_horizon(self, dias: int = HORIZON_DAYS, force: 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() + nuevos = 0 + 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) + if not antes and self.store.get_plan(d): + nuevos += 1 + self._planned_for = hoy + if nuevos: + self.store.log("plan", f"planificados {nuevos} dias nuevos hasta " + f"{hoy + timedelta(days=dias - 1)}") + self.wake() + + async def ensure_plan(self, force: bool = False) -> list: + """Plan de hoy. `force` lo vuelve a tirar aunque ya exista.""" + await self.ensure_horizon(force=force) + return self.store.get_plan(self.today()) + async def cancel_pending_today(self, reason: str) -> None: day = self.today() for r in self.store.pending(day): @@ -185,7 +216,7 @@ class Service: async def tick(self) -> None: now = self.now() if self._planned_for != now.date(): - await self.ensure_plan() + await self.ensure_horizon() if self.is_paused(): return diff --git a/app/jitter.py b/app/jitter.py index 8c528b2..3d01cc5 100644 --- a/app/jitter.py +++ b/app/jitter.py @@ -5,9 +5,18 @@ Reglas del juego (ver app/schedule.py y el analisis del historico): * Timenet solo cuenta el tiempo DENTRO de la ventana teorica. Una entrada tardia se debita al minuto; una salida tardia no acredita nada. Por tanto las ENTRADAS se fichan siempre ANTES de la hora y las SALIDAS siempre DESPUES. Nunca al reves. -* Para que no parezca un dado nuevo cada dia, cada hueco del horario tiene su propia - "costumbre" que deriva lentamente (proceso de reversion a la media) y sobre la que - se suma el ruido del dia. + +* Esa restriccion es asimetrica: solo se puede variar hacia el lado seguro, y + alejarse cuesta presencia real. Una distribucion uniforme resuelve mal ese + compromiso: para que se vean 4 minutos distintos hay que pagar 2,1 min de media. + Una LOGNORMAL da mas variedad por menos coste (5 minutos por 1,9) porque la + mayoria de los dias caen baratos, cerca de la hora, y solo unos pocos se van + lejos. Ademas es la forma que tiene de verdad la llegada de una persona que + intenta ser puntual: moda pegada a la hora y cola hacia atras. + +* Encima va la COSTUMBRE, que deriva despacio: la hora habitual de esta semana no + es la de la que viene. Eso ensancha lo que se ve a lo largo del mes sin coste + diario, y es mas humano que tirar un dado nuevo e independiente cada dia. """ from __future__ import annotations @@ -16,8 +25,8 @@ import random # reversion a la media de la costumbre diaria: 0 = sin memoria, 1 = no se mueve DRIFT_MEMORY = 0.82 -# probabilidad de un dia atipico (te despistas y llegas bastante antes / te quedas mas) -OUTLIER_P = 0.06 +# z de la normal tipificada para el percentil 10/90, usado al describir el rango +Z90 = 1.2816 def next_drift(previous: float, sigma: float, clamp: float, rng: random.Random) -> float: @@ -26,24 +35,34 @@ def next_drift(previous: float, sigma: float, clamp: float, rng: random.Random) return max(-clamp, min(clamp, nxt)) -def offset_seconds(lo: int, hi: int, drift: float, rng: random.Random, - outlier_cap: int | None = None) -> int: - """Desplazamiento en segundos respecto de la hora teorica, siempre en el lado seguro. +def offset_seconds(floor: int, median: float, sigma: float, cap: int, + drift: float, rng: random.Random) -> int: + """Desplazamiento en segundos respecto de la hora teorica, en el lado seguro. Devuelve un numero POSITIVO: cuanto antes se ficha una entrada, o cuanto despues - se ficha una salida. La cola larga va hacia el lado seguro (mas margen), nunca - hacia el lado que cuesta dinero. + se ficha una salida. Lognormal desplazada, con la moda cerca de `median` y la + cola hacia mas margen (nunca hacia menos). + + `drift` va en unidades LOGARITMICAS, no en segundos: multiplica el centro en vez + de sumarse. Sumandolo, una deriva negativa lo hundia contra `floor` y amontonaba + la mitad de los dias en el mismo minuto, justo lo que se queria evitar. """ - mid = (lo + hi) / 2.0 - spread = max(1.0, (hi - lo) / 4.0) - val = mid + drift + rng.gauss(0.0, spread) + centro = max(1.0, median - floor) * math.exp(drift) + # resorteo en vez de recorte: truncar contra el tope amontonaba un 3% de los + # dias exactamente en el limite, que se ve como un muro en el histograma + for _ in range(8): + val = floor + centro * math.exp(rng.gauss(0.0, sigma)) + if val <= cap: + return int(round(max(float(floor), val))) + return int(round(max(float(floor), min(float(cap), val)))) - if rng.random() < OUTLIER_P: - cap = outlier_cap if outlier_cap is not None else hi * 3 - val += abs(rng.gauss(0.0, (cap - hi) / 2.5)) if cap > hi else 0.0 - val = min(val, cap) - return int(round(max(lo, val))) +def offset_band(floor: int, median: float, sigma: float, cap: int) -> tuple[int, int]: + """Banda p10-p90 en segundos, para describir el rango sin prometer la cola.""" + centro = max(1.0, median - floor) + lo = floor + centro * math.exp(-Z90 * sigma) + hi = floor + centro * math.exp(+Z90 * sigma) + return int(round(max(floor, lo))), int(round(min(float(cap), hi))) def jitter_geo(latitude: float, longitude: float, meters: float, diff --git a/app/planner.py b/app/planner.py index e766d40..70bc71a 100644 --- a/app/planner.py +++ b/app/planner.py @@ -76,23 +76,21 @@ class Planner: start = datetime.combine(day, datetime.min.time(), self.tz) + timedelta(minutes=w.start) end = datetime.combine(day, datetime.min.time(), self.tz) + timedelta(minutes=w.end) - # ENTRADA: siempre antes de la hora teorica + # 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"] off = jitter.offset_seconds( - self.j["entry_before_min"], self.j["entry_before_max"], - self._drift(wi, ENTRADA, rng), rng, - # el margen de entrada del propio Timenet acota cuanto se puede adelantar - outlier_cap=min(self.j["entry_before_max"] * 3, max(w.entry_margin * 60 // 2, 60)), - ) + self.j["entry_floor"], self.j["entry_median"], self.j["entry_sigma"], + tope, self._drift(wi, ENTRADA, rng), rng) off = max(off, self.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_after_min"], self.j["exit_after_max"], - self._drift(wi, SALIDA, rng), rng, - outlier_cap=self.j["exit_after_max"] * 4, - ) + self.j["exit_floor"], self.j["exit_median"], self.j["exit_sigma"], + self.j["exit_cap"], self._drift(wi, SALIDA, rng), rng) out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off))) idx += 1 diff --git a/app/settings.py b/app/settings.py index 8827a83..586a152 100644 --- a/app/settings.py +++ b/app/settings.py @@ -19,18 +19,28 @@ DEFAULTS = { # margen, en segundos, con el que se ficha respecto de la hora teorica. # Entradas ANTES (Timenet no acredita nada fuera de la ventana, pero si # debita las entradas tardias). Salidas DESPUES. - # perfil "equilibrado": saldo 0 con ~3,6 min/dia de presencia extra. - # Mas holgado (40/150/30/130) = mas seguro pero ~5,7 min/dia. - # Mas ajustado (20/70/15/45) = ~2,7 min/dia pero solo 15s de colchon. - "entry_before_min": 30, - "entry_before_max": 100, - "exit_after_min": 20, - "exit_after_max": 70, - # deriva lenta de la hora habitual, para no parecer un dado nuevo cada dia - "drift_sigma": 25, - "drift_clamp": 60, + # Lognormal: la moda queda pegada a la hora teorica y la cola va hacia + # mas margen. Da mas minutos distintos por menos presencia extra que una + # uniforme (ver la nota de cabecera de app/jitter.py). + # floor = margen minimo, median = mediana, sigma = longitud de la cola, + # cap = tope duro. + # perfil "natural": 5 minutos distintos en la entrada, 4,1 min/dia de + # presencia extra. Ver la tabla de perfiles en el README. + "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, + # Deriva lenta de la hora habitual, en unidades LOGARITMICAS (multiplica + # el centro). Mueve la moda de una semana a otra, lo que ensancha lo que se + # ve a lo largo del mes con coste medio practicamente nulo. + "drift_sigma": 0.30, + "drift_clamp": 0.75, # margen de seguridad: nunca fichar una entrada a menos de X s de la hora - "entry_guard": 20, + "entry_guard": 12, }, "geo_jitter_meters": 12.0, "telegram": {},