decklens 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.
decklens/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """DeckLens Semantic Diff for CAE — OpenRadioss input file analysis."""
2
+
3
+ __version__ = "0.1.0"
decklens/cli.py ADDED
@@ -0,0 +1,199 @@
1
+ """CLI entry point for DeckLens Semantic Diff."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import click
10
+ from rich.columns import Columns
11
+ from rich.console import Console
12
+ from rich.panel import Panel
13
+ from rich.table import Table
14
+ from rich.text import Text
15
+
16
+ from .diff.engine import Change, DiffEngine
17
+ from .parsers.openradioss import parse_deck
18
+
19
+ def _make_console() -> Console:
20
+ import sys
21
+ if sys.platform == "win32":
22
+ try:
23
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
24
+ sys.stderr.reconfigure(encoding="utf-8", errors="replace") # type: ignore[attr-defined]
25
+ except (AttributeError, Exception):
26
+ pass
27
+ return Console(highlight=False, safe_box=True)
28
+
29
+
30
+ console = _make_console()
31
+
32
+ _SEVERITY_STYLE = {
33
+ "CRITICAL": "bold red",
34
+ "WARNING": "bold yellow",
35
+ "INFO": "cyan",
36
+ }
37
+ _SEVERITY_LABEL = {
38
+ "CRITICAL": "CRIT",
39
+ "WARNING": "WARN",
40
+ "INFO": "INFO",
41
+ }
42
+
43
+
44
+ def _fmt_value(v, unit: str | None) -> str:
45
+ if v is None:
46
+ return "-"
47
+ u = f" {unit}" if unit else ""
48
+ if isinstance(v, float):
49
+ return f"{v:g}{u}"
50
+ return f"{v}{u}"
51
+
52
+
53
+ def _pct_text(pct: float | None) -> Text:
54
+ if pct is None:
55
+ return Text("")
56
+ sign = "+" if pct > 0 else ""
57
+ color = "red" if abs(pct) >= 20 else "yellow" if abs(pct) >= 5 else "green"
58
+ return Text(f"{sign}{pct:.1f}%", style=color)
59
+
60
+
61
+ def _build_table(changes: list[Change]) -> Table:
62
+ table = Table(
63
+ show_header=True,
64
+ header_style="bold white on dark_blue",
65
+ border_style="bright_black",
66
+ safe_box=True,
67
+ )
68
+ table.add_column("Sev", style="bold", no_wrap=True)
69
+ table.add_column("Category", no_wrap=True)
70
+ table.add_column("Type / ID", no_wrap=True)
71
+ table.add_column("Name")
72
+ table.add_column("Field", ratio=2)
73
+ table.add_column("Before", justify="right", no_wrap=True)
74
+ table.add_column("After", justify="right", no_wrap=True)
75
+ table.add_column("Change", justify="right", no_wrap=True)
76
+
77
+ for ch in changes:
78
+ style = _SEVERITY_STYLE[ch.severity]
79
+ icon = Text("[" + _SEVERITY_LABEL[ch.severity] + "]", style=style)
80
+ table.add_row(
81
+ icon,
82
+ Text(ch.category, style="dim"),
83
+ Text(f"{ch.item_type}/{ch.item_id}", style="bright_white"),
84
+ Text(str(ch.item_name), style="white"),
85
+ Text(str(ch.field)),
86
+ Text(_fmt_value(ch.old_value, None)),
87
+ Text(_fmt_value(ch.new_value, ch.unit)),
88
+ _pct_text(ch.percent_change),
89
+ )
90
+
91
+ return table
92
+
93
+
94
+ def _build_summary(changes: list[Change]) -> Panel:
95
+ counts = {"CRITICAL": 0, "WARNING": 0, "INFO": 0}
96
+ for ch in changes:
97
+ counts[ch.severity] += 1
98
+
99
+ parts = []
100
+ for sev in ("CRITICAL", "WARNING", "INFO"):
101
+ n = counts[sev]
102
+ style = _SEVERITY_STYLE[sev]
103
+ parts.append(Text(f" {n} {sev} ", style=style))
104
+
105
+ content = Text("").join(parts)
106
+ return Panel(content, title="[bold]Summary", expand=False)
107
+
108
+
109
+ @click.group()
110
+ @click.version_option(package_name="DeckLens-Semantic-Diff-for-CAE")
111
+ def main() -> None:
112
+ """DeckLens: Semantic Diff for CAE input files."""
113
+
114
+
115
+ @main.command()
116
+ @click.argument("before", type=click.Path(exists=True, path_type=Path))
117
+ @click.argument("after", type=click.Path(exists=True, path_type=Path))
118
+ @click.option("--no-ai", is_flag=True, default=False, help="Skip Claude AI analysis.")
119
+ @click.option(
120
+ "--format", "output_format",
121
+ type=click.Choice(["text", "json"], case_sensitive=False),
122
+ default="text",
123
+ help="Output format.",
124
+ )
125
+ @click.option("--model", default="claude-opus-4-8", show_default=True,
126
+ help="Claude model ID for AI analysis.")
127
+ @click.option("--min-severity",
128
+ type=click.Choice(["INFO", "WARNING", "CRITICAL"], case_sensitive=False),
129
+ default="INFO", show_default=True,
130
+ help="Minimum severity level to display.")
131
+ def diff(
132
+ before: Path,
133
+ after: Path,
134
+ no_ai: bool,
135
+ output_format: str,
136
+ model: str,
137
+ min_severity: str,
138
+ ) -> None:
139
+ """Compare two OpenRadioss deck files and show engineering-aware changes.
140
+
141
+ BEFORE and AFTER are paths to .rad input files.
142
+ """
143
+ _sev_rank = {"INFO": 0, "WARNING": 1, "CRITICAL": 2}
144
+ min_rank = _sev_rank[min_severity.upper()]
145
+
146
+ # Parse
147
+ with console.status("[bold green]Parsing decks...", spinner="dots"):
148
+ try:
149
+ deck_before = parse_deck(before)
150
+ deck_after = parse_deck(after)
151
+ except Exception as exc:
152
+ console.print(f"[red]Parse error: {exc}")
153
+ sys.exit(1)
154
+
155
+ # Diff
156
+ engine = DiffEngine()
157
+ all_changes = engine.diff(deck_before, deck_after)
158
+ changes = [ch for ch in all_changes if _sev_rank[ch.severity] >= min_rank]
159
+
160
+ if output_format == "json":
161
+ import dataclasses
162
+ click.echo(json.dumps([dataclasses.asdict(ch) for ch in changes], indent=2))
163
+ return
164
+
165
+ # Rich text output
166
+ console.print()
167
+ console.rule(f"[bold blue]DeckLens Semantic Diff")
168
+ console.print(f" [dim]Before:[/dim] [white]{before}")
169
+ console.print(f" [dim]After: [/dim] [white]{after}")
170
+ console.print()
171
+
172
+ if not changes:
173
+ console.print("[green]No changes found above the specified severity threshold.[/green]")
174
+ return
175
+
176
+ console.print(_build_summary(all_changes))
177
+ console.print()
178
+ console.print(_build_table(changes))
179
+
180
+ # AI analysis
181
+ if not no_ai:
182
+ try:
183
+ from dotenv import load_dotenv
184
+ load_dotenv()
185
+ except ImportError:
186
+ pass
187
+
188
+ import os
189
+ if not os.environ.get("ANTHROPIC_API_KEY"):
190
+ console.print(
191
+ "\n[yellow]Tip: Set ANTHROPIC_API_KEY in .env for AI analysis. "
192
+ "Use --no-ai to suppress this message.[/yellow]"
193
+ )
194
+ return
195
+
196
+ from .explainer.claude import ClaudeExplainer
197
+ with console.status("[bold green]Generating AI analysis...", spinner="dots"):
198
+ explainer = ClaudeExplainer(model=model)
199
+ explainer.explain(changes, str(before), str(after), console=console)
@@ -0,0 +1,3 @@
1
+ from .engine import Change, DiffEngine
2
+
3
+ __all__ = ["Change", "DiffEngine"]
@@ -0,0 +1,472 @@
1
+ """Semantic diff engine: compares two Deck objects and returns engineering-aware Changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from typing import Any
7
+
8
+ from ..parsers.base import (
9
+ BoundaryCondition,
10
+ Contact,
11
+ Deck,
12
+ Load,
13
+ Material,
14
+ Part,
15
+ ShellProperty,
16
+ SolidProperty,
17
+ )
18
+
19
+ # Thresholds (WARNING%, CRITICAL%) per field name
20
+ _THRESHOLDS: dict[str, tuple[float, float]] = {
21
+ "thickness": (5.0, 20.0),
22
+ "Thick": (5.0, 20.0),
23
+ "E": (1.5, 10.0),
24
+ "nu": (5.0, 15.0),
25
+ "yield_stress": (5.0, 15.0),
26
+ "a": (5.0, 15.0), # J-C yield stress
27
+ "Rho_Init": (5.0, 20.0),
28
+ "rho": (5.0, 20.0),
29
+ "Fscale_y": (10.0, 50.0), # load magnitude
30
+ "Stfac": (10.0, 30.0), # contact stiffness
31
+ "Fric": (10.0, 30.0), # friction coefficient
32
+ "gap_min": (10.0, 30.0),
33
+ }
34
+
35
+ _SEVERITY_ORDER = ("CRITICAL", "WARNING", "INFO")
36
+
37
+
38
+ @dataclass
39
+ class Change:
40
+ category: str # MATERIAL | PROPERTY | PART | BOUNDARY | LOAD | CONTACT | MESH | TOPOLOGY
41
+ item_type: str # e.g. "MAT/ELAST", "PROP/SHELL"
42
+ item_id: int | str
43
+ item_name: str
44
+ field: str
45
+ old_value: Any
46
+ new_value: Any
47
+ unit: str | None = None
48
+ percent_change: float | None = None
49
+ severity: str = "INFO" # INFO | WARNING | CRITICAL
50
+
51
+
52
+ def _pct(old: float, new: float) -> float | None:
53
+ if old == 0.0:
54
+ return None
55
+ return (new - old) / abs(old) * 100.0
56
+
57
+
58
+ def _severity(field: str, pct: float | None) -> str:
59
+ if pct is None:
60
+ return "WARNING"
61
+ warn, crit = _THRESHOLDS.get(field, (5.0, 20.0))
62
+ abs_pct = abs(pct)
63
+ if abs_pct >= crit:
64
+ return "CRITICAL"
65
+ if abs_pct >= warn:
66
+ return "WARNING"
67
+ return "INFO"
68
+
69
+
70
+ def _float_change(
71
+ category: str,
72
+ item_type: str,
73
+ item_id: int,
74
+ item_name: str,
75
+ field: str,
76
+ old: float | None,
77
+ new: float | None,
78
+ unit: str | None = None,
79
+ ) -> Change | None:
80
+ if old is None and new is None:
81
+ return None
82
+ if old == new:
83
+ return None
84
+ pct = _pct(old, new) if (old is not None and new is not None) else None
85
+ sev = _severity(field, pct)
86
+ return Change(
87
+ category=category,
88
+ item_type=item_type,
89
+ item_id=item_id,
90
+ item_name=item_name,
91
+ field=field,
92
+ old_value=old,
93
+ new_value=new,
94
+ unit=unit,
95
+ percent_change=pct,
96
+ severity=sev,
97
+ )
98
+
99
+
100
+ class DiffEngine:
101
+ def diff(self, before: Deck, after: Deck) -> list[Change]:
102
+ changes: list[Change] = []
103
+ changes.extend(self._diff_materials(before, after))
104
+ changes.extend(self._diff_shell_props(before, after))
105
+ changes.extend(self._diff_solid_props(before, after))
106
+ changes.extend(self._diff_parts(before, after))
107
+ changes.extend(self._diff_bcs(before, after))
108
+ changes.extend(self._diff_loads(before, after))
109
+ changes.extend(self._diff_contacts(before, after))
110
+ changes.extend(self._diff_mesh(before, after))
111
+ # Sort: CRITICAL first, then WARNING, then INFO
112
+ changes.sort(key=lambda c: _SEVERITY_ORDER.index(c.severity))
113
+ return changes
114
+
115
+ # ------------------------------------------------------------------
116
+ # Materials
117
+ # ------------------------------------------------------------------
118
+
119
+ def _diff_materials(self, before: Deck, after: Deck) -> list[Change]:
120
+ changes: list[Change] = []
121
+ all_ids = set(before.materials) | set(after.materials)
122
+
123
+ for mid in sorted(all_ids):
124
+ b = before.materials.get(mid)
125
+ a = after.materials.get(mid)
126
+
127
+ if b is None:
128
+ changes.append(Change(
129
+ category="MATERIAL", item_type=f"MAT/{a.law}",
130
+ item_id=mid, item_name=a.name,
131
+ field="[added]", old_value=None, new_value=a.name,
132
+ severity="WARNING",
133
+ ))
134
+ continue
135
+ if a is None:
136
+ changes.append(Change(
137
+ category="MATERIAL", item_type=f"MAT/{b.law}",
138
+ item_id=mid, item_name=b.name,
139
+ field="[removed]", old_value=b.name, new_value=None,
140
+ severity="CRITICAL",
141
+ ))
142
+ continue
143
+
144
+ item_type = f"MAT/{b.law}"
145
+ name = b.name
146
+
147
+ for fld, unit in [("E", "MPa"), ("nu", None), ("rho", "Mg/mm³"), ("yield_stress", "MPa")]:
148
+ bv = getattr(b, fld)
149
+ av = getattr(a, fld)
150
+ c = _float_change("MATERIAL", item_type, mid, name, fld, bv, av, unit)
151
+ if c:
152
+ changes.append(c)
153
+
154
+ # Extra raw fields for plasticity laws
155
+ if b.law in ("PLAS_JOHNS", "PLAS_ZERIL"):
156
+ for fld, unit in [("b", "MPa"), ("n", None), ("c", None)]:
157
+ bv = b.raw_fields.get(fld)
158
+ av = a.raw_fields.get(fld)
159
+ c = _float_change("MATERIAL", item_type, mid, name, fld, bv, av, unit)
160
+ if c:
161
+ changes.append(c)
162
+
163
+ return changes
164
+
165
+ # ------------------------------------------------------------------
166
+ # Shell properties
167
+ # ------------------------------------------------------------------
168
+
169
+ def _diff_shell_props(self, before: Deck, after: Deck) -> list[Change]:
170
+ changes: list[Change] = []
171
+ all_ids = set(before.shell_props) | set(after.shell_props)
172
+
173
+ for pid in sorted(all_ids):
174
+ b = before.shell_props.get(pid)
175
+ a = after.shell_props.get(pid)
176
+
177
+ if b is None:
178
+ changes.append(Change(
179
+ category="PROPERTY", item_type="PROP/SHELL",
180
+ item_id=pid, item_name=a.name,
181
+ field="[added]", old_value=None, new_value=a.name,
182
+ severity="WARNING",
183
+ ))
184
+ continue
185
+ if a is None:
186
+ changes.append(Change(
187
+ category="PROPERTY", item_type="PROP/SHELL",
188
+ item_id=pid, item_name=b.name,
189
+ field="[removed]", old_value=b.name, new_value=None,
190
+ severity="CRITICAL",
191
+ ))
192
+ continue
193
+
194
+ c = _float_change("PROPERTY", "PROP/SHELL", pid, b.name, "thickness", b.thickness, a.thickness, "mm")
195
+ if c:
196
+ # Annotate bending stiffness impact: EI ∝ t³
197
+ if c.percent_change is not None and b.thickness and a.thickness:
198
+ ratio = a.thickness / b.thickness
199
+ stiffness_pct = (ratio ** 3 - 1.0) * 100.0
200
+ c.field = f"thickness (bend-stiffness {stiffness_pct:+.1f}%)"
201
+ changes.append(c)
202
+
203
+ if b.ishell != a.ishell and a.ishell is not None:
204
+ changes.append(Change(
205
+ category="PROPERTY", item_type="PROP/SHELL",
206
+ item_id=pid, item_name=b.name,
207
+ field="Ishell (formulation)",
208
+ old_value=b.ishell, new_value=a.ishell,
209
+ severity="WARNING",
210
+ ))
211
+
212
+ return changes
213
+
214
+ # ------------------------------------------------------------------
215
+ # Solid properties
216
+ # ------------------------------------------------------------------
217
+
218
+ def _diff_solid_props(self, before: Deck, after: Deck) -> list[Change]:
219
+ changes: list[Change] = []
220
+ all_ids = set(before.solid_props) | set(after.solid_props)
221
+
222
+ for pid in sorted(all_ids):
223
+ b = before.solid_props.get(pid)
224
+ a = after.solid_props.get(pid)
225
+
226
+ if b is None:
227
+ changes.append(Change(
228
+ category="PROPERTY", item_type="PROP/SOLID",
229
+ item_id=pid, item_name=a.name,
230
+ field="[added]", old_value=None, new_value=a.name,
231
+ severity="INFO",
232
+ ))
233
+ elif a is None:
234
+ changes.append(Change(
235
+ category="PROPERTY", item_type="PROP/SOLID",
236
+ item_id=pid, item_name=b.name,
237
+ field="[removed]", old_value=b.name, new_value=None,
238
+ severity="CRITICAL",
239
+ ))
240
+ elif b.isolid != a.isolid:
241
+ changes.append(Change(
242
+ category="PROPERTY", item_type="PROP/SOLID",
243
+ item_id=pid, item_name=b.name,
244
+ field="Isolid (formulation)",
245
+ old_value=b.isolid, new_value=a.isolid,
246
+ severity="WARNING",
247
+ ))
248
+
249
+ return changes
250
+
251
+ # ------------------------------------------------------------------
252
+ # Parts
253
+ # ------------------------------------------------------------------
254
+
255
+ def _diff_parts(self, before: Deck, after: Deck) -> list[Change]:
256
+ changes: list[Change] = []
257
+ all_ids = set(before.parts) | set(after.parts)
258
+
259
+ for pid in sorted(all_ids):
260
+ b = before.parts.get(pid)
261
+ a = after.parts.get(pid)
262
+
263
+ if b is None:
264
+ changes.append(Change(
265
+ category="PART", item_type="PART",
266
+ item_id=pid, item_name=a.name,
267
+ field="[added]", old_value=None, new_value=a.name,
268
+ severity="INFO",
269
+ ))
270
+ continue
271
+ if a is None:
272
+ changes.append(Change(
273
+ category="PART", item_type="PART",
274
+ item_id=pid, item_name=b.name,
275
+ field="[removed]", old_value=b.name, new_value=None,
276
+ severity="CRITICAL",
277
+ ))
278
+ continue
279
+
280
+ if b.mat_id != a.mat_id:
281
+ changes.append(Change(
282
+ category="PART", item_type="PART",
283
+ item_id=pid, item_name=b.name,
284
+ field="mat_ID",
285
+ old_value=b.mat_id, new_value=a.mat_id,
286
+ severity="CRITICAL",
287
+ ))
288
+ if b.prop_id != a.prop_id:
289
+ changes.append(Change(
290
+ category="PART", item_type="PART",
291
+ item_id=pid, item_name=b.name,
292
+ field="prop_ID",
293
+ old_value=b.prop_id, new_value=a.prop_id,
294
+ severity="CRITICAL",
295
+ ))
296
+
297
+ return changes
298
+
299
+ # ------------------------------------------------------------------
300
+ # Boundary conditions
301
+ # ------------------------------------------------------------------
302
+
303
+ def _diff_bcs(self, before: Deck, after: Deck) -> list[Change]:
304
+ changes: list[Change] = []
305
+ all_ids = set(before.bcs) | set(after.bcs)
306
+ dofs = ["tx", "ty", "tz", "rx", "ry", "rz"]
307
+
308
+ for bid in sorted(all_ids):
309
+ b = before.bcs.get(bid)
310
+ a = after.bcs.get(bid)
311
+
312
+ if b is None:
313
+ changes.append(Change(
314
+ category="BOUNDARY", item_type="BCS",
315
+ item_id=bid, item_name=a.name,
316
+ field="[added]", old_value=None, new_value=a.name,
317
+ severity="WARNING",
318
+ ))
319
+ continue
320
+ if a is None:
321
+ changes.append(Change(
322
+ category="BOUNDARY", item_type="BCS",
323
+ item_id=bid, item_name=b.name,
324
+ field="[removed]", old_value=b.name, new_value=None,
325
+ severity="CRITICAL",
326
+ ))
327
+ continue
328
+
329
+ for dof in dofs:
330
+ bv = getattr(b, dof)
331
+ av = getattr(a, dof)
332
+ if bv != av:
333
+ changes.append(Change(
334
+ category="BOUNDARY", item_type="BCS",
335
+ item_id=bid, item_name=b.name,
336
+ field=dof.upper(),
337
+ old_value="fixed" if bv else "free",
338
+ new_value="fixed" if av else "free",
339
+ severity="CRITICAL" if not av else "WARNING",
340
+ ))
341
+
342
+ return changes
343
+
344
+ # ------------------------------------------------------------------
345
+ # Loads
346
+ # ------------------------------------------------------------------
347
+
348
+ def _diff_loads(self, before: Deck, after: Deck) -> list[Change]:
349
+ changes: list[Change] = []
350
+ all_ids = set(before.loads) | set(after.loads)
351
+
352
+ for lid in sorted(all_ids):
353
+ b = before.loads.get(lid)
354
+ a = after.loads.get(lid)
355
+
356
+ if b is None:
357
+ changes.append(Change(
358
+ category="LOAD", item_type=a.load_type,
359
+ item_id=lid, item_name=a.name,
360
+ field="[added]", old_value=None, new_value=a.name,
361
+ severity="WARNING",
362
+ ))
363
+ continue
364
+ if a is None:
365
+ changes.append(Change(
366
+ category="LOAD", item_type=b.load_type,
367
+ item_id=lid, item_name=b.name,
368
+ field="[removed]", old_value=b.name, new_value=None,
369
+ severity="CRITICAL",
370
+ ))
371
+ continue
372
+
373
+ item_type = b.load_type
374
+
375
+ # CLOAD: compare direction and magnitude scaling
376
+ if item_type == "CLOAD":
377
+ bdir = b.raw_fields.get("Dir")
378
+ adir = a.raw_fields.get("Dir")
379
+ if bdir != adir:
380
+ changes.append(Change(
381
+ category="LOAD", item_type="CLOAD",
382
+ item_id=lid, item_name=b.name,
383
+ field="Dir",
384
+ old_value=bdir, new_value=adir,
385
+ severity="CRITICAL",
386
+ ))
387
+ c = _float_change("LOAD", "CLOAD", lid, b.name, "Fscale_y",
388
+ b.raw_fields.get("Fscale_y"), a.raw_fields.get("Fscale_y"), "N")
389
+ if c:
390
+ changes.append(c)
391
+
392
+ elif item_type == "GRAV":
393
+ for ax in ("gx", "gy", "gz"):
394
+ c = _float_change("LOAD", "GRAV", lid, b.name, ax,
395
+ b.raw_fields.get(ax), a.raw_fields.get(ax), "mm/s²")
396
+ if c:
397
+ changes.append(c)
398
+
399
+ elif item_type == "PRESSURE":
400
+ c = _float_change("LOAD", "PRESSURE", lid, b.name, "Fscale_P",
401
+ b.raw_fields.get("Fscale_P"), a.raw_fields.get("Fscale_P"), "MPa")
402
+ if c:
403
+ changes.append(c)
404
+
405
+ return changes
406
+
407
+ # ------------------------------------------------------------------
408
+ # Contacts
409
+ # ------------------------------------------------------------------
410
+
411
+ def _diff_contacts(self, before: Deck, after: Deck) -> list[Change]:
412
+ changes: list[Change] = []
413
+ all_ids = set(before.contacts) | set(after.contacts)
414
+
415
+ for cid in sorted(all_ids):
416
+ b = before.contacts.get(cid)
417
+ a = after.contacts.get(cid)
418
+
419
+ if b is None:
420
+ changes.append(Change(
421
+ category="CONTACT", item_type=f"INTER/{a.contact_type}",
422
+ item_id=cid, item_name=a.name,
423
+ field="[added]", old_value=None, new_value=a.name,
424
+ severity="WARNING",
425
+ ))
426
+ continue
427
+ if a is None:
428
+ changes.append(Change(
429
+ category="CONTACT", item_type=f"INTER/{b.contact_type}",
430
+ item_id=cid, item_name=b.name,
431
+ field="[removed]", old_value=b.name, new_value=None,
432
+ severity="CRITICAL",
433
+ ))
434
+ continue
435
+
436
+ item_type = f"INTER/{b.contact_type}"
437
+ for fld, unit in [("Stfac", None), ("Fric", None), ("gap_min", "mm"), ("gap_max", "mm")]:
438
+ c = _float_change("CONTACT", item_type, cid, b.name, fld,
439
+ b.raw_fields.get(fld), a.raw_fields.get(fld), unit)
440
+ if c:
441
+ changes.append(c)
442
+
443
+ return changes
444
+
445
+ # ------------------------------------------------------------------
446
+ # Mesh topology
447
+ # ------------------------------------------------------------------
448
+
449
+ def _diff_mesh(self, before: Deck, after: Deck) -> list[Change]:
450
+ changes: list[Change] = []
451
+
452
+ for attr, label, unit in [
453
+ ("node_count", "node_count", "nodes"),
454
+ ("shell_count", "shell_count", "elements"),
455
+ ("solid_count", "solid_count", "elements"),
456
+ ]:
457
+ bv = getattr(before, attr)
458
+ av = getattr(after, attr)
459
+ if bv == av:
460
+ continue
461
+ pct = _pct(bv, av) if bv else None
462
+ changes.append(Change(
463
+ category="MESH", item_type="MESH",
464
+ item_id=0, item_name="mesh",
465
+ field=label,
466
+ old_value=bv, new_value=av,
467
+ unit=unit,
468
+ percent_change=pct,
469
+ severity="WARNING" if pct and abs(pct) >= 10.0 else "INFO",
470
+ ))
471
+
472
+ return changes
@@ -0,0 +1,3 @@
1
+ from .claude import ClaudeExplainer
2
+
3
+ __all__ = ["ClaudeExplainer"]