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>
This commit is contained in:
48
app/bot.py
48
app/bot.py
@@ -13,6 +13,8 @@ from telegram import (BotCommand, BotCommandScopeChat, InlineKeyboardButton,
|
|||||||
from telegram.constants import ParseMode
|
from telegram.constants import ParseMode
|
||||||
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
from telegram.ext import Application, CallbackQueryHandler, CommandHandler, ContextTypes
|
||||||
|
|
||||||
|
from app import jitter
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
ENTRADA = 0
|
ENTRADA = 0
|
||||||
@@ -234,57 +236,43 @@ class BotUI:
|
|||||||
|
|
||||||
async def cmd_proximos(self, update: Update, ctx) -> None:
|
async def cmd_proximos(self, update: Update, ctx) -> None:
|
||||||
try:
|
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:
|
except ValueError:
|
||||||
ndias = 14
|
ndias = 14
|
||||||
|
|
||||||
cal = await asyncio.to_thread(self.svc.executor.calendar)
|
cal = await asyncio.to_thread(self.svc.executor.calendar)
|
||||||
j = self.svc.cfg["jitter"]
|
|
||||||
hoy = self.svc.today()
|
hoy = self.svc.today()
|
||||||
|
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
|
||||||
|
"skipped": "⤵️", "failed": "🟥"}
|
||||||
lines = [f"<b>Previsión · próximos {ndias} días</b>"]
|
lines = [f"<b>Previsión · próximos {ndias} días</b>"]
|
||||||
laborables = 0
|
laborables = 0
|
||||||
|
|
||||||
for i in range(ndias):
|
for i in range(ndias):
|
||||||
d = hoy + timedelta(days=i)
|
d = hoy + timedelta(days=i)
|
||||||
cabecera = f"<b>{fecha_corta(d)}</b>"
|
cab = f"<b>{fecha_corta(d)}</b>"
|
||||||
|
|
||||||
ov = self.svc.store.get_override(d)
|
ov = self.svc.store.get_override(d)
|
||||||
if ov and ov["action"] == "off":
|
if ov and ov["action"] == "off":
|
||||||
lines.append(f"{cabecera} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
|
lines.append(f"{cab} 🚫 <i>{ov['reason'] or 'desactivado a mano'}</i>")
|
||||||
continue
|
continue
|
||||||
if not cal.is_workday(d):
|
if not cal.is_workday(d):
|
||||||
lines.append(f"{cabecera} 🌴 <i>{cal.why_not_workday(d)}</i>")
|
lines.append(f"{cab} 🌴 <i>{cal.why_not_workday(d)}</i>")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
laborables += 1
|
laborables += 1
|
||||||
if d == hoy and self.svc.store.get_plan(d):
|
plan = self.svc.store.get_plan(d)
|
||||||
icon = {"pending": "⏳", "done": "✅", "cancelled": "🛑",
|
if plan:
|
||||||
"skipped": "⤵️", "failed": "🟥"}
|
trozos = [f"{icon.get(r['state'], '·')}"
|
||||||
trozos = []
|
f"{datetime.fromisoformat(r['planned_at']):%H:%M:%S}"
|
||||||
for r in self.svc.store.get_plan(d):
|
for r in plan]
|
||||||
t = datetime.fromisoformat(r["planned_at"])
|
lines.append(f"{cab} <code>{' '.join(trozos)}</code>")
|
||||||
trozos.append(f"{icon.get(r['state'], '·')}{t:%H:%M:%S}")
|
else:
|
||||||
lines.append(f"{cabecera} {' '.join(trozos)}")
|
# mas alla del horizonte que se planifica por adelantado
|
||||||
continue
|
tramos = " ".join(str(w) for w in cal.windows(d))
|
||||||
|
lines.append(f"{cab} <code>{tramos}</code> <i>(sin planificar aún)</i>")
|
||||||
# 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} <code>{' · '.join(trozos)}</code>")
|
|
||||||
|
|
||||||
b = await asyncio.to_thread(self.svc.executor.balance)
|
b = await asyncio.to_thread(self.svc.executor.balance)
|
||||||
lines.append(f"\n{laborables} días laborables · saldo acumulado <b>{hm(b['total'])}</b>")
|
lines.append(f"\n{laborables} días laborables · saldo acumulado <b>{hm(b['total'])}</b>")
|
||||||
lines.append("<i>Las horas futuras son el rango en que caerá el fichaje; "
|
|
||||||
"el segundo exacto se sortea esa madrugada.</i>")
|
|
||||||
await update.effective_message.reply_text("\n".join(lines),
|
await update.effective_message.reply_text("\n".join(lines),
|
||||||
parse_mode=ParseMode.HTML)
|
parse_mode=ParseMode.HTML)
|
||||||
|
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ logger = logging.getLogger(__name__)
|
|||||||
ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch
|
ANNOUNCE_LEAD = timedelta(minutes=12) # cuanta antelacion tiene el killswitch
|
||||||
MAX_SLEEP = 60.0
|
MAX_SLEEP = 60.0
|
||||||
REPLAN_HOUR = 4 # hora a la que se planifica el dia
|
REPLAN_HOUR = 4 # hora a la que se planifica el dia
|
||||||
|
HORIZON_DAYS = 21 # dias que se planifican por adelantado
|
||||||
|
|
||||||
|
|
||||||
class Service:
|
class Service:
|
||||||
@@ -41,6 +42,7 @@ class Service:
|
|||||||
self._wake = asyncio.Event()
|
self._wake = asyncio.Event()
|
||||||
self._stop = asyncio.Event()
|
self._stop = asyncio.Event()
|
||||||
self._planned_for: date | None = None
|
self._planned_for: date | None = None
|
||||||
|
self._gcal = None
|
||||||
|
|
||||||
# ------------------------------------------------------------------ basico
|
# ------------------------------------------------------------------ basico
|
||||||
def now(self) -> datetime:
|
def now(self) -> datetime:
|
||||||
@@ -78,7 +80,9 @@ class Service:
|
|||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
from app import google_calendar
|
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"):
|
if ev and isinstance(ev, str) and not ev.startswith("Error"):
|
||||||
return ev
|
return ev
|
||||||
if ev and str(ev).startswith("Error"):
|
if ev and str(ev).startswith("Error"):
|
||||||
@@ -89,43 +93,70 @@ class Service:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
# --------------------------------------------------------------------- plan
|
# --------------------------------------------------------------------- plan
|
||||||
async def ensure_plan(self, force: bool = False) -> list:
|
def _plan_al_dia(self, day: date, cal) -> bool:
|
||||||
day = self.today()
|
"""El plan guardado sigue cuadrando con el calendario de Timenet."""
|
||||||
if self._planned_for == day and not force:
|
rows = self.store.get_plan(day)
|
||||||
return self.store.get_plan(day)
|
if not rows:
|
||||||
|
return False
|
||||||
cal = await asyncio.to_thread(self.executor.calendar)
|
esperado = [m for w in cal.windows(day) for m in (w.start, w.end)]
|
||||||
self._planned_for = day
|
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():
|
if self.is_paused():
|
||||||
logger.info("servicio en pausa, no se planifica")
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
ov = self.store.get_override(day)
|
ov = self.store.get_override(day)
|
||||||
if ov and ov["action"] == "off":
|
if ov and ov["action"] == "off":
|
||||||
logger.info("%s desactivado a mano: %s", day, ov["reason"])
|
|
||||||
return []
|
return []
|
||||||
|
|
||||||
if not cal.is_workday(day):
|
if not cal.is_workday(day):
|
||||||
motivo = cal.why_not_workday(day)
|
if day == self.today():
|
||||||
logger.info("%s no laborable: %s", day, motivo)
|
self.store.log("plan", f"{day} no laborable: {cal.why_not_workday(day)}")
|
||||||
self.store.log("plan", f"{day} no laborable: {motivo}")
|
|
||||||
return []
|
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)
|
ausencia = await self._personal_absence(day)
|
||||||
if ausencia:
|
if ausencia:
|
||||||
self.store.set_override(day, "off", f"Google Calendar: {ausencia}")
|
self.store.set_override(day, "off", f"Google Calendar: {ausencia}")
|
||||||
|
if day == self.today():
|
||||||
await self.notify(f"🌴 Hoy no se ficha: <i>{ausencia}</i> (Google Calendar)")
|
await self.notify(f"🌴 Hoy no se ficha: <i>{ausencia}</i> (Google Calendar)")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
plan = self.planner.plan_day(cal, day, random.Random())
|
plan = self.planner.plan_day(cal, day, random.Random())
|
||||||
self.store.save_plan(day, self.planner.rows_for_store(plan))
|
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])
|
logger.info("plan de %s: %s", day, [str(p) for p in plan])
|
||||||
self.wake()
|
|
||||||
return self.store.get_plan(day)
|
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:
|
async def cancel_pending_today(self, reason: str) -> None:
|
||||||
day = self.today()
|
day = self.today()
|
||||||
for r in self.store.pending(day):
|
for r in self.store.pending(day):
|
||||||
@@ -185,7 +216,7 @@ class Service:
|
|||||||
async def tick(self) -> None:
|
async def tick(self) -> None:
|
||||||
now = self.now()
|
now = self.now()
|
||||||
if self._planned_for != now.date():
|
if self._planned_for != now.date():
|
||||||
await self.ensure_plan()
|
await self.ensure_horizon()
|
||||||
if self.is_paused():
|
if self.is_paused():
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
* 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
|
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.
|
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
|
* Esa restriccion es asimetrica: solo se puede variar hacia el lado seguro, y
|
||||||
se suma el ruido del dia.
|
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
|
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
|
# reversion a la media de la costumbre diaria: 0 = sin memoria, 1 = no se mueve
|
||||||
DRIFT_MEMORY = 0.82
|
DRIFT_MEMORY = 0.82
|
||||||
# probabilidad de un dia atipico (te despistas y llegas bastante antes / te quedas mas)
|
# z de la normal tipificada para el percentil 10/90, usado al describir el rango
|
||||||
OUTLIER_P = 0.06
|
Z90 = 1.2816
|
||||||
|
|
||||||
|
|
||||||
def next_drift(previous: float, sigma: float, clamp: float, rng: random.Random) -> float:
|
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))
|
return max(-clamp, min(clamp, nxt))
|
||||||
|
|
||||||
|
|
||||||
def offset_seconds(lo: int, hi: int, drift: float, rng: random.Random,
|
def offset_seconds(floor: int, median: float, sigma: float, cap: int,
|
||||||
outlier_cap: int | None = None) -> int:
|
drift: float, rng: random.Random) -> int:
|
||||||
"""Desplazamiento en segundos respecto de la hora teorica, siempre en el lado seguro.
|
"""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
|
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
|
se ficha una salida. Lognormal desplazada, con la moda cerca de `median` y la
|
||||||
hacia el lado que cuesta dinero.
|
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
|
centro = max(1.0, median - floor) * math.exp(drift)
|
||||||
spread = max(1.0, (hi - lo) / 4.0)
|
# resorteo en vez de recorte: truncar contra el tope amontonaba un 3% de los
|
||||||
val = mid + drift + rng.gauss(0.0, spread)
|
# 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,
|
def jitter_geo(latitude: float, longitude: float, meters: float,
|
||||||
|
|||||||
@@ -76,23 +76,21 @@ class Planner:
|
|||||||
start = datetime.combine(day, datetime.min.time(), self.tz) + timedelta(minutes=w.start)
|
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)
|
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(
|
off = jitter.offset_seconds(
|
||||||
self.j["entry_before_min"], self.j["entry_before_max"],
|
self.j["entry_floor"], self.j["entry_median"], self.j["entry_sigma"],
|
||||||
self._drift(wi, ENTRADA, rng), rng,
|
tope, 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)),
|
|
||||||
)
|
|
||||||
off = max(off, self.j["entry_guard"])
|
off = max(off, self.j["entry_guard"])
|
||||||
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
|
out.append(PlannedCheck(idx, ENTRADA, wi, start, start - timedelta(seconds=off)))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
# SALIDA: siempre despues
|
# SALIDA: siempre despues
|
||||||
off = jitter.offset_seconds(
|
off = jitter.offset_seconds(
|
||||||
self.j["exit_after_min"], self.j["exit_after_max"],
|
self.j["exit_floor"], self.j["exit_median"], self.j["exit_sigma"],
|
||||||
self._drift(wi, SALIDA, rng), rng,
|
self.j["exit_cap"], self._drift(wi, SALIDA, rng), rng)
|
||||||
outlier_cap=self.j["exit_after_max"] * 4,
|
|
||||||
)
|
|
||||||
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
|
out.append(PlannedCheck(idx, SALIDA, wi, end, end + timedelta(seconds=off)))
|
||||||
idx += 1
|
idx += 1
|
||||||
|
|
||||||
|
|||||||
@@ -19,18 +19,28 @@ DEFAULTS = {
|
|||||||
# margen, en segundos, con el que se ficha respecto de la hora teorica.
|
# 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
|
# Entradas ANTES (Timenet no acredita nada fuera de la ventana, pero si
|
||||||
# debita las entradas tardias). Salidas DESPUES.
|
# debita las entradas tardias). Salidas DESPUES.
|
||||||
# perfil "equilibrado": saldo 0 con ~3,6 min/dia de presencia extra.
|
# Lognormal: la moda queda pegada a la hora teorica y la cola va hacia
|
||||||
# Mas holgado (40/150/30/130) = mas seguro pero ~5,7 min/dia.
|
# mas margen. Da mas minutos distintos por menos presencia extra que una
|
||||||
# Mas ajustado (20/70/15/45) = ~2,7 min/dia pero solo 15s de colchon.
|
# uniforme (ver la nota de cabecera de app/jitter.py).
|
||||||
"entry_before_min": 30,
|
# floor = margen minimo, median = mediana, sigma = longitud de la cola,
|
||||||
"entry_before_max": 100,
|
# cap = tope duro.
|
||||||
"exit_after_min": 20,
|
# perfil "natural": 5 minutos distintos en la entrada, 4,1 min/dia de
|
||||||
"exit_after_max": 70,
|
# presencia extra. Ver la tabla de perfiles en el README.
|
||||||
# deriva lenta de la hora habitual, para no parecer un dado nuevo cada dia
|
"entry_floor": 12,
|
||||||
"drift_sigma": 25,
|
"entry_median": 58,
|
||||||
"drift_clamp": 60,
|
"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
|
# 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,
|
"geo_jitter_meters": 12.0,
|
||||||
"telegram": {},
|
"telegram": {},
|
||||||
|
|||||||
Reference in New Issue
Block a user