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>
98 lines
3.7 KiB
Python
98 lines
3.7 KiB
Python
#!/usr/bin/env python3
|
|
"""Simula el plan de todo el año y comprueba que Timenet contaria 0 de saldo. No ficha nada."""
|
|
import json, os, random, sys
|
|
from collections import Counter
|
|
from datetime import timedelta
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
from app import settings, schedule, jitter
|
|
from app.planner import Planner, ENTRADA
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
CACHE = sys.argv[1] if len(sys.argv) > 1 else os.path.join(ROOT, "data", "calendar_cache.json")
|
|
|
|
|
|
def load_calendar(cfg):
|
|
if CACHE and os.path.exists(CACHE):
|
|
print("calendario desde cache: %s" % CACHE)
|
|
return schedule.Calendar(json.load(open(CACHE)))
|
|
from app.timenet_api import TimenetClient
|
|
cli = TimenetClient(cfg["api_url"], cfg["user"])
|
|
cli.login(settings.pin(cfg))
|
|
return schedule.Calendar(cli.raw_calendar())
|
|
|
|
|
|
def main():
|
|
cfg = settings.load()
|
|
cal = load_calendar(cfg)
|
|
rng = random.Random(int(os.environ.get("SEED", "1234")))
|
|
pl = Planner(cfg, store=None)
|
|
|
|
lo, hi = cal.span
|
|
dias = 0
|
|
tot_P = tot_W = tot_pres = 0
|
|
offs = {"ENTRADA": [], "SALIDA": []}
|
|
muestra = []
|
|
minutos = {"ENTRADA": Counter(), "SALIDA": Counter()}
|
|
|
|
d = lo
|
|
while d <= hi:
|
|
if cal.is_workday(d):
|
|
plan = pl.plan_day(cal, d, rng)
|
|
dias += 1
|
|
for wi, w in enumerate(cal.windows(d)):
|
|
ent = [p for p in plan if p.window_idx == wi and p.typ == ENTRADA][0]
|
|
sal = [p for p in plan if p.window_idx == wi and p.typ != ENTRADA][0]
|
|
# Timenet trabaja a granularidad de MINUTO: trunca los segundos
|
|
em = ent.planned_at.hour * 60 + ent.planned_at.minute
|
|
sm = sal.planned_at.hour * 60 + sal.planned_at.minute
|
|
tot_W += min(sm, w.end) - max(em, w.start)
|
|
tot_P += w.minutes
|
|
tot_pres += sm - em
|
|
for p in plan:
|
|
offs[p.label].append(p.offset_seconds)
|
|
minutos[p.label][p.planned_at.strftime("%H:%M")] += 1
|
|
if len(muestra) < 5:
|
|
muestra.append((d, plan))
|
|
d += timedelta(days=1)
|
|
|
|
saldo = tot_W - tot_P
|
|
print("\ndias laborables simulados: %d (%s -> %s)" % (dias, lo, hi))
|
|
print(" previsto P = %6d min" % tot_P)
|
|
print(" contado W = %6d min" % tot_W)
|
|
print(" SALDO = %+d min <-- objetivo: 0" % saldo)
|
|
print(" presencia = %6d min (%+d vs previsto = %.2f min/dia de mas)"
|
|
% (tot_pres, tot_pres - tot_P, (tot_pres - tot_P) / dias))
|
|
|
|
print("\n=== margen respecto de la hora teorica (segundos, lado seguro) ===")
|
|
for k, v in offs.items():
|
|
v = sorted(v)
|
|
print(" %-7s n=%-4d min %3d p25 %3d mediana %3d p75 %3d max %3d media %.0f"
|
|
% (k, len(v), v[0], v[len(v) // 4], v[len(v) // 2], v[3 * len(v) // 4], v[-1], sum(v) / len(v)))
|
|
|
|
print("\n=== minuto en que lo registra Timenet ===")
|
|
for k in ("ENTRADA", "SALIDA"):
|
|
top = sorted(minutos[k].items())[:10]
|
|
print(" %-7s %s" % (k, " ".join("%s x%d" % t for t in top)))
|
|
|
|
print("\n=== muestra de dias ===")
|
|
for d, plan in muestra:
|
|
print(" %s %s (%s)" % (d, d.strftime("%a"), cal.day_type(d).name.strip()))
|
|
for p in plan:
|
|
print(" %s" % p)
|
|
|
|
print("\n=== jitter de posicion (8 muestras, radio %s m) ===" % cfg["geo_jitter_meters"])
|
|
g = cfg["geo"]
|
|
for _ in range(8):
|
|
la, ln = jitter.jitter_geo(g["latitude"], g["longitude"], cfg["geo_jitter_meters"], rng)
|
|
print(" %.6f,%.6f" % (la, ln))
|
|
|
|
if saldo < 0:
|
|
print("\n!! SALDO NEGATIVO: el plan pierde minutos")
|
|
sys.exit(1)
|
|
print("\nOK: ningun minuto perdido.")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|