259 lines
6.6 KiB
Python
259 lines
6.6 KiB
Python
"""Parsing numérico tolerante (miles, decimales, moneda)."""
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Any, Literal, Optional
|
|
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
from .nullish import _is_nullish_scalar
|
|
|
|
_HINT_MAJORITY = 0.6
|
|
_MIN_HINT_VOTES = 1
|
|
|
|
|
|
def _strip_currency(s: str) -> str:
|
|
t = s.replace("\u00a0", " ").replace("\u202f", " ")
|
|
t = re.sub(r"[\s]+", "", t)
|
|
t = t.strip()
|
|
if not t or t.lower() in ("nan", "none", "nat"):
|
|
return ""
|
|
neg = False
|
|
if t[0:1] == "-":
|
|
neg, t = not neg, t[1:].lstrip()
|
|
if t[0:1] == "(" and t.endswith(")"):
|
|
neg, t = not neg, t[1:-1]
|
|
t = re.sub(
|
|
r"^[\$€\u00a3\u0e3f\uffe5\uffe6\uff04\ufe69]+",
|
|
"",
|
|
t,
|
|
count=1,
|
|
)
|
|
t = re.sub(r"[\$€%]+$", "", t)
|
|
if not t and neg:
|
|
return "-0"
|
|
return ("-" if neg else "") + t
|
|
|
|
|
|
def _extract_numeric_chunk(s: str) -> str:
|
|
t = s.strip()
|
|
t = t.replace("$", "").replace("€", "").replace("\u00a3", "")
|
|
t = t.replace(" ", "")
|
|
cands1 = re.findall(
|
|
r"[+\-]?(?:\d{1,3}(?:[.,]\d{3})*|\d+)(?:[.,]\d+)?", t
|
|
)
|
|
cands2 = re.findall(
|
|
r"[+\-]?\d[.,\d ]*\d", t
|
|
)
|
|
cands = cands1 + cands2
|
|
if not cands:
|
|
m = re.search(r"[\d.,\-]+", t)
|
|
if m:
|
|
c = m.group(0).replace("(", "-").replace(")", "")
|
|
return c.lstrip("+")
|
|
return ""
|
|
best = max(cands, key=len) if cands else ""
|
|
best = best.replace(" ", "").replace("(", "-").replace(")", "")
|
|
if not re.search(r"\d", best):
|
|
return ""
|
|
return best.lstrip("+")
|
|
|
|
|
|
def _as_float_s(s: str) -> Optional[float]:
|
|
if not s:
|
|
return None
|
|
try:
|
|
f = float(s)
|
|
if not np.isfinite(f) or abs(f) > 1e100:
|
|
return None
|
|
return f
|
|
except (ValueError, TypeError, OverflowError):
|
|
return None
|
|
|
|
|
|
def _parse_both_separators(body: str) -> Optional[float]:
|
|
"""El separador más a la DERECHA entre `,` y `.` es el decimal; el otro, miles."""
|
|
if "," not in body or "." not in body:
|
|
return None
|
|
t = body.strip()
|
|
if re.search(r"[^0-9,.\-]", t):
|
|
t2 = re.sub(r"[^0-9,.\-]", "", t)
|
|
t = t2
|
|
if not t:
|
|
return None
|
|
neg = t[0:1] == "-"
|
|
b = t[1:].lstrip() if neg else t
|
|
if not b:
|
|
return None
|
|
i_comma, i_dot = b.rfind(","), b.rfind(".")
|
|
if min(i_comma, i_dot) < 0:
|
|
return None
|
|
dpos = max(i_comma, i_dot)
|
|
th = "," if b[dpos] == "." else "."
|
|
intp, fr = b[:dpos], b[dpos + 1 :]
|
|
if not re.fullmatch(r"\d{1,4}$", fr):
|
|
return None
|
|
intc = intp.replace(th, "")
|
|
if not intc.isdigit():
|
|
return None
|
|
v = _as_float_s(f"{intc}.{fr}")
|
|
if v is None:
|
|
return None
|
|
if neg and v and v is not None:
|
|
return -abs(v)
|
|
return v
|
|
|
|
|
|
def _only_commas(w: str, dec_hint: Optional[Literal[",", "."]]) -> Optional[float]:
|
|
neg = w[0:1] == "-"
|
|
wn = w[1:].lstrip() if neg else w
|
|
if wn and re.search(r"[^0-9,]", wn):
|
|
wn = re.sub(r"[^0-9,]", "", wn)
|
|
n = wn.count(",")
|
|
if n == 0 and wn.isdigit():
|
|
v = float(wn)
|
|
return -v if neg else v
|
|
if n == 0 or "." in wn:
|
|
return None
|
|
if n == 1:
|
|
i = wn.rfind(",")
|
|
h, t = wn[:i], wn[i + 1 :]
|
|
if not t.isdigit() or not h.isdigit():
|
|
return None
|
|
if len(t) in (1, 2):
|
|
v = _as_float_s(f"{h}.{t}")
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
if len(t) == 3:
|
|
if dec_hint is None or dec_hint == ",":
|
|
v = _as_float_s(f"{h}{t}")
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
if dec_hint == ".":
|
|
v = _as_float_s(wn.replace(",", ""))
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
v = _as_float_s(wn.replace(",", ""))
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
|
|
|
|
def _only_dots(w: str, dec_hint: Optional[Literal[",", "."]]) -> Optional[float]:
|
|
neg = w[0:1] == "-"
|
|
wn = w[1:].lstrip() if neg else w
|
|
if wn and re.search(r"[^0-9.]", wn):
|
|
wn = re.sub(r"[^0-9.]", "", wn)
|
|
n = wn.count(".")
|
|
if n == 0 and wn.isdigit():
|
|
v = float(wn)
|
|
return -v if neg else v
|
|
if n == 0 or "," in wn:
|
|
return None
|
|
if n == 1:
|
|
i = wn.rfind(".")
|
|
h, t = wn[:i], wn[i + 1 :]
|
|
if not t.isdigit() or not h.isdigit():
|
|
return None
|
|
if len(t) in (1, 2):
|
|
v = _as_float_s(f"{h}.{t}")
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
if len(t) == 3:
|
|
if dec_hint is None or dec_hint == ".":
|
|
v = _as_float_s(f"{h}{t}")
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
if dec_hint == ",":
|
|
v = _as_float_s(wn.replace(".", ""))
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
v = _as_float_s(wn.replace(".", ""))
|
|
if v is None:
|
|
return None
|
|
return -v if neg else v
|
|
|
|
|
|
def _parse_lenient_one(
|
|
raw: Any,
|
|
decimal_hint: Optional[Literal[",", "."]] = None,
|
|
) -> float:
|
|
if _is_nullish_scalar(raw):
|
|
return float("nan")
|
|
t = _strip_currency(str(raw))
|
|
if not t or t == "-":
|
|
return float("nan")
|
|
c = _extract_numeric_chunk(t) or t
|
|
c = c.strip()
|
|
if not c or c == "-":
|
|
return float("nan")
|
|
n_comma, n_dot = c.count(","), c.count(".")
|
|
if n_comma and n_dot:
|
|
f = _parse_both_separators(c)
|
|
elif n_comma and not n_dot:
|
|
f = _only_commas(c, decimal_hint)
|
|
elif n_dot and not n_comma:
|
|
f = _only_dots(c, decimal_hint)
|
|
else:
|
|
c2 = re.sub(r"[^0-9.\-]", "", c)
|
|
f = _as_float_s(c2) if c2 and re.fullmatch(r"-?\d+$", c2) else None
|
|
if f is not None and np.isfinite(f):
|
|
return f
|
|
return float("nan")
|
|
|
|
|
|
def _infer_decimal_hint_from_column(
|
|
series: pd.Series,
|
|
) -> Optional[Literal[",", "."]]:
|
|
"""Voto con celdas que traen `,` y `.`: gana el de más a la derecha = decimal."""
|
|
votes: list[Literal[",", "."]] = []
|
|
for raw in series.dropna():
|
|
if _is_nullish_scalar(raw):
|
|
continue
|
|
t = _strip_currency(str(raw))
|
|
c = _extract_numeric_chunk(t) or t
|
|
c = c.strip()
|
|
if "," not in c or "." not in c or _parse_both_separators(c) is None:
|
|
continue
|
|
if c.rfind(",") > c.rfind("."):
|
|
votes.append(",")
|
|
else:
|
|
votes.append(".")
|
|
n = len(votes)
|
|
if n < _MIN_HINT_VOTES:
|
|
return None
|
|
a = sum(1 for v in votes if v == ",")
|
|
b = sum(1 for v in votes if v == ".")
|
|
if a + b == 0:
|
|
return None
|
|
if a / n >= _HINT_MAJORITY and a > b:
|
|
return ","
|
|
if b / n >= _HINT_MAJORITY and b > a:
|
|
return "."
|
|
return None
|
|
|
|
|
|
def _to_numeric_lenient(
|
|
series: pd.Series,
|
|
decimal_hint: Optional[Literal[",", "."]] = None,
|
|
) -> pd.Series:
|
|
"""Convierte a flotante; pista de columna por voto (si `decimal_hint` es None)."""
|
|
hint = decimal_hint if decimal_hint is not None else _infer_decimal_hint_from_column(
|
|
series
|
|
)
|
|
|
|
def _cell(x: Any) -> float:
|
|
if _is_nullish_scalar(x):
|
|
return float("nan")
|
|
return _parse_lenient_one(x, hint)
|
|
|
|
return series.map(_cell)
|
|
|