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": {},