simcon-toolkit 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- simcon_toolkit/__init__.py +40 -0
- simcon_toolkit/__main__.py +7 -0
- simcon_toolkit/_kit/LICENSE +202 -0
- simcon_toolkit/_kit/NOTICE +37 -0
- simcon_toolkit/_kit/assets/parts/clip_frame.stl +0 -0
- simcon_toolkit/_kit/assets/parts/simple_plate.stl +0 -0
- simcon_toolkit/_kit/packages/.ruff.toml +10 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/__init__.py +8 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/auth.py +681 -0
- simcon_toolkit/_kit/packages/cadmould_cloud/client.py +235 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/__init__.py +5 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/mesh.py +210 -0
- simcon_toolkit/_kit/packages/cadmould_geometry/stl.py +168 -0
- simcon_toolkit/_kit/packages/cadmould_results/__init__.py +30 -0
- simcon_toolkit/_kit/packages/cadmould_results/loader.py +288 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/__init__.py +7 -0
- simcon_toolkit/_kit/packages/cadmould_scoring/metrics.py +519 -0
- simcon_toolkit/_kit/pyproject.toml +232 -0
- simcon_toolkit/_kit/templates/_shared/AGENTS.base.md +101 -0
- simcon_toolkit/_kit/templates/gate-study/.gitignore +18 -0
- simcon_toolkit/_kit/templates/gate-study/AGENTS.md +46 -0
- simcon_toolkit/_kit/templates/gate-study/GATING_STUDY_PLAYBOOK.md +219 -0
- simcon_toolkit/_kit/templates/gate-study/INITIAL_PROMPT.md +26 -0
- simcon_toolkit/_kit/templates/gate-study/README.md +137 -0
- simcon_toolkit/_kit/templates/gate-study/main.py +344 -0
- simcon_toolkit/_kit/templates/gate-study/pipeline.py +281 -0
- simcon_toolkit/_kit/templates/process-window/.gitignore +20 -0
- simcon_toolkit/_kit/templates/process-window/AGENTS.md +49 -0
- simcon_toolkit/_kit/templates/process-window/METHOD.md +155 -0
- simcon_toolkit/_kit/templates/process-window/README.md +176 -0
- simcon_toolkit/_kit/templates/process-window/configs/simple-plate.yaml +116 -0
- simcon_toolkit/_kit/templates/process-window/doe_spec.schema.md +249 -0
- simcon_toolkit/_kit/templates/process-window/main.py +82 -0
- simcon_toolkit/_kit/templates/process-window/process_window/__init__.py +5 -0
- simcon_toolkit/_kit/templates/process-window/process_window/centre.py +298 -0
- simcon_toolkit/_kit/templates/process-window/process_window/design.py +144 -0
- simcon_toolkit/_kit/templates/process-window/process_window/economics.py +367 -0
- simcon_toolkit/_kit/templates/process-window/process_window/emit.py +591 -0
- simcon_toolkit/_kit/templates/process-window/process_window/guardrails.py +153 -0
- simcon_toolkit/_kit/templates/process-window/process_window/harness.py +360 -0
- simcon_toolkit/_kit/templates/process-window/process_window/identity.py +92 -0
- simcon_toolkit/_kit/templates/process-window/process_window/inspect_part.py +184 -0
- simcon_toolkit/_kit/templates/process-window/process_window/kpis.py +355 -0
- simcon_toolkit/_kit/templates/process-window/process_window/material_card.py +163 -0
- simcon_toolkit/_kit/templates/process-window/process_window/probe_proxy.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_confirm.py +403 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_epsilon_floor.py +198 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_feedback.py +322 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_refine.py +279 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_screening.py +370 -0
- simcon_toolkit/_kit/templates/process-window/process_window/run_sweep.py +166 -0
- simcon_toolkit/_kit/templates/process-window/process_window/setup_campaign.py +312 -0
- simcon_toolkit/_kit/templates/process-window/process_window/surrogate.py +201 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_centre.py +169 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_design.py +113 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_guardrails.py +157 -0
- simcon_toolkit/_kit/templates/process-window/process_window/test_surrogate.py +127 -0
- simcon_toolkit/_kit/templates/process-window/process_window/units.py +152 -0
- simcon_toolkit/_kit/templates/quoting/.gitignore +24 -0
- simcon_toolkit/_kit/templates/quoting/AGENTS.md +58 -0
- simcon_toolkit/_kit/templates/quoting/INTERVIEW.md +147 -0
- simcon_toolkit/_kit/templates/quoting/METHOD.md +256 -0
- simcon_toolkit/_kit/templates/quoting/PROMPT.md +46 -0
- simcon_toolkit/_kit/templates/quoting/QUOTING_PLAYBOOK.md +245 -0
- simcon_toolkit/_kit/templates/quoting/README.md +158 -0
- simcon_toolkit/_kit/templates/quoting/main.py +484 -0
- simcon_toolkit/_kit/templates/quoting/parts/.gitkeep +0 -0
- simcon_toolkit/_kit/templates/quoting/quoting/__init__.py +11 -0
- simcon_toolkit/_kit/templates/quoting/quoting/costing.py +725 -0
- simcon_toolkit/_kit/templates/quoting/quoting/geometry.py +398 -0
- simcon_toolkit/_kit/templates/quoting/quoting/shop.py +193 -0
- simcon_toolkit/_kit/templates/quoting/quoting/state.py +260 -0
- simcon_toolkit/_kit/templates/quoting/quoting/study.py +577 -0
- simcon_toolkit/_kit/templates/quoting/quoting/toolkit.py +50 -0
- simcon_toolkit/_kit/templates/quoting/shop/README.md +43 -0
- simcon_toolkit/_kit/templates/quoting/shop/commercial.md +86 -0
- simcon_toolkit/_kit/templates/quoting/shop/lessons.md +94 -0
- simcon_toolkit/_kit/templates/quoting/shop/machines.md +68 -0
- simcon_toolkit/_kit/templates/quoting/shop/materials.md +92 -0
- simcon_toolkit/_kit/templates/quoting/shop/shop-profile.md +87 -0
- simcon_toolkit/_kit/templates/quoting/shop/tooling.md +145 -0
- simcon_toolkit/_kit/templates/run-one-simulation/.gitignore +16 -0
- simcon_toolkit/_kit/templates/run-one-simulation/AGENTS.md +41 -0
- simcon_toolkit/_kit/templates/run-one-simulation/README.md +133 -0
- simcon_toolkit/_kit/templates/run-one-simulation/main.py +216 -0
- simcon_toolkit/_kit/templates.toml +83 -0
- simcon_toolkit/choices.py +11 -0
- simcon_toolkit/cli.py +381 -0
- simcon_toolkit/generate.py +590 -0
- simcon_toolkit/instructions.py +152 -0
- simcon_toolkit/manifest.py +86 -0
- simcon_toolkit/project.py +356 -0
- simcon_toolkit/wizard.py +160 -0
- simcon_toolkit-0.1.0.dist-info/METADATA +48 -0
- simcon_toolkit-0.1.0.dist-info/RECORD +97 -0
- simcon_toolkit-0.1.0.dist-info/WHEEL +4 -0
- simcon_toolkit-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Thin live REST client for the Cloud Solver Service (Simulation Platform).
|
|
2
|
+
|
|
3
|
+
Mirrors the endpoints exercised by the canonical runner
|
|
4
|
+
``cloud-solver/scripts/run_ai_filling.py`` and adds the **project / group**
|
|
5
|
+
endpoints the one-click demo needs. Every call goes over the wire to a real
|
|
6
|
+
deployed (or local) platform; there is no dummy mode here -- ``ai_demo.py`` is a
|
|
7
|
+
*live* end-to-end demo.
|
|
8
|
+
|
|
9
|
+
Endpoints (all under the ``/api/v1`` prefix, which the caller bakes into
|
|
10
|
+
``base_url``):
|
|
11
|
+
|
|
12
|
+
GET /projects -> list (dedupe "AI DEMO" by name)
|
|
13
|
+
POST /projects -> create project
|
|
14
|
+
POST /projects/{id}/groups -> create group
|
|
15
|
+
GET /projects/{id}/runs -> runs linked to the project
|
|
16
|
+
POST /projects/{id}/events -> append a note to the decision log
|
|
17
|
+
POST /geometries/upload-url -> {geometry_id, presigned_put_url}
|
|
18
|
+
PUT <presigned S3 url> -> upload the .cfex bytes
|
|
19
|
+
GET /materials?q=... -> material catalogue search
|
|
20
|
+
GET /material-versions/{version_id} -> temps + process recommendations
|
|
21
|
+
POST /simulations/filling -> AI filling (SYNCHRONOUS; carries project_id/group_id)
|
|
22
|
+
GET /simulations/filling/{id}/download-url -> fresh presigned result download
|
|
23
|
+
GET <presigned S3 url> -> the result HDF5 bytes
|
|
24
|
+
|
|
25
|
+
Auth: an Auth0 Bearer JWT (see ``cloud_auth.get_access_token``) attached to every
|
|
26
|
+
platform call. Presigned S3 PUT/GET URLs are absolute and need no bearer; the PUT
|
|
27
|
+
must send ``Content-Type: application/octet-stream`` exactly or S3 rejects it.
|
|
28
|
+
|
|
29
|
+
Units follow the API: temperatures Kelvin, pressure Pascals, spatial mm, flow cm3/s.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
|
|
41
|
+
# Filling is synchronous server-side: warm ~2-3 s, cold start can take longer.
|
|
42
|
+
# Give each POST generous headroom (the API gateway caps it earlier anyway).
|
|
43
|
+
FILLING_TIMEOUT_S = 120.0
|
|
44
|
+
DEFAULT_TIMEOUT_S = 60.0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class PlatformAPI:
|
|
49
|
+
"""REST client for the cloud platform, holding one connection and one bearer token."""
|
|
50
|
+
|
|
51
|
+
base_url: str # includes the /api/v1 prefix
|
|
52
|
+
token: str
|
|
53
|
+
logger: Callable[[str], None] = print
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
"""Open the connection the rest of this object reuses."""
|
|
57
|
+
self._client = httpx.Client(
|
|
58
|
+
base_url=self.base_url.rstrip("/"),
|
|
59
|
+
headers={"Authorization": f"Bearer {self.token}"},
|
|
60
|
+
timeout=DEFAULT_TIMEOUT_S,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
# -- plumbing ------------------------------------------------------------
|
|
64
|
+
def _req(self, method: str, path: str, *, timeout: float | None = None, **kw: object) -> dict:
|
|
65
|
+
self.logger(f" -> {method} {path}")
|
|
66
|
+
resp = self._client.request(method, path, timeout=timeout or DEFAULT_TIMEOUT_S, **kw)
|
|
67
|
+
self.logger(f" <- {resp.status_code} {resp.reason_phrase}")
|
|
68
|
+
resp.raise_for_status()
|
|
69
|
+
return resp.json() if resp.content else {}
|
|
70
|
+
|
|
71
|
+
def close(self) -> None:
|
|
72
|
+
"""Close the underlying connection."""
|
|
73
|
+
self._client.close()
|
|
74
|
+
|
|
75
|
+
def __enter__(self) -> PlatformAPI:
|
|
76
|
+
"""Enter a ``with`` block; the client is already open."""
|
|
77
|
+
return self
|
|
78
|
+
|
|
79
|
+
def __exit__(self, *exc: object) -> None:
|
|
80
|
+
"""Leave a ``with`` block, closing the connection. Exceptions propagate."""
|
|
81
|
+
self.close()
|
|
82
|
+
|
|
83
|
+
# -- projects / groups ---------------------------------------------------
|
|
84
|
+
def list_projects(self) -> list[dict]:
|
|
85
|
+
"""Every project this token can see."""
|
|
86
|
+
return self._req("GET", "/projects").get("projects", [])
|
|
87
|
+
|
|
88
|
+
def get_or_create_project(self, name: str, *, customer: str | None = None, notes: str | None = None) -> dict:
|
|
89
|
+
"""Reuse an existing project with this exact name, else create one.
|
|
90
|
+
|
|
91
|
+
Keeps re-runs of the demo from piling up duplicate "AI DEMO" projects.
|
|
92
|
+
"""
|
|
93
|
+
for p in self.list_projects():
|
|
94
|
+
if p.get("name") == name:
|
|
95
|
+
self.logger(f" reusing existing project '{name}' (id={p['id']})")
|
|
96
|
+
return p
|
|
97
|
+
body: dict[str, Any] = {"name": name}
|
|
98
|
+
if customer:
|
|
99
|
+
body["customer"] = customer
|
|
100
|
+
if notes:
|
|
101
|
+
body["notes"] = notes
|
|
102
|
+
proj = self._req("POST", "/projects", json=body)
|
|
103
|
+
self.logger(f" created project '{name}' (id={proj['id']})")
|
|
104
|
+
return proj
|
|
105
|
+
|
|
106
|
+
def create_group(self, project_id: str, name: str, *, description: str | None = None) -> dict:
|
|
107
|
+
"""Create a run group inside a project, so a study's phases stay separable."""
|
|
108
|
+
body: dict[str, Any] = {"name": name}
|
|
109
|
+
if description:
|
|
110
|
+
body["description"] = description
|
|
111
|
+
grp = self._req("POST", f"/projects/{project_id}/groups", json=body)
|
|
112
|
+
self.logger(f" created group '{name}' (id={grp['id']})")
|
|
113
|
+
return grp
|
|
114
|
+
|
|
115
|
+
def list_project_runs(self, project_id: str) -> dict:
|
|
116
|
+
"""Every run recorded against a project."""
|
|
117
|
+
return self._req("GET", f"/projects/{project_id}/runs")
|
|
118
|
+
|
|
119
|
+
def add_note(self, project_id: str, message: str, *, title: str | None = None) -> dict | None:
|
|
120
|
+
"""Record a decision as a project note, which the web UI shows on the project page.
|
|
121
|
+
|
|
122
|
+
The platform's request model is a single required field, ``note``. Extra keys are
|
|
123
|
+
ignored rather than refused, so a body that omits ``note`` is a 422 no matter what
|
|
124
|
+
else it carries - which is why a title is prefixed into the text rather than sent
|
|
125
|
+
beside it, where it would be silently dropped. Never let a refused note abort the
|
|
126
|
+
run that produced it: the decision is worth less than the result.
|
|
127
|
+
"""
|
|
128
|
+
note = f"{title}: {message}" if title else message
|
|
129
|
+
try:
|
|
130
|
+
return self._req("POST", f"/projects/{project_id}/events", json={"note": note})
|
|
131
|
+
# Deliberately broad: one dead run must not kill the batch.
|
|
132
|
+
except Exception as exc:
|
|
133
|
+
self.logger(f" (project note not recorded: {type(exc).__name__}: {exc})")
|
|
134
|
+
return None
|
|
135
|
+
|
|
136
|
+
# -- geometry ------------------------------------------------------------
|
|
137
|
+
def request_geometry_upload(self, filename: str, description: str | None = None) -> dict:
|
|
138
|
+
"""Register a geometry and get the presigned URL to upload its ``.cfex`` to."""
|
|
139
|
+
body: dict[str, Any] = {"filename": filename}
|
|
140
|
+
if description:
|
|
141
|
+
body["description"] = description
|
|
142
|
+
return self._req("POST", "/geometries/upload-url", json=body)
|
|
143
|
+
|
|
144
|
+
def put_cfex(self, presigned_put_url: str, cfex_path: str | Path) -> None:
|
|
145
|
+
"""Upload a ``.cfex`` to the presigned URL from ``request_geometry_upload``."""
|
|
146
|
+
data = Path(cfex_path).read_bytes()
|
|
147
|
+
self.logger(f" -> PUT (presigned S3) {len(data)} bytes")
|
|
148
|
+
# The geometry service presigns Content-Type: application/octet-stream;
|
|
149
|
+
# the PUT must send that exact header or S3 returns 403 SignatureDoesNotMatch.
|
|
150
|
+
r = httpx.put(
|
|
151
|
+
presigned_put_url, content=data, headers={"Content-Type": "application/octet-stream"}, timeout=300
|
|
152
|
+
)
|
|
153
|
+
self.logger(f" <- {r.status_code} (uploaded)")
|
|
154
|
+
r.raise_for_status()
|
|
155
|
+
|
|
156
|
+
# -- materials -----------------------------------------------------------
|
|
157
|
+
def search_materials(self, query: str, *, limit: int = 20) -> list[dict]:
|
|
158
|
+
"""Catalogue materials matching a free-text query."""
|
|
159
|
+
return self._req("GET", "/materials", params={"q": query, "limit": limit}).get("materials", [])
|
|
160
|
+
|
|
161
|
+
def find_material(self, trade_name: str) -> dict:
|
|
162
|
+
"""Find a catalogue material by trade name (exact, case-insensitive; else partial)."""
|
|
163
|
+
candidates = self.search_materials(trade_name)
|
|
164
|
+
exact = [m for m in candidates if m.get("trade_name", "").lower() == trade_name.lower()]
|
|
165
|
+
match = exact or [m for m in candidates if trade_name.lower() in m.get("trade_name", "").lower()] or candidates
|
|
166
|
+
if not match:
|
|
167
|
+
raise RuntimeError(f"No material matching '{trade_name}' in the catalogue.")
|
|
168
|
+
return match[0]
|
|
169
|
+
|
|
170
|
+
def get_material_version(self, version_id: str) -> dict:
|
|
171
|
+
"""One catalogue material version, by id."""
|
|
172
|
+
return self._req("GET", f"/material-versions/{version_id}")
|
|
173
|
+
|
|
174
|
+
# -- filling simulation --------------------------------------------------
|
|
175
|
+
def run_filling(
|
|
176
|
+
self,
|
|
177
|
+
*,
|
|
178
|
+
geometry_id: str,
|
|
179
|
+
material_id: str,
|
|
180
|
+
gate_positions_mm: list[list[float]],
|
|
181
|
+
melt_temperature_K: float,
|
|
182
|
+
wall_temperature_K: float,
|
|
183
|
+
flow_rate_cm3_s: float,
|
|
184
|
+
estimated_filling_time_s: float,
|
|
185
|
+
model_version: str,
|
|
186
|
+
num_timesteps: int = 50,
|
|
187
|
+
htc_W_m2K: float = 1000.0,
|
|
188
|
+
label: str | None = None,
|
|
189
|
+
project_id: str | None = None,
|
|
190
|
+
group_id: str | None = None,
|
|
191
|
+
solver_type: str = "ai",
|
|
192
|
+
) -> dict:
|
|
193
|
+
"""POST /simulations/filling -- AI path is synchronous, returns the full result.
|
|
194
|
+
|
|
195
|
+
Setting ``project_id`` / ``group_id`` links the run to the project + group at
|
|
196
|
+
submit time (no separate /runs/link call needed). ``solver_type`` defaults to
|
|
197
|
+
the synchronous "ai" path; "numerical" requests the physics solver (which may
|
|
198
|
+
run async / be unavailable on this endpoint).
|
|
199
|
+
"""
|
|
200
|
+
body: dict[str, Any] = {
|
|
201
|
+
"solver_type": solver_type,
|
|
202
|
+
"model_version": model_version,
|
|
203
|
+
"geometry_id": geometry_id,
|
|
204
|
+
"material_id": material_id,
|
|
205
|
+
"process_params": {
|
|
206
|
+
"melt_temperature_K": melt_temperature_K,
|
|
207
|
+
"wall_temperature_K": wall_temperature_K,
|
|
208
|
+
"flow_rate_cm3_s": flow_rate_cm3_s,
|
|
209
|
+
},
|
|
210
|
+
"gate_positions_mm": gate_positions_mm,
|
|
211
|
+
"estimated_filling_time_s": estimated_filling_time_s,
|
|
212
|
+
"num_timesteps": num_timesteps,
|
|
213
|
+
"htc_W_m2K": htc_W_m2K,
|
|
214
|
+
"label": label,
|
|
215
|
+
"project_id": project_id,
|
|
216
|
+
"group_id": group_id,
|
|
217
|
+
}
|
|
218
|
+
return self._req("POST", "/simulations/filling", json=body, timeout=FILLING_TIMEOUT_S)
|
|
219
|
+
|
|
220
|
+
def refresh_download_url(self, simulation_id: str) -> dict:
|
|
221
|
+
"""A fresh presigned GET for the result (the URL from the POST may have expired)."""
|
|
222
|
+
return self._req("GET", f"/simulations/filling/{simulation_id}/download-url")
|
|
223
|
+
|
|
224
|
+
def download_to(self, url: str, dest: str | Path) -> Path:
|
|
225
|
+
"""Stream a presigned URL to a local file, creating parent directories."""
|
|
226
|
+
dest = Path(dest)
|
|
227
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
228
|
+
self.logger(f" -> GET (presigned S3) {dest.name}")
|
|
229
|
+
with httpx.stream("GET", url, timeout=300) as r:
|
|
230
|
+
r.raise_for_status()
|
|
231
|
+
with open(dest, "wb") as fh:
|
|
232
|
+
for chunk in r.iter_bytes(1 << 20):
|
|
233
|
+
fh.write(chunk)
|
|
234
|
+
self.logger(f" <- 200 saved {dest.name} [{dest.stat().st_size} bytes]")
|
|
235
|
+
return dest
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Mesh an STL into a ``.cfex`` and place gates on real mesh nodes.
|
|
2
|
+
|
|
3
|
+
The AI filling solver requires every gate to sit on an actual mesh node, never an
|
|
4
|
+
interpolated point, and the SDK's mesh objects expose no coordinate getter — so the
|
|
5
|
+
written ``.cfex`` is the only place the node positions can be read back from.
|
|
6
|
+
|
|
7
|
+
``cadmould`` is imported inside the functions that need it, so the pure-geometry
|
|
8
|
+
helpers here stay usable on a machine with no licence and no wheel.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable, Sequence
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from cadmould_geometry import stl
|
|
20
|
+
|
|
21
|
+
__all__ = ["Geometry", "gate_line", "parse_cfex_nodes", "prepare_geometry", "snap"]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sdk_classes() -> dict:
|
|
25
|
+
"""Resolve the SDK entry points this module uses, across wheel layouts.
|
|
26
|
+
|
|
27
|
+
rc2 grouped meshing under ``cadmould.mesh`` (``mesh.Mesh``, ``mesh.Mesher``,
|
|
28
|
+
``mesh.io.CfexWriter``); rc3 moved them to ``cadmould.domain`` / ``cadmould.tools``
|
|
29
|
+
/ ``cadmould.io``. Same constructors and call chains, just relocated.
|
|
30
|
+
|
|
31
|
+
This is the module's only import of the licensed wheel, so it is also where a
|
|
32
|
+
machine without it is told so.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The five SDK entry points this module uses, keyed by name.
|
|
36
|
+
|
|
37
|
+
Raises:
|
|
38
|
+
ModuleNotFoundError: If the licensed wheel is not installed.
|
|
39
|
+
"""
|
|
40
|
+
try:
|
|
41
|
+
# Imported here, not at module level: the licensed wheel is absent on offline
|
|
42
|
+
# machines, and everything else in this module must still work without it.
|
|
43
|
+
import cadmould
|
|
44
|
+
except ImportError as exc:
|
|
45
|
+
raise ModuleNotFoundError(
|
|
46
|
+
"meshing needs the licensed cadmould wheel, which is not installed. "
|
|
47
|
+
"See docs/SETUP.md. Reading an STL and placing gates on an existing mesh "
|
|
48
|
+
"need no licence; only writing a .cfex does."
|
|
49
|
+
) from exc
|
|
50
|
+
|
|
51
|
+
mesh_mod = getattr(cadmould, "mesh", None)
|
|
52
|
+
io_mod = getattr(mesh_mod, "io", None) if mesh_mod else None
|
|
53
|
+
return {
|
|
54
|
+
"Mesh": getattr(mesh_mod, "Mesh", None) or cadmould.domain.Mesh,
|
|
55
|
+
"Mesher": getattr(mesh_mod, "Mesher", None) or cadmould.tools.Mesher,
|
|
56
|
+
"CfexWriter": getattr(io_mod, "CfexWriter", None) or cadmould.io.CfexWriter,
|
|
57
|
+
"suggest_gates": cadmould.tools.suggest_gates,
|
|
58
|
+
"Session": cadmould.Session,
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def parse_cfex_nodes(cfex_path: str | Path) -> np.ndarray:
|
|
63
|
+
"""Read the node coordinates from the ``#NODDAT`` block of a ``.cfex``.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
cfex_path: The ``.cfex`` file to read.
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
An ``(N, 3)`` array of node coordinates in millimetres.
|
|
70
|
+
"""
|
|
71
|
+
coords: list[tuple[float, float, float]] = []
|
|
72
|
+
n_expected = None
|
|
73
|
+
with open(cfex_path) as fh:
|
|
74
|
+
it = iter(fh)
|
|
75
|
+
for line in it:
|
|
76
|
+
if line.startswith("#NODDAT"):
|
|
77
|
+
n_expected = int(line.strip().split(",")[1])
|
|
78
|
+
break
|
|
79
|
+
for line in it:
|
|
80
|
+
if line.startswith("#"):
|
|
81
|
+
break
|
|
82
|
+
parts = line.split(",")
|
|
83
|
+
if len(parts) < 4:
|
|
84
|
+
break
|
|
85
|
+
coords.append((float(parts[1]), float(parts[2]), float(parts[3])))
|
|
86
|
+
if n_expected and len(coords) >= n_expected:
|
|
87
|
+
break
|
|
88
|
+
return np.asarray(coords, dtype=np.float64)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
@dataclass
|
|
92
|
+
class Geometry:
|
|
93
|
+
"""A meshed part: where its nodes are, how big it is, and which way it runs."""
|
|
94
|
+
|
|
95
|
+
cfex_path: Path
|
|
96
|
+
node_coords: np.ndarray
|
|
97
|
+
suggested_gate: list[float]
|
|
98
|
+
volume_cm3: float
|
|
99
|
+
long_axis: int
|
|
100
|
+
|
|
101
|
+
@property
|
|
102
|
+
def bounds(self) -> np.ndarray:
|
|
103
|
+
"""The axis-aligned bounding box as a ``(3, 2)`` min/max array."""
|
|
104
|
+
c = self.node_coords
|
|
105
|
+
return np.array([c.min(0), c.max(0)]).T
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def prepare_geometry(
|
|
109
|
+
stl_path: str | Path,
|
|
110
|
+
cfex_out: str | Path,
|
|
111
|
+
scale: float = 1.0,
|
|
112
|
+
logger: Callable[[str], None] = print,
|
|
113
|
+
) -> Geometry:
|
|
114
|
+
"""Mesh an STL, write the ``.cfex``, and read its node coordinates back.
|
|
115
|
+
|
|
116
|
+
Args:
|
|
117
|
+
stl_path: The surface mesh to start from.
|
|
118
|
+
cfex_out: Where to write the meshed ``.cfex``.
|
|
119
|
+
scale: Multiplier applied to the STL coordinates, for files not in millimetres.
|
|
120
|
+
logger: Called with one progress line.
|
|
121
|
+
|
|
122
|
+
Returns:
|
|
123
|
+
The meshed geometry, including the SDK's own suggested gate.
|
|
124
|
+
"""
|
|
125
|
+
stl_path, cfex_out = Path(stl_path), Path(cfex_out)
|
|
126
|
+
# Resolved before the STL is parsed, so an unlicensed machine is told immediately
|
|
127
|
+
# rather than after the read.
|
|
128
|
+
cm = _sdk_classes()
|
|
129
|
+
|
|
130
|
+
raw_pts, faces = stl.read_stl(stl_path)
|
|
131
|
+
pts = raw_pts * scale
|
|
132
|
+
volume_cm3 = stl.volume_mm3(raw_pts, faces) / 1000.0 * (scale**3)
|
|
133
|
+
|
|
134
|
+
with cm["Session"].user_based():
|
|
135
|
+
logger(" cadmould licence session opened (user_based); a failed sign-in prints above")
|
|
136
|
+
raw = cm["Mesh"](pts.ravel(), faces.ravel())
|
|
137
|
+
meshed = cm["Mesher"](raw).with_surface_triangulation().with_thickness_and_w2w().build()
|
|
138
|
+
gates = cm["suggest_gates"](meshed)
|
|
139
|
+
suggested = [[float(p.x), float(p.y), float(p.z)] for p in gates.points][0]
|
|
140
|
+
cfex_out.parent.mkdir(parents=True, exist_ok=True)
|
|
141
|
+
cm["CfexWriter"]().save(meshed, str(cfex_out))
|
|
142
|
+
|
|
143
|
+
node_coords = parse_cfex_nodes(cfex_out)
|
|
144
|
+
dims = node_coords.max(0) - node_coords.min(0)
|
|
145
|
+
long_axis = int(np.argmax(dims))
|
|
146
|
+
logger(
|
|
147
|
+
f" meshed {meshed.node_count()} nodes / {meshed.element_count()} elems; "
|
|
148
|
+
f"cfex has {len(node_coords)} nodes; volume {volume_cm3:.1f} cm^3; "
|
|
149
|
+
f"long axis = {'xyz'[long_axis]} ({dims[long_axis]:.0f} mm)"
|
|
150
|
+
)
|
|
151
|
+
return Geometry(cfex_out, node_coords, suggested, volume_cm3, long_axis)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def snap(targets: np.ndarray, node_coords: np.ndarray) -> list[list[float]]:
|
|
155
|
+
"""Move each target point onto the nearest real mesh node, dropping duplicates.
|
|
156
|
+
|
|
157
|
+
Args:
|
|
158
|
+
targets: The points to snap, as an ``(M, 3)`` array or a single point.
|
|
159
|
+
node_coords: The mesh nodes to snap onto.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
One position per distinct node, in the order their targets were given. Two targets
|
|
163
|
+
whose nearest node is the same node yield one position, because two gates cannot
|
|
164
|
+
share a node.
|
|
165
|
+
"""
|
|
166
|
+
out: list[list[float]] = []
|
|
167
|
+
seen: set[int] = set()
|
|
168
|
+
for t in np.atleast_2d(targets):
|
|
169
|
+
j = int(np.argmin(np.linalg.norm(node_coords - t, axis=1)))
|
|
170
|
+
# Keyed on the node index. Keying on the rounded position merges two nodes that
|
|
171
|
+
# agree to a micrometre, which silently returns fewer gates than were asked for.
|
|
172
|
+
if j not in seen:
|
|
173
|
+
seen.add(j)
|
|
174
|
+
out.append([float(v) for v in node_coords[j]])
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def gate_line(
|
|
179
|
+
geo: Geometry,
|
|
180
|
+
fractions: Sequence[float],
|
|
181
|
+
ref_xz: tuple[float, float] | None = None,
|
|
182
|
+
) -> list[list[float]]:
|
|
183
|
+
"""Place gates along the part's long axis at the given fractional stations.
|
|
184
|
+
|
|
185
|
+
The off-axis coordinates default to the SDK-suggested gate's, so every gate sits on
|
|
186
|
+
the same feed face and width centre line and only its length station varies.
|
|
187
|
+
|
|
188
|
+
Args:
|
|
189
|
+
geo: The meshed part.
|
|
190
|
+
fractions: Positions along the long axis, 0.0 at one end and 1.0 at the other.
|
|
191
|
+
ref_xz: Fixed off-axis coordinates, overriding the suggested gate's.
|
|
192
|
+
|
|
193
|
+
Returns:
|
|
194
|
+
One position per fraction, each snapped to a real mesh node.
|
|
195
|
+
"""
|
|
196
|
+
axis = geo.long_axis
|
|
197
|
+
lo, hi = geo.node_coords[:, axis].min(), geo.node_coords[:, axis].max()
|
|
198
|
+
others = [i for i in range(3) if i != axis]
|
|
199
|
+
if ref_xz is None:
|
|
200
|
+
ref = {others[0]: geo.suggested_gate[others[0]], others[1]: geo.suggested_gate[others[1]]}
|
|
201
|
+
else:
|
|
202
|
+
ref = {others[0]: ref_xz[0], others[1]: ref_xz[1]}
|
|
203
|
+
targets = []
|
|
204
|
+
for fr in fractions:
|
|
205
|
+
t = [0.0, 0.0, 0.0]
|
|
206
|
+
t[axis] = lo + fr * (hi - lo)
|
|
207
|
+
t[others[0]] = ref[others[0]]
|
|
208
|
+
t[others[1]] = ref[others[1]]
|
|
209
|
+
targets.append(t)
|
|
210
|
+
return snap(np.array(targets), geo.node_coords)
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""Read an STL into the vertex and face arrays the meshing pipeline expects.
|
|
2
|
+
|
|
3
|
+
This replaces a three-line idiom that appeared in every template::
|
|
4
|
+
|
|
5
|
+
surf = pv.read(str(STL)).triangulate()
|
|
6
|
+
pts = np.asarray(surf.points, np.float64)
|
|
7
|
+
faces = surf.faces.reshape(-1, 4)[:, 1:].astype(np.int32)
|
|
8
|
+
|
|
9
|
+
PyVista pulls in VTK, which is roughly 600 MB installed. Nothing here draws
|
|
10
|
+
anything, so the templates were paying a graphics stack to parse a header
|
|
11
|
+
followed by a flat list of triangles.
|
|
12
|
+
|
|
13
|
+
**The output is byte-identical to what PyVista returned**, which is a hard
|
|
14
|
+
requirement rather than a nicety: a gate is stored as an index into the vertex
|
|
15
|
+
array, so a reader that returns the same points in a different order silently
|
|
16
|
+
moves every gate in the kit. `tests/test_stl_reader.py` pins this against
|
|
17
|
+
values recorded from PyVista's own output.
|
|
18
|
+
|
|
19
|
+
Three things make that identity work, and all three are easy to get wrong:
|
|
20
|
+
|
|
21
|
+
* **Duplicate corners are folded.** An STL repeats a shared corner once per
|
|
22
|
+
triangle, and VTK merges them on exact float equality, numbering points in
|
|
23
|
+
the order they are first met while scanning triangles.
|
|
24
|
+
* **Degenerate triangles are dropped** after folding — VTK discards a triangle
|
|
25
|
+
whose corners are not three distinct points, so a reader that keeps them
|
|
26
|
+
returns a longer face array.
|
|
27
|
+
* **A binary file may open with the word "solid"**, so the text-versus-binary
|
|
28
|
+
decision is made on the file's length against its triangle count, never on
|
|
29
|
+
that prefix.
|
|
30
|
+
|
|
31
|
+
`trimesh` is used for the parsing alone — the file-format detection and its
|
|
32
|
+
overflow guard — and costs 3 MB with numpy as its only dependency. The folding
|
|
33
|
+
and the degenerate-face rule are done here, because trimesh's own versions of
|
|
34
|
+
both differ from what the kit assumed; :func:`read_stl` says how.
|
|
35
|
+
|
|
36
|
+
The derived quantities below are plain numpy over the arrays, so they need no
|
|
37
|
+
reader at all and can be tested without one.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from pathlib import Path
|
|
43
|
+
|
|
44
|
+
import numpy as np
|
|
45
|
+
import trimesh
|
|
46
|
+
|
|
47
|
+
__all__ = [
|
|
48
|
+
"body_count",
|
|
49
|
+
"open_edge_count",
|
|
50
|
+
"read_stl",
|
|
51
|
+
"surface_area_mm2",
|
|
52
|
+
"volume_mm3",
|
|
53
|
+
]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def read_stl(path: str | Path) -> tuple[np.ndarray, np.ndarray]:
|
|
57
|
+
"""Read a binary or ASCII STL.
|
|
58
|
+
|
|
59
|
+
trimesh parses the file and nothing else. Its own cleanup is switched off with
|
|
60
|
+
``process=False``, because it does not match what the templates read before:
|
|
61
|
+
|
|
62
|
+
* it merges corners that fall in the same **tolerance bucket** (``1e-8``)
|
|
63
|
+
rather than corners that are exactly equal, so two distinct points closer
|
|
64
|
+
than that become one and every later index shifts. Coordinates smaller than
|
|
65
|
+
about 0.08 are where this bites, because float32 spacing is finer than the
|
|
66
|
+
tolerance there — which is to say, anywhere a part crosses the origin.
|
|
67
|
+
* it keeps a face whose corners collapse to fewer than three distinct points
|
|
68
|
+
once folding has happened.
|
|
69
|
+
|
|
70
|
+
Folding on exact equality in first-encounter order, then dropping collapsed
|
|
71
|
+
faces, is what the graphics stack did and what the rest of the kit assumes.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
path: The ``.stl`` file to read.
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
``(vertices, faces)`` — an ``(N, 3)`` float64 array of point positions in
|
|
78
|
+
the file's own units, and an ``(M, 3)`` int32 array of indices into it.
|
|
79
|
+
"""
|
|
80
|
+
soup = trimesh.load_mesh(str(path), file_type="stl", process=False)
|
|
81
|
+
corners = np.asarray(soup.vertices, dtype=np.float32)[np.asarray(soup.faces, dtype=np.int64)]
|
|
82
|
+
return _fold(corners.reshape(-1, 3))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _fold(corners: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
86
|
+
"""Fold a flat corner list into unique points plus the faces indexing them.
|
|
87
|
+
|
|
88
|
+
Uniqueness is decided on the float32 bit patterns, so it is exact equality and
|
|
89
|
+
carries no tolerance. Points are numbered in the order they are first met.
|
|
90
|
+
"""
|
|
91
|
+
view = np.ascontiguousarray(corners).view([("x", "<f4"), ("y", "<f4"), ("z", "<f4")]).ravel()
|
|
92
|
+
_, first_seen, inverse = np.unique(view, return_index=True, return_inverse=True)
|
|
93
|
+
|
|
94
|
+
order = np.argsort(first_seen)
|
|
95
|
+
renumber = np.empty(len(order), dtype=np.int64)
|
|
96
|
+
renumber[order] = np.arange(len(order))
|
|
97
|
+
|
|
98
|
+
vertices = corners[first_seen[order]].astype(np.float64)
|
|
99
|
+
faces = renumber[inverse].reshape(-1, 3)
|
|
100
|
+
distinct = (faces[:, 0] != faces[:, 1]) & (faces[:, 0] != faces[:, 2]) & (faces[:, 1] != faces[:, 2])
|
|
101
|
+
return vertices, faces[distinct].astype(np.int32)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def volume_mm3(vertices: np.ndarray, faces: np.ndarray) -> float:
|
|
105
|
+
"""Enclosed volume, by the divergence theorem over the triangles.
|
|
106
|
+
|
|
107
|
+
Absolute, matching what the templates read before: a mesh whose normals all
|
|
108
|
+
point inward would otherwise return a negative number where every caller
|
|
109
|
+
expects a positive one. Meaningless on a surface that is not closed — check
|
|
110
|
+
:func:`open_edge_count` first if that is in doubt.
|
|
111
|
+
"""
|
|
112
|
+
a, b, c = _corners(vertices, faces)
|
|
113
|
+
return float(abs(np.einsum("ij,ij->i", a, np.cross(b, c)).sum() / 6.0))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def surface_area_mm2(vertices: np.ndarray, faces: np.ndarray) -> float:
|
|
117
|
+
"""Total area of the triangles."""
|
|
118
|
+
a, b, c = _corners(vertices, faces)
|
|
119
|
+
return float(np.linalg.norm(np.cross(b - a, c - a), axis=1).sum() / 2.0)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def open_edge_count(vertices: np.ndarray, faces: np.ndarray) -> int:
|
|
123
|
+
"""Edges that leave the surface open, so ``0`` means watertight.
|
|
124
|
+
|
|
125
|
+
Counts both boundary edges, used by one triangle, and non-manifold edges,
|
|
126
|
+
used by three or more. Both are defects and the graphics stack counted both;
|
|
127
|
+
counting only the first under-reports a mesh where three faces meet along one
|
|
128
|
+
edge, which is a shape that reads as closed and is not.
|
|
129
|
+
"""
|
|
130
|
+
_, counts = np.unique(_edges(faces), axis=0, return_counts=True)
|
|
131
|
+
return int(((counts == 1) | (counts >= 3)).sum())
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def body_count(vertices: np.ndarray, faces: np.ndarray) -> int:
|
|
135
|
+
"""How many separate connected bodies the mesh describes.
|
|
136
|
+
|
|
137
|
+
Reported because a part file holding one body has been described as holding
|
|
138
|
+
several, and the claim travelled a long way before anyone checked it.
|
|
139
|
+
|
|
140
|
+
Union-find rather than a graph library: the obvious call needs SciPy, and
|
|
141
|
+
pulling in SciPy for this would cost more than the graphics stack this module
|
|
142
|
+
exists to remove.
|
|
143
|
+
"""
|
|
144
|
+
parent = np.arange(len(vertices))
|
|
145
|
+
|
|
146
|
+
def find(node: int) -> int:
|
|
147
|
+
while parent[node] != node:
|
|
148
|
+
parent[node] = parent[parent[node]]
|
|
149
|
+
node = parent[node]
|
|
150
|
+
return int(node)
|
|
151
|
+
|
|
152
|
+
for left, right in _edges(faces):
|
|
153
|
+
root_left, root_right = find(left), find(right)
|
|
154
|
+
if root_left != root_right:
|
|
155
|
+
parent[root_left] = root_right
|
|
156
|
+
return len({find(node) for node in range(len(vertices))})
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _corners(vertices: np.ndarray, faces: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
160
|
+
"""The three corner positions of every triangle, as three parallel arrays."""
|
|
161
|
+
indices = np.asarray(faces, dtype=np.int64)
|
|
162
|
+
return vertices[indices[:, 0]], vertices[indices[:, 1]], vertices[indices[:, 2]]
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _edges(faces: np.ndarray) -> np.ndarray:
|
|
166
|
+
"""Every triangle edge as a sorted index pair, so the two directions agree."""
|
|
167
|
+
indices = np.asarray(faces, dtype=np.int64)
|
|
168
|
+
return np.sort(np.vstack([indices[:, [0, 1]], indices[:, [1, 2]], indices[:, [2, 0]]]), axis=1)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Tiny toolkit for working with Simcon AI-Solver prediction files.
|
|
2
|
+
|
|
3
|
+
from cadmould_results import PredictionResult, PRESSURE, TEMPERATURE
|
|
4
|
+
|
|
5
|
+
See ``cadmould_results.loader`` for the data-model documentation.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .loader import (
|
|
9
|
+
ALL_FIELDS,
|
|
10
|
+
DEGREE_OF_FILLING,
|
|
11
|
+
LAYERED_FIELDS,
|
|
12
|
+
NODE_FIELDS,
|
|
13
|
+
PRESSURE,
|
|
14
|
+
SHEAR_LOG,
|
|
15
|
+
TEMPERATURE,
|
|
16
|
+
PredictionResult,
|
|
17
|
+
ProcessInputs,
|
|
18
|
+
)
|
|
19
|
+
|
|
20
|
+
__all__ = [
|
|
21
|
+
"PredictionResult",
|
|
22
|
+
"ProcessInputs",
|
|
23
|
+
"ALL_FIELDS",
|
|
24
|
+
"NODE_FIELDS",
|
|
25
|
+
"LAYERED_FIELDS",
|
|
26
|
+
"DEGREE_OF_FILLING",
|
|
27
|
+
"PRESSURE",
|
|
28
|
+
"TEMPERATURE",
|
|
29
|
+
"SHEAR_LOG",
|
|
30
|
+
]
|