"""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)