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 +3 -0
- decklens/cli.py +199 -0
- decklens/diff/__init__.py +3 -0
- decklens/diff/engine.py +472 -0
- decklens/explainer/__init__.py +3 -0
- decklens/explainer/claude.py +108 -0
- decklens/parsers/__init__.py +27 -0
- decklens/parsers/base.py +87 -0
- decklens/parsers/openradioss.py +344 -0
- decklens-0.1.0.dist-info/METADATA +192 -0
- decklens-0.1.0.dist-info/RECORD +15 -0
- decklens-0.1.0.dist-info/WHEEL +5 -0
- decklens-0.1.0.dist-info/entry_points.txt +2 -0
- decklens-0.1.0.dist-info/licenses/LICENSE +21 -0
- decklens-0.1.0.dist-info/top_level.txt +1 -0
decklens/__init__.py
ADDED
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)
|
decklens/diff/engine.py
ADDED
|
@@ -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
|