El 96% de las entradas caian en solo dos minutos (08:58 / 08:59). El historial
real del usuario tiene 4 valores repartidos, o sea que el bot resultaba MAS
regular que una persona.
Tres cosas estaban mal:
- La deriva de la costumbre se aplicaba SUMANDO segundos. Cuando salia negativa
hundia el valor contra el suelo y amontonaba medio año en el mismo minuto,
justo lo contrario de lo que pretendia. Ahora va en escala logaritmica:
multiplica el centro en vez de sumarse. Solo con eso la moda baja del 50% al
39%, sin coste ninguno.
- La distribucion diaria era uniforme. Una lognormal da mas minutos distintos
por menos presencia extra, porque la mayoria de los dias caen baratos cerca de
la hora y solo unos pocos se van lejos. Ademas es la forma real de la llegada
de alguien que intenta ser puntual.
- El tope recortaba con min(), asi que un 3% de los dias caia exactamente en el
limite y se veia como un muro en el histograma. Ahora resortea.
El compromiso de fondo no tiene truco: solo se puede variar hacia atras, y
alejarse cuesta presencia. Perfiles medidos sobre los 226 dias laborables del
año, los tres con saldo 0:
discreto 2,8 min/dia entrada 2 minutos, moda 75%
natural 4,2 min/dia entrada 5 minutos, moda 51% <- por defecto
camuflado 5,5 min/dia entrada 4 minutos, moda 43%
Ademas, /proximos mostraba una banda estimada que ocultaba la cola. Ahora se
planifican 21 dias por adelantado y se muestran las horas exactas, con lo que
la prevision es real y se puede decidir sobre ella. El plan guardado se
revalida contra el calendario de Timenet y se regenera si cambia el tipo de
jornada de un dia. Google Calendar se consulta con una sola autenticacion para
todo el horizonte.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
77 lines
2.9 KiB
Python
77 lines
2.9 KiB
Python
"""Carga de config.json sin depender de argparse.
|
|
|
|
El ``config_parser`` original invoca ``ArgParser().parse()`` en tiempo de import, lo
|
|
que obliga a que haya argumentos de linea de comandos validos. El daemon y las
|
|
herramientas necesitan leer la configuracion sin eso.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
|
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
DEFAULT_CONFIG = os.path.join(ROOT, "config.json")
|
|
|
|
DEFAULTS = {
|
|
"api_url": "https://timenet-wcp.gpisoftware.com/api/",
|
|
"timezone": "Europe/Madrid",
|
|
"jitter": {
|
|
# margen, en segundos, con el que se ficha respecto de la hora teorica.
|
|
# Entradas ANTES (Timenet no acredita nada fuera de la ventana, pero si
|
|
# debita las entradas tardias). Salidas DESPUES.
|
|
# Lognormal: la moda queda pegada a la hora teorica y la cola va hacia
|
|
# mas margen. Da mas minutos distintos por menos presencia extra que una
|
|
# uniforme (ver la nota de cabecera de app/jitter.py).
|
|
# floor = margen minimo, median = mediana, sigma = longitud de la cola,
|
|
# cap = tope duro.
|
|
# perfil "natural": 5 minutos distintos en la entrada, 4,1 min/dia de
|
|
# presencia extra. Ver la tabla de perfiles en el README.
|
|
"entry_floor": 12,
|
|
"entry_median": 58,
|
|
"entry_sigma": 0.95,
|
|
"entry_cap": 330,
|
|
"exit_floor": 10,
|
|
"exit_median": 42,
|
|
"exit_sigma": 0.95,
|
|
"exit_cap": 300,
|
|
# Deriva lenta de la hora habitual, en unidades LOGARITMICAS (multiplica
|
|
# el centro). Mueve la moda de una semana a otra, lo que ensancha lo que se
|
|
# ve a lo largo del mes con coste medio practicamente nulo.
|
|
"drift_sigma": 0.30,
|
|
"drift_clamp": 0.75,
|
|
# margen de seguridad: nunca fichar una entrada a menos de X s de la hora
|
|
"entry_guard": 12,
|
|
},
|
|
"geo_jitter_meters": 12.0,
|
|
"telegram": {},
|
|
}
|
|
|
|
|
|
def _merge(base: dict, over: dict) -> dict:
|
|
out = dict(base)
|
|
for k, v in (over or {}).items():
|
|
out[k] = _merge(base[k], v) if isinstance(v, dict) and isinstance(base.get(k), dict) else v
|
|
return out
|
|
|
|
|
|
def load(path: str | None = None) -> dict:
|
|
path = path or os.environ.get("AUTOFICHER_CONFIG") or DEFAULT_CONFIG
|
|
if not os.path.exists(path):
|
|
raise FileNotFoundError(f"No se encuentra el archivo de configuracion: {path}")
|
|
with open(path, encoding="utf-8") as f:
|
|
cfg = json.load(f)
|
|
if not isinstance(cfg, dict):
|
|
raise ValueError("config.json no contiene un objeto JSON")
|
|
cfg = _merge(DEFAULTS, cfg)
|
|
for req in ("api_url", "user"):
|
|
if not cfg.get(req):
|
|
raise ValueError(f"Falta '{req}' en {path}")
|
|
return cfg
|
|
|
|
|
|
def pin(cfg: dict | None = None) -> str:
|
|
"""PIN desde el entorno, la config o por teclado. Nunca se escribe a disco."""
|
|
import getpass
|
|
p = os.environ.get("TIMENET_PIN") or (cfg or {}).get("pin")
|
|
return str(p) if p else getpass.getpass("PIN de Timenet: ")
|