"""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. * 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. """ 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 # probabilidad de un dia atipico (te despistas y llegas bastante antes / te quedas mas) OUTLIER_P = 0.06 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(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. 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. """ mid = (lo + hi) / 2.0 spread = max(1.0, (hi - lo) / 4.0) val = mid + drift + rng.gauss(0.0, spread) 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 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)