178 lines
5.8 KiB
Python
178 lines
5.8 KiB
Python
"""Deterministic thickness band from free-text thickness_in (METADATA-15 / QUALITY-95).
|
||
|
||
The owner enters thickness in 1/8-inch steps on capture. The Woo attribute is a
|
||
synced band term. We map thickness_in into the first half-open band [low, high)
|
||
that contains it, derived only from the live synced term names/slugs — never a
|
||
hardcoded Woo term id.
|
||
|
||
Round-up semantics: bands are half-open on the upper bound, so a value that sits
|
||
on a shared boundary (e.g. 1.0 in) belongs to the band that *starts* at that
|
||
value (1"–1 1/2"), not the band that ends there (3/4"–1").
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from typing import Any, Iterable, Optional
|
||
|
||
# Mixed number / fraction / decimal inch token, optional trailing quote.
|
||
_INCH_RE = re.compile(
|
||
r"(?P<a>\d+)\s+(?P<n>\d+)\s*/\s*(?P<d>\d+)" # 1 1/2
|
||
r"|(?P<n2>\d+)\s*/\s*(?P<d2>\d+)" # 1/2
|
||
r"|(?P<dec>\d+(?:\.\d+)?)", # 1 or 1.5
|
||
re.IGNORECASE,
|
||
)
|
||
_QTR_RANGE_RE = re.compile(r"^(\d+)-(\d+)qtr$", re.IGNORECASE)
|
||
_QTR_MIN_RE = re.compile(r"^(\d+)qtr$", re.IGNORECASE)
|
||
|
||
|
||
def parse_inch_token(text: str) -> Optional[float]:
|
||
"""Parse one inch token like '1 1/2', '3/4', '2', or 2-with-quote → float inches."""
|
||
t = (text or "").strip().rstrip("\"'").strip()
|
||
if not t:
|
||
return None
|
||
m = _INCH_RE.fullmatch(t) or _INCH_RE.search(t)
|
||
if not m:
|
||
return None
|
||
if m.group("a") is not None:
|
||
return float(m.group("a")) + float(m.group("n")) / float(m.group("d"))
|
||
if m.group("n2") is not None:
|
||
return float(m.group("n2")) / float(m.group("d2"))
|
||
if m.group("dec") is not None:
|
||
return float(m.group("dec"))
|
||
return None
|
||
|
||
|
||
def _bounds_from_name(name: str) -> Optional[tuple[float, float]]:
|
||
"""Return half-open [low, high) inches from a Woo thickness term name."""
|
||
raw = (name or "").strip()
|
||
low_s = raw.lower()
|
||
if not raw:
|
||
return None
|
||
|
||
# Under X / less than X / < X
|
||
m = re.match(r"^(?:under|below|less than|<)\s*(.+)$", low_s, re.IGNORECASE)
|
||
if m:
|
||
hi = parse_inch_token(m.group(1))
|
||
if hi is not None:
|
||
return (0.0, hi)
|
||
|
||
# Over X / above X / > X / X+
|
||
m = re.match(r"^(?:over|above|greater than|>)\s*(.+)$", low_s, re.IGNORECASE)
|
||
if m:
|
||
lo = parse_inch_token(m.group(1))
|
||
if lo is not None:
|
||
return (lo, float("inf"))
|
||
m = re.match(r"^(.+?)\+\s*$", raw)
|
||
if m:
|
||
lo = parse_inch_token(m.group(1))
|
||
if lo is not None:
|
||
return (lo, float("inf"))
|
||
|
||
# Range: "A - B" / "A–B" / "A- B" (quotes optional on either side)
|
||
parts = re.split(r"\s*[-–—]\s*", raw, maxsplit=1)
|
||
if len(parts) == 2:
|
||
lo = parse_inch_token(parts[0])
|
||
hi = parse_inch_token(parts[1])
|
||
if lo is not None and hi is not None and hi > lo:
|
||
return (lo, hi)
|
||
|
||
# Single value band treated as [v, v+epsilon) — rare
|
||
only = parse_inch_token(raw)
|
||
if only is not None:
|
||
return (only, only + 1e-9)
|
||
return None
|
||
|
||
|
||
def _bounds_from_slug(slug: str) -> Optional[tuple[float, float]]:
|
||
"""Fallback: quarter-inch slugs like '4-6qtr', '12qtr', 'xthin'."""
|
||
s = (slug or "").strip().lower()
|
||
if not s:
|
||
return None
|
||
if s in {"xthin", "under", "thin"}:
|
||
return (0.0, 0.5)
|
||
m = _QTR_RANGE_RE.match(s)
|
||
if m:
|
||
lo = int(m.group(1)) * 0.25
|
||
hi = int(m.group(2)) * 0.25
|
||
if hi > lo:
|
||
return (lo, hi)
|
||
m = _QTR_MIN_RE.match(s)
|
||
if m:
|
||
lo = int(m.group(1)) * 0.25
|
||
return (lo, float("inf"))
|
||
return None
|
||
|
||
|
||
def term_bounds(term: dict[str, Any]) -> Optional[tuple[float, float]]:
|
||
"""Bounds for one synced thickness term; name first, slug fallback."""
|
||
return _bounds_from_name(str(term.get("name") or "")) or _bounds_from_slug(
|
||
str(term.get("slug") or "")
|
||
)
|
||
|
||
|
||
def resolve_thickness_band_id(
|
||
thickness_in: float | int | None,
|
||
terms: Iterable[dict[str, Any]],
|
||
) -> Optional[int]:
|
||
"""Pick the synced thickness term id for thickness_in, or None if unknown.
|
||
|
||
terms: iterable of {id, name, slug?} from the live Woo thickness attribute.
|
||
"""
|
||
if thickness_in is None:
|
||
return None
|
||
try:
|
||
t = float(thickness_in)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
if t < 0:
|
||
return None
|
||
|
||
scored: list[tuple[float, float, int]] = [] # (low, high, id)
|
||
for term in terms:
|
||
b = term_bounds(term)
|
||
if b is None:
|
||
continue
|
||
tid = term.get("id")
|
||
if tid is None:
|
||
continue
|
||
lo, hi = b
|
||
scored.append((lo, hi, int(tid)))
|
||
|
||
if not scored:
|
||
return None
|
||
|
||
# Prefer the tightest band that contains t under half-open [low, high).
|
||
matches = [(hi - lo, lo, hi, tid) for lo, hi, tid in scored if lo <= t < hi]
|
||
if matches:
|
||
matches.sort() # tightest span first
|
||
return matches[0][3]
|
||
|
||
# If nothing matched (gaps in taxonomy), pick nearest band by lower edge.
|
||
scored.sort(key=lambda x: x[0])
|
||
for lo, hi, tid in scored:
|
||
if t < hi:
|
||
return tid
|
||
return scored[-1][2]
|
||
|
||
|
||
def load_thickness_terms(conn) -> list[dict[str, Any]]:
|
||
"""Live Thickness attribute terms from the synced Woo cache."""
|
||
row = conn.execute(
|
||
"SELECT id FROM woo_attributes WHERE lower(name)=? OR lower(slug)=?",
|
||
("thickness", "thickness"),
|
||
).fetchone()
|
||
if row is None:
|
||
# Case-insensitive name variants ("Thickness", "pa_thk" style slugs stay open)
|
||
row = conn.execute(
|
||
"SELECT id FROM woo_attributes WHERE lower(name) LIKE '%thickness%'"
|
||
).fetchone()
|
||
if row is None:
|
||
return []
|
||
attr_id = row[0]
|
||
rows = conn.execute(
|
||
"SELECT id, name, slug FROM woo_attribute_terms WHERE attribute_id=? ORDER BY id",
|
||
(attr_id,),
|
||
).fetchall()
|
||
return [{"id": r[0], "name": r[1], "slug": r[2]} for r in rows]
|