Timenet recorta el tiempo a la ventana teorica del horario:
trabajado = min(salida, fin_teorico) - max(entrada, inicio_teorico)
Verificado sobre 6 meses (104 dias laborables, 376 fichajes). La API guarda dos
horas por fichaje: HR (la que fichas) y H (la que cuenta). De 376 fichajes, 136
llevaban ajuste: todos salidas, todos recortados hacia atras. Ninguna entrada
ajustada jamas. Resultado: una entrada tardia se debita al minuto y una salida
tardia no acredita nada, asi que el jitter aleatorio positivo que habia perdia
unos 3,2 min/dia (-776 min acumulados, casi 13 horas).
Y acabar el mes en positivo es imposible: 0 dias con saldo positivo en 108, y
timeExtraHours=false en todos los tipos de jornada. El objetivo es 0 clavado.
- timenet_api: cliente limpio. Documenta que Tn-D y dStr son obligatorias (sin
ellas el servidor responde 403 hasta en endpoints publicos).
- schedule: calendario laboral desde cp/calendar. Festivos, vacaciones y tramos
horarios los da Timenet, no hace falta adivinarlos.
- planner + jitter: entradas siempre ANTES de la hora, salidas siempre DESPUES,
con costumbre que deriva lenta y cola larga hacia el lado seguro. Invariantes
que fallan en duro si una entrada se planifica tarde.
- jitter_geo: las coordenadas del historico son identicas al sexto decimal en
los 376 fichajes; ahora se desplazan dentro de un disco.
- store: SQLite con plan diario, overrides, auditoria y estado del drift.
- settings: config sin el acoplamiento a argparse del config_parser original.
tools/dryrun.py simula el año entero: saldo +0 en 226 dias laborables, con
~3,5 min/dia de presencia extra.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
69 lines
2.7 KiB
Python
69 lines
2.7 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.
|
|
* 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)
|