"""Mapeo de códigos de analizador → claves canónicas de la app + conversión de unidades. Fuente única de verdad del vocabulario, compartida por todo formato de entrada (ASTM, HL7, JSON). Es un port a Python de la lógica ya validada en `frontend/src/pdf-parser.ts` (`CONVERSIONES_UNIDADES`, `aplicarConversion`, `extraerValorYUnidad`, `parsearSemiCuantitativo`, y la derivación de porcentajes del diferencial). Los factores de conversión se mantienen IDÉNTICOS a los del PDF para que ambas importaciones coincidan. Las tablas código→analito viven en data/lab_mapeos/*.json (genérico + overrides por fabricante), de modo que añadir un equipo es editar JSON, no código. """ from __future__ import annotations import json import math import re from collections.abc import Callable from functools import lru_cache from ..config import RAIZ_REPO from ..schemas_lab import ResultadoAnalizador, ResultadoMapeado, ValorAnalito DIR_MAPEOS = RAIZ_REPO / "data" / "lab_mapeos" # Claves que en el formulario son (neg/+/++/+++).""" t = texto.lower() if re.search(r"negati|nég|neg\b|ausente|absent|no\s+detect", t): return "neg" if re.search(r"\+{3}", t): return "+++" if re.search(r"\+{2}", t): return "++" if "+" in t: return "+" if re.search(r"traz|trace", t): return "+" return None # --- Carga de tablas de mapeo --- def _normalizar_fabricante(fabricante: str | None) -> str | None: if not fabricante: return None f = fabricante.lower() if "abaxis" in f or "vetscan" in f: return "abaxis" if "horiba" in f or "scil" in f: return "horiba" if "bionote" in f or "vcheck" in f: return "bionote" return None def _cargar_json(nombre: str) -> dict: ruta = DIR_MAPEOS / f"{nombre}.json" if not ruta.exists(): return {} with ruta.open(encoding="utf-8") as f: return json.load(f) @lru_cache(maxsize=8) def _indice(fabricante: str | None) -> dict[str, dict]: """Índice código(mayúsculas) → {clave, clave_conv, unidad_defecto}. Parte de generico.json; si hay tabla del fabricante, la superpone (gana el fabricante). """ tablas = [_cargar_json("generico")] fab = _normalizar_fabricante(fabricante) if fab: tablas.append(_cargar_json(fab)) indice: dict[str, dict] = {} for tabla in tablas: for clave_json, definicion in tabla.items(): if not isinstance(definicion, dict): continue # entradas de metadatos como "_comentario" # `clave` opcional permite varias definiciones para la misma clave canónica con # distinto claveConv (p. ej. BUN vs UREA, ambas → 'bun' pero con conversión distinta). entrada = { "clave": definicion.get("clave", clave_json), "clave_conv": definicion.get("claveConv"), "unidad_defecto": definicion.get("unidad_defecto", ""), } for codigo in definicion.get("codigos", []): indice[codigo.strip().upper()] = entrada return indice # --- Mapeo --- def mapear_observacion(obs, indice: dict[str, dict]) -> ValorAnalito | None: """Mapea una observación cruda a un ValorAnalito canónico, o None si no se reconoce.""" entrada = indice.get(obs.codigo_prueba.strip().upper()) if not entrada: return None clave = entrada["clave"] if clave in CLAVES_SEMICUANTITATIVAS: semis = parsear_semicuantitativo(obs.valor) if semis is None: return None return ValorAnalito( clave=clave, valor=semis, valor_original=obs.valor, unidad_original=obs.unidad, es_semicuantitativo=True, ) num = parsear_valor_numerico(obs.valor) if num is None: return None unidad = obs.unidad or entrada.get("unidad_defecto", "") convertido = convertir_unidad(clave, entrada.get("clave_conv"), num, unidad) return ValorAnalito( clave=clave, valor=convertido, valor_original=obs.valor, unidad_original=obs.unidad, ) def _derivar_porcentajes(analitos: dict[str, ValorAnalito]) -> None: """Deriva % del diferencial de leucocitos desde absolutos y % de reticulocitos. Port de la derivación de parsearTextoLab: sólo rellena si el % no vino directamente. """ def _num(clave: str) -> float | None: va = analitos.get(clave) return va.valor if va and isinstance(va.valor, (int, float)) else None wbc = _num("wbc") if wbc and wbc > 0: for f in ("neutro", "linfo", "mono", "eosino", "baso"): abs_val = _num(f"{f}_abs") if f not in analitos and abs_val is not None: pct = round((abs_val / wbc) * 100) if 0 <= pct <= 100: analitos[f] = ValorAnalito(clave=f, valor=float(pct), valor_original="(derivado)") rbc = _num("rbc") reti_abs = _num("reti_abs") if rbc and rbc > 0 and "reti" not in analitos and reti_abs is not None: pct = reti_abs / (rbc * 10) if 0 <= pct <= 20: analitos["reti"] = ValorAnalito(clave="reti", valor=round(pct, 2), valor_original="(derivado)") def mapear_resultado(res: ResultadoAnalizador) -> ResultadoMapeado: """Convierte un ResultadoAnalizador crudo en el ResultadoMapeado que consume el frontend.""" indice = _indice(res.fabricante) analitos: dict[str, ValorAnalito] = {} no_mapeados: list[str] = [] for obs in res.observaciones: va = mapear_observacion(obs, indice) if va is None: no_mapeados.append(obs.codigo_prueba) continue if va.clave not in analitos: # primer match gana, como en el PDF analitos[va.clave] = va _derivar_porcentajes(analitos) return ResultadoMapeado( muestra_id=res.muestra_id, instrumento_id=res.instrumento_id, momento=res.momento, analitos=analitos, paciente=res.pistas_paciente, no_mapeados=no_mapeados, )