#!/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()