Files
autoficher/app/planner.py
omar a496b1c672 fix: el jitter era demasiado regular; planificar por adelantado
El 96% de las entradas caian en solo dos minutos (08:58 / 08:59). El historial
real del usuario tiene 4 valores repartidos, o sea que el bot resultaba MAS
regular que una persona.

Tres cosas estaban mal:

- La deriva de la costumbre se aplicaba SUMANDO segundos. Cuando salia negativa
  hundia el valor contra el suelo y amontonaba medio año en el mismo minuto,
  justo lo contrario de lo que pretendia. Ahora va en escala logaritmica:
  multiplica el centro en vez de sumarse. Solo con eso la moda baja del 50% al
  39%, sin coste ninguno.

- La distribucion diaria era uniforme. Una lognormal da mas minutos distintos
  por menos presencia extra, porque la mayoria de los dias caen baratos cerca de
  la hora y solo unos pocos se van lejos. Ademas es la forma real de la llegada
  de alguien que intenta ser puntual.

- El tope recortaba con min(), asi que un 3% de los dias caia exactamente en el
  limite y se veia como un muro en el histograma. Ahora resortea.

El compromiso de fondo no tiene truco: solo se puede variar hacia atras, y
alejarse cuesta presencia. Perfiles medidos sobre los 226 dias laborables del
año, los tres con saldo 0:

    discreto    2,8 min/dia   entrada 2 minutos, moda 75%
    natural     4,2 min/dia   entrada 5 minutos, moda 51%   <- por defecto
    camuflado   5,5 min/dia   entrada 4 minutos, moda 43%

Ademas, /proximos mostraba una banda estimada que ocultaba la cola. Ahora se
planifican 21 dias por adelantado y se muestran las horas exactas, con lo que
la prevision es real y se puede decidir sobre ella. El plan guardado se
revalida contra el calendario de Timenet y se regenera si cambia el tipo de
jornada de un dia. Google Calendar se consulta con una sola autenticacion para
todo el horizonte.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-24 19:36:38 +02:00

116 lines
4.7 KiB
Python

"""Construye el plan de fichajes de un dia a partir del calendario de Timenet."""
from __future__ import annotations
import logging
import random
from dataclasses import dataclass
from datetime import date, datetime, timedelta
from zoneinfo import ZoneInfo
from app import jitter
from app.schedule import Calendar
logger = logging.getLogger(__name__)
ENTRADA, SALIDA = 0, 1
@dataclass
class PlannedCheck:
idx: int
typ: int # 0 entrada, 1 salida
window_idx: int
target: datetime # hora teorica
planned_at: datetime # instante real en que se fichara
@property
def label(self) -> str:
return "ENTRADA" if self.typ == ENTRADA else "SALIDA"
@property
def offset_seconds(self) -> int:
"""Segundos de margen respecto de la teorica. Positivo = lado seguro."""
d = (self.planned_at - self.target).total_seconds()
return int(-d if self.typ == ENTRADA else d)
def __str__(self) -> str:
return "%-7s teorica %s ficha %s (%+ds -> %s)" % (
self.label, self.target.strftime("%H:%M:%S"),
self.planned_at.strftime("%H:%M:%S"),
int((self.planned_at - self.target).total_seconds()),
"antes" if self.typ == ENTRADA else "despues")
class Planner:
def __init__(self, cfg: dict, store=None):
self.cfg = cfg
self.store = store
self.tz = ZoneInfo(cfg.get("timezone", "Europe/Madrid"))
self.j = cfg["jitter"]
# ------------------------------------------------------------------ drift
def _drift_key(self, window_idx: int, typ: int) -> str:
return f"drift.{window_idx}.{typ}"
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)
if self.store:
self.store.put(key, nxt)
return nxt
# ------------------------------------------------------------------- plan
def plan_day(self, cal: Calendar, day: date,
rng: random.Random | None = None) -> list[PlannedCheck]:
"""Plan del dia. Lista vacia si no toca fichar."""
if not cal.is_workday(day):
logger.info("%s no es laborable: %s", day, cal.why_not_workday(day))
return []
rng = rng or random.Random()
out: list[PlannedCheck] = []
idx = 0
for wi, w in enumerate(cal.windows(day)):
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. 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_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_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
self._validate(out, cal, day)
return out
# --------------------------------------------------------------- garantias
def _validate(self, plan: list[PlannedCheck], cal: Calendar, day: date) -> None:
"""Invariantes que, si se rompen, cuestan minutos de verdad."""
for p in plan:
if p.typ == ENTRADA and p.planned_at >= p.target:
raise AssertionError(f"entrada planificada tarde: {p}")
if p.typ == SALIDA and p.planned_at <= p.target:
raise AssertionError(f"salida planificada pronto: {p}")
for a, b in zip(plan, plan[1:]):
if b.planned_at <= a.planned_at:
raise AssertionError(f"fichajes desordenados: {a} / {b}")
def rows_for_store(self, plan: list[PlannedCheck]) -> list[dict]:
return [{"idx": p.idx, "typ": p.typ, "window_idx": p.window_idx,
"target": p.target.isoformat(), "planned_at": p.planned_at.isoformat()}
for p in plan]