Files
autoficher/app/jitter.py
omar 9294474066 feat: nucleo nuevo: cliente API, calendario laboral, planificador y jitter
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>
2026-09-24 18:51:33 +02:00

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)