STAR
Cal STAR — firmware, DAQ server, and engine design
Loading...
Searching...
No Matches
catalog.py
Go to the documentation of this file.
1"""The catalogues Hardware mode draws from: discrete parts, every row with where it came from.
2
3Hardware mode never sizes a part continuously. It offers what can be bought or drilled, and only
4what a row here says exists. Four kinds:
5
6* **drills** -- number drills #40-#70 from ``engine.layerx.reconcile.NUMBER_DRILLS_IN`` (ASME
7 B94.11M, *Twist Drills*), the table the injector reconcile already drills from, plus metric drills
8 in 0.05 mm steps (``reconcile.METRIC_STEP_MM``, assumed: jobber sets in that range). Built here
9 from that table, not re-typed, so the two cannot disagree.
10* **valves** (``data/catalog/valves.json``) -- Cv options for solenoids, valves and regulators.
11* **tubes** (``data/catalog/tubes.json``) -- bore and wall pairs for lines.
12* **bottles** (``data/catalog/bottles.json``) -- pressurant bottles.
13
14The JSON catalogues are seeded only with the parts the team named (the Aqua 1092-50 at Cv 0.8, the
151.7 Cv press solenoid) and what the shipped drawings carry, each row naming the drawing elements
16and their own provenance. Nothing is taken from a vendor data sheet this repo does not hold
17(docs/layerx/AUDIT.md 9.10 4b: "Hardware mode must wait for cited catalogue data, or it will
18invent Cv values, bottle masses and tube ratings"). A person adds rows in
19``<userdata>/layerx/catalog/<kind>.json`` (same shape); a user row replaces a shipped row with the
20same id.
21
22A row::
23
24 {"id": "press-solenoid-cv1.7", "label": "...", "applies_to": ["SOL"],
25 "params": {"Cv": {"value": 1.7, "unit": "Cv"}, "bore": {"value": 6.35, "unit": "mm", "source": "manufacturer"}},
26 "source": "measured" | "manufacturer" | "estimated",
27 "provenance": "who says so, and where (data sheet, drawing element)"}
28
29A parameter's own ``source`` (optional) wins over the row's, for a row whose numbers do not share
30one provenance (the drawn bottle: volume measured, mass and MAWP estimated).
31
32``applies_to`` names drawing component types (``SOL``, ``PR``, ``KBOTTLE``) or ``line`` for an edge.
33"""
34
35from __future__ import annotations
36
37import json
38import math
39from pathlib import Path
40from typing import Any, Dict, List, Mapping, Optional
41
42SCHEMA = "layerx.catalog/1"
43KINDS = ("drills", "valves", "tubes", "bottles")
44FILE_KINDS = ("valves", "tubes", "bottles")
45SOURCES = ("measured", "manufacturer", "estimated")
46SHIPPED_DIR = Path(__file__).resolve().parent / "data" / "catalog"
47
48DRILL_STANDARD = "ASME B94.11M, Twist Drills (number sizes #40-#70)"
49
50
51def drill_rows(lo_mm: float = 0.5, hi_mm: float = 13.0) -> List[Dict[str, Any]]:
52 """Number drills from the reconcile's table and metric drills every 0.05 mm in [lo, hi]."""
53 from engine.layerx.reconcile import METRIC_STEP_MM, NUMBER_DRILLS_IN
54
55 rows = [{"id": f"drill-no{no}", "label": f"#{no} drill", "drill": f"#{no}", "applies_to": ["hole", "orifice"],
56 "params": {"d": {"value": inch * 25.4, "unit": "mm"}}, "source": "manufacturer",
57 "provenance": f"{DRILL_STANDARD}: #{no} = {inch:.4f} in (engine/layerx/reconcile.py NUMBER_DRILLS_IN)"}
58 for no, inch in NUMBER_DRILLS_IN.items()]
59 k0, k1 = int(math.ceil(lo_mm / METRIC_STEP_MM - 1e-9)), int(math.floor(hi_mm / METRIC_STEP_MM + 1e-9))
60 rows += [{"id": f"drill-{k * METRIC_STEP_MM:.2f}mm", "label": f"{k * METRIC_STEP_MM:.2f} mm drill",
61 "drill": f"{k * METRIC_STEP_MM:.2f} mm", "applies_to": ["hole", "orifice"],
62 "params": {"d": {"value": round(k * METRIC_STEP_MM, 4), "unit": "mm"}}, "source": "estimated",
63 "provenance": (f"assumed: metric drills in {METRIC_STEP_MM} mm steps (reconcile.METRIC_STEP_MM, stated "
64 f"there for the small-hole range and carried here to {hi_mm:g} mm for trim plates, "
65 "which can be bored to size): check the size is stocked")}
66 for k in range(k0, k1 + 1)]
67 return rows
68
69
70def _check_row(kind: str, row: Mapping[str, Any], where: str) -> Dict[str, Any]:
71 if not isinstance(row, Mapping):
72 raise ValueError(f"{where}: a {kind} row must be an object")
73 rid = str(row.get("id") or "").strip()
74 if not rid:
75 raise ValueError(f"{where}: a {kind} row needs an id")
76 if not str(row.get("provenance") or "").strip():
77 raise ValueError(f"{where}: {kind} row {rid!r} has no provenance; a part nobody can source is not offered")
78 source = row.get("source")
79 if source not in SOURCES:
80 raise ValueError(f"{where}: {kind} row {rid!r} source {source!r}; expected one of {', '.join(SOURCES)}")
81 params = row.get("params")
82 if not isinstance(params, Mapping) or not params:
83 raise ValueError(f"{where}: {kind} row {rid!r} states no params")
84 for name, p in params.items():
85 if not isinstance(p, Mapping) or not isinstance(p.get("value"), (int, float)) or isinstance(p.get("value"), bool) \
86 or not math.isfinite(float(p["value"])) or float(p["value"]) <= 0 or not str(p.get("unit") or "").strip():
87 raise ValueError(f"{where}: {kind} row {rid!r} param {name!r} needs a positive value and a unit")
88 if p.get("source") is not None and p.get("source") not in SOURCES:
89 raise ValueError(f"{where}: {kind} row {rid!r} param {name!r} source {p.get('source')!r}; expected one of "
90 f"{', '.join(SOURCES)}")
91 applies = row.get("applies_to")
92 if not isinstance(applies, list) or not applies:
93 raise ValueError(f"{where}: {kind} row {rid!r} says nothing it applies_to")
94 return {**dict(row), "id": rid, "origin": where}
95
96
97def _read(path: Path, kind: str) -> List[Dict[str, Any]]:
98 data = json.loads(path.read_text())
99 if not isinstance(data, Mapping) or data.get("kind") != kind or not isinstance(data.get("rows"), list):
100 raise ValueError(f"{path}: not a {kind} catalogue ({SCHEMA}, kind {kind!r}, rows [...])")
101 return [_check_row(kind, r, path.name) for r in data["rows"]]
102
103
104def user_catalog_dir(user_dir: Optional[Path]) -> Optional[Path]:
105 return (Path(user_dir) / "layerx" / "catalog") if user_dir else None
106
107
108def load_catalogs(user_dir: Optional[Path] = None, shipped_dir: Optional[Path] = None) -> Dict[str, Any]:
109 """Every catalogue, shipped rows first and a user's rows over them (by id). A user file that
110 does not read is reported in ``problems``, never silently dropped, and the shipped rows stand."""
111 shipped = Path(shipped_dir) if shipped_dir else SHIPPED_DIR
112 out: Dict[str, Any] = {"drills": drill_rows(), "problems": []}
113 udir = user_catalog_dir(user_dir)
114 for kind in FILE_KINDS:
115 rows: Dict[str, Dict[str, Any]] = {}
116 path = shipped / f"{kind}.json"
117 if path.is_file():
118 for r in _read(path, kind):
119 rows[r["id"]] = {**r, "origin": "shipped"}
120 if udir is not None and (udir / f"{kind}.json").is_file():
121 try:
122 for r in _read(udir / f"{kind}.json", kind):
123 rows[r["id"]] = {**r, "origin": "user"}
124 except (ValueError, OSError, json.JSONDecodeError) as exc:
125 out["problems"].append(f"{kind}: {exc}")
126 out[kind] = list(rows.values())
127 return out
128
129
130def rows_for(catalogs: Mapping[str, Any], kind: str, applies: str) -> List[Dict[str, Any]]:
131 return [r for r in catalogs.get(kind) or [] if applies in (r.get("applies_to") or [])]
132
133
134def param_si(row: Mapping[str, Any], name: str) -> Optional[float]:
135 """A row parameter in the drawing's own SI-ish units: mm for lengths, L for volume, psi, Cv."""
136 p = (row.get("params") or {}).get(name)
137 if not isinstance(p, Mapping):
138 return None
139 v, u = float(p["value"]), str(p.get("unit") or "")
140 return v * {"in": 25.4, "m": 1e3, "mm": 1.0}.get(u, 1.0) if u in ("in", "m", "mm") else v
141
142
143def neighbours(rows: List[Dict[str, Any]], key: str, current: float, n: int) -> List[Dict[str, Any]]:
144 """Up to ``n`` rows each side of ``current`` in ``key`` (nearest first, by ratio), never the
145 current size itself."""
146 scored = [(param_si(r, key), r) for r in rows]
147 scored = [(v, r) for v, r in scored if v is not None and v > 0 and abs(v / current - 1.0) > 1e-6]
148 below = sorted([s for s in scored if s[0] < current], key=lambda s: current / s[0])[:n]
149 above = sorted([s for s in scored if s[0] > current], key=lambda s: s[0] / current)[:n]
150 seen, out = set(), []
151 for _, r in below + above:
152 if r["id"] not in seen:
153 seen.add(r["id"])
154 out.append(r)
155 return out
List[Dict[str, Any]] _read(Path path, str kind)
Definition catalog.py:97
Dict[str, Any] _check_row(str kind, Mapping[str, Any] row, str where)
Definition catalog.py:70
List[Dict[str, Any]] rows_for(Mapping[str, Any] catalogs, str kind, str applies)
Definition catalog.py:130
Dict[str, Any] load_catalogs(Optional[Path] user_dir=None, Optional[Path] shipped_dir=None)
Definition catalog.py:108
List[Dict[str, Any]] neighbours(List[Dict[str, Any]] rows, str key, float current, int n)
Definition catalog.py:143
List[Dict[str, Any]] drill_rows(float lo_mm=0.5, float hi_mm=13.0)
Definition catalog.py:51
Optional[Path] user_catalog_dir(Optional[Path] user_dir)
Definition catalog.py:104
Optional[float] param_si(Mapping[str, Any] row, str name)
Definition catalog.py:134