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>
88 lines
4.0 KiB
Python
88 lines
4.0 KiB
Python
"""Aleatoriedad que imita comportamiento humano, en tiempo y en posicion.
|
|
|
|
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.
|
|
|
|
* 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
|
|
|
|
import math
|
|
import random
|
|
|
|
# reversion a la media de la costumbre diaria: 0 = sin memoria, 1 = no se mueve
|
|
DRIFT_MEMORY = 0.82
|
|
# 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:
|
|
"""Siguiente valor de la costumbre, en segundos. Deriva lenta y acotada."""
|
|
nxt = previous * DRIFT_MEMORY + rng.gauss(0.0, sigma)
|
|
return max(-clamp, min(clamp, nxt))
|
|
|
|
|
|
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. 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.
|
|
"""
|
|
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))))
|
|
|
|
|
|
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,
|
|
rng: random.Random | None = None) -> tuple[float, float]:
|
|
"""Desplaza la posicion dentro de un disco de radio `meters`.
|
|
|
|
Las coordenadas del historico son identicas al sexto decimal en los 376 fichajes
|
|
de 6 meses. Un GPS real nunca hace eso.
|
|
"""
|
|
rng = rng or random
|
|
if not meters:
|
|
return round(float(latitude), 6), round(float(longitude), 6)
|
|
|
|
bearing = rng.uniform(0, 2 * math.pi)
|
|
# raiz cuadrada -> reparto uniforme sobre el area del disco, no concentrado al centro
|
|
dist = meters * math.sqrt(rng.random())
|
|
|
|
dlat = (dist * math.cos(bearing)) / 111_320.0
|
|
coslat = math.cos(math.radians(float(latitude))) or 1e-9
|
|
dlon = (dist * math.sin(bearing)) / (111_320.0 * coslat)
|
|
|
|
return round(float(latitude) + dlat, 6), round(float(longitude) + dlon, 6)
|