Files
autoficher/app/planner.py
omar a1eef6472f 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>
2026-09-24 19:41:50 +02:00

135 lines
5.3 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, profiles
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"))
@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:
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
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
# ------------------------------------------------------------------- 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()
j = self.j
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.
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(
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, j["entry_guard"])
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
idx += 1
# SALIDA: siempre despues
off = jitter.offset_seconds(
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
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]