mphkit 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.
- mphkit/__init__.py +23 -0
- mphkit/_comsol.py +719 -0
- mphkit/_expr.py +37 -0
- mphkit/_measure.py +102 -0
- mphkit/_props.py +62 -0
- mphkit/_sel.py +487 -0
- mphkit/errors.py +5 -0
- mphkit/geometry.py +537 -0
- mphkit/py.typed +0 -0
- mphkit/sel.py +37 -0
- mphkit-0.1.0.dist-info/METADATA +174 -0
- mphkit-0.1.0.dist-info/RECORD +14 -0
- mphkit-0.1.0.dist-info/WHEEL +4 -0
- mphkit-0.1.0.dist-info/licenses/LICENSE +21 -0
mphkit/_comsol.py
ADDED
|
@@ -0,0 +1,719 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Internals that depend on COMSOL or MPh behavior.
|
|
3
|
+
|
|
4
|
+
Everything that relies on COMSOL tag conventions or on MPh internals lives
|
|
5
|
+
here, so that a change in either needs a fix in one place only.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from collections.abc import Iterable
|
|
10
|
+
from difflib import get_close_matches
|
|
11
|
+
|
|
12
|
+
import numpy
|
|
13
|
+
from mph import Node
|
|
14
|
+
from mph.node import cast, escape, join, tag_pattern
|
|
15
|
+
|
|
16
|
+
from ._expr import vector
|
|
17
|
+
|
|
18
|
+
ENTITIES = ('domain', 'boundary', 'edge', 'point')
|
|
19
|
+
RESULT_SUFFIX = {'domain': 'dom', 'boundary': 'bnd', 'edge': 'edg', 'point': 'pnt'}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
#########################
|
|
23
|
+
# Geometry and entities #
|
|
24
|
+
#########################
|
|
25
|
+
|
|
26
|
+
def geometry_of(node: Node) -> Node:
|
|
27
|
+
"""Returns the geometry node that `node` belongs to."""
|
|
28
|
+
if len(node.path) < 2 or node.path[0] != 'geometries':
|
|
29
|
+
raise TypeError(f'Node "{node}" is not a geometry or geometry feature.')
|
|
30
|
+
return Node(node.model, join(node.path[:2]))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def component_of(geom: Node):
|
|
34
|
+
"""Returns the Java component that contains the geometry `geom`."""
|
|
35
|
+
tag = geom.tag()
|
|
36
|
+
components = geom.model.java.component()
|
|
37
|
+
for ctag in components.tags():
|
|
38
|
+
if tag in [str(t) for t in components.get(ctag).geom().tags()]:
|
|
39
|
+
return components.get(ctag)
|
|
40
|
+
raise LookupError(f'No component contains geometry "{geom}".')
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def sdim(geom: Node) -> int:
|
|
44
|
+
"""Returns the space dimension of the geometry."""
|
|
45
|
+
return int(geom.java.getSDim())
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def entity_name(geom: Node, entity: str) -> str:
|
|
49
|
+
"""Validates an entity name; in 2D (and work planes) an edge is a boundary."""
|
|
50
|
+
if entity not in ENTITIES:
|
|
51
|
+
raise ValueError(f'Entity must be one of {ENTITIES}, not {entity!r}.')
|
|
52
|
+
dim = parent_dim(geom)
|
|
53
|
+
if entity == 'edge':
|
|
54
|
+
if dim < 2:
|
|
55
|
+
raise ValueError('A 1D geometry has no edges.')
|
|
56
|
+
if dim == 2:
|
|
57
|
+
return 'boundary'
|
|
58
|
+
return entity
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def entity_dim(geom: Node, entity: str) -> int:
|
|
62
|
+
"""Maps an entity name to the `entitydim` value for a geometry or plane."""
|
|
63
|
+
entity = entity_name(geom, entity)
|
|
64
|
+
dim = parent_dim(geom)
|
|
65
|
+
return {'domain': dim, 'boundary': dim - 1, 'edge': 1, 'point': 0}[entity]
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def entity_count(geom: Node, dim: int) -> int:
|
|
69
|
+
"""Returns the number of entities of level `dim` in the built geometry."""
|
|
70
|
+
java = geom.java
|
|
71
|
+
if dim == sdim(geom):
|
|
72
|
+
return int(java.getNDomains())
|
|
73
|
+
if dim == sdim(geom) - 1:
|
|
74
|
+
return int(java.getNBoundaries())
|
|
75
|
+
return int(java.getNEdges() if dim == 1 else java.getNVertices())
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def parent_dim(parent: Node) -> int:
|
|
79
|
+
"""Returns the space dimension of a geometry, or 2 for a work plane."""
|
|
80
|
+
return 2 if is_workplane(parent.java) else sdim(parent)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def check_not_workplane(parent: Node, what: str):
|
|
84
|
+
"""Raises for a work plane where only a geometry makes sense."""
|
|
85
|
+
if is_workplane(parent.java):
|
|
86
|
+
raise TypeError(f'{what} needs a geometry, not the work plane '
|
|
87
|
+
f'"{parent}".')
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def check_vector(parent: Node, name: str, value):
|
|
91
|
+
"""Raises unless `value` has one item per space dimension of `parent`."""
|
|
92
|
+
dim = parent_dim(parent)
|
|
93
|
+
if (not isinstance(value, (list, tuple, numpy.ndarray))
|
|
94
|
+
or len(value) != dim):
|
|
95
|
+
raise ValueError(f'{name} needs {dim} values in "{parent}", '
|
|
96
|
+
f'not {value!r}.')
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def axis_properties(axis) -> dict:
|
|
100
|
+
"""Maps an axis given as 'x', 'y', 'z' or a vector to COMSOL properties."""
|
|
101
|
+
if axis is None:
|
|
102
|
+
return {}
|
|
103
|
+
if isinstance(axis, str) and axis in ('x', 'y', 'z'):
|
|
104
|
+
return {'axistype': axis}
|
|
105
|
+
return {'axis': axis}
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def check_built(geom: Node):
|
|
109
|
+
"""
|
|
110
|
+
Raises if the geometry is not built or changed since its last build.
|
|
111
|
+
|
|
112
|
+
Queries on an unbuilt geometry silently return nothing, and after an
|
|
113
|
+
edit they return the numbers of the last build. COMSOL marks features
|
|
114
|
+
as not built after edits, parameter changes and disabling, but not when
|
|
115
|
+
a feature is removed, so a removal goes unnoticed.
|
|
116
|
+
"""
|
|
117
|
+
features = geom.java.feature()
|
|
118
|
+
stale = [str(tag) for tag in features.tags()
|
|
119
|
+
if not features.get(tag).isBuilt()]
|
|
120
|
+
if stale:
|
|
121
|
+
raise RuntimeError(f'Geometry "{geom}" is not built or has changed '
|
|
122
|
+
f'since the last build (features: '
|
|
123
|
+
f'{", ".join(stale)}); run model.build(geom) '
|
|
124
|
+
'first.')
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def check_selection(geom: Node, selection: Node):
|
|
128
|
+
"""
|
|
129
|
+
Returns the Java selection behind a selection node of `geom`.
|
|
130
|
+
|
|
131
|
+
MPh finds selections by label, and the selections COMSOL derives from
|
|
132
|
+
one feature (e.g. `geom1_blk1_dom` and `geom1_blk1_bnd`) share the
|
|
133
|
+
feature's label, so such a node may resolve to the wrong one. Those are
|
|
134
|
+
rejected; `sel.result` and `sel.layer` give uniquely named selections.
|
|
135
|
+
"""
|
|
136
|
+
if (not isinstance(selection, Node) or len(selection.path) != 2
|
|
137
|
+
or selection.path[0] != 'selections'):
|
|
138
|
+
raise TypeError(f'{selection!r} is not a selection node.')
|
|
139
|
+
java = selection.java_if_exists()
|
|
140
|
+
tag = str(java.tag())
|
|
141
|
+
gtag = geom.tag()
|
|
142
|
+
own = [str(t) for t in component_of(geom).selection().tags()]
|
|
143
|
+
if tag not in own and not tag.startswith(f'{gtag}_'):
|
|
144
|
+
raise ValueError(f'Selection "{selection}" does not belong to '
|
|
145
|
+
f'geometry "{geom}".')
|
|
146
|
+
selections = geom.model.java.selection()
|
|
147
|
+
label = str(java.label())
|
|
148
|
+
if sum(str(selections.get(t).label()) == label
|
|
149
|
+
for t in selections.tags()) > 1:
|
|
150
|
+
raise ValueError(f'Several selections are named "{label}", so '
|
|
151
|
+
f'"{selection}" is ambiguous. Use sel.result(), '
|
|
152
|
+
'sel.layer() or sel.cumulative() for selections '
|
|
153
|
+
'COMSOL derives.')
|
|
154
|
+
return java
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
##################
|
|
158
|
+
# Feature access #
|
|
159
|
+
##################
|
|
160
|
+
|
|
161
|
+
def is_workplane(java) -> bool:
|
|
162
|
+
"""Tells whether a Java node is a geometry work plane."""
|
|
163
|
+
return (java is not None and hasattr(java, 'getType')
|
|
164
|
+
and str(java.getType()) == 'WorkPlane')
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def feature_container(parent: Node):
|
|
168
|
+
"""Returns the Java feature list that new features of `parent` go into."""
|
|
169
|
+
java = parent.java
|
|
170
|
+
if java is None:
|
|
171
|
+
raise LookupError(f'Node "{parent}" does not exist.')
|
|
172
|
+
if is_workplane(java):
|
|
173
|
+
return java.geom().feature()
|
|
174
|
+
if len(parent.path) == 2 and parent.path[0] == 'geometries':
|
|
175
|
+
return java.feature()
|
|
176
|
+
raise TypeError(f'Node "{parent}" is neither a geometry nor a work plane.')
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def labels(container) -> set[str]:
|
|
180
|
+
"""Returns the labels of all members of a Java feature list."""
|
|
181
|
+
return {str(container.get(tag).label()) for tag in container.tags()}
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def selection_labels(model, exclude_prefix: str = None) -> set[str]:
|
|
185
|
+
"""
|
|
186
|
+
Returns the labels of all selections in the model.
|
|
187
|
+
|
|
188
|
+
This includes selections derived from geometry features (result
|
|
189
|
+
selections, geometry-sequence selections), which MPh lists under the
|
|
190
|
+
feature's label. Tags starting with `exclude_prefix` are left out.
|
|
191
|
+
"""
|
|
192
|
+
selections = model.java.selection()
|
|
193
|
+
found = set()
|
|
194
|
+
for tag in selections.tags():
|
|
195
|
+
if exclude_prefix and str(tag).startswith(exclude_prefix):
|
|
196
|
+
continue
|
|
197
|
+
found.add(str(selections.get(tag).label()))
|
|
198
|
+
return found
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def pick_label(name: str | None, default: str, taken: set[str]) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Returns the label for a new node.
|
|
204
|
+
|
|
205
|
+
A label given by the user must be unique, because MPh finds nodes by
|
|
206
|
+
label. An automatic label that clashes gets a number appended.
|
|
207
|
+
"""
|
|
208
|
+
if name is not None:
|
|
209
|
+
if name in taken:
|
|
210
|
+
raise ValueError(f'The name "{name}" is already in use.')
|
|
211
|
+
return name
|
|
212
|
+
label, n = default, 2
|
|
213
|
+
while label in taken:
|
|
214
|
+
label = f'{default} ({n})'
|
|
215
|
+
n += 1
|
|
216
|
+
return label
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def create_java(container, group: str, type: str, name: str | None,
|
|
220
|
+
taken: set[str], tags=None, default: str = None,
|
|
221
|
+
args: tuple = ()):
|
|
222
|
+
"""
|
|
223
|
+
Creates a Java feature in `container` with a unique tag and label.
|
|
224
|
+
|
|
225
|
+
Does not use MPh's `Node.create()`: that function finds the new node by
|
|
226
|
+
label and may retag a different, existing node when labels clash.
|
|
227
|
+
`tags` is the list whose `uniquetag()` is used (default: `container`),
|
|
228
|
+
`default` replaces COMSOL's automatic label, and `args` go between tag
|
|
229
|
+
and type (e.g. the geometry tag of a coordinate system). Returns
|
|
230
|
+
`(tag, label)`.
|
|
231
|
+
"""
|
|
232
|
+
if name is not None:
|
|
233
|
+
pick_label(name, name, taken) # fail before creating
|
|
234
|
+
pattern = tag_pattern([group, '?', type]).rstrip('*')
|
|
235
|
+
tag = str((tags or container).uniquetag(pattern))
|
|
236
|
+
container.create(tag, *args, type)
|
|
237
|
+
java = container.get(tag)
|
|
238
|
+
label = pick_label(name, default or str(java.label()), taken)
|
|
239
|
+
java.label(label)
|
|
240
|
+
return tag, label
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
class WorkPlaneNode(Node):
|
|
244
|
+
"""
|
|
245
|
+
MPh node that also resolves features inside a geometry work plane.
|
|
246
|
+
|
|
247
|
+
MPh looks up child nodes in `java.feature()`, but the 2D features of a
|
|
248
|
+
work plane live in `java.geom().feature()`. This subclass resolves,
|
|
249
|
+
lists, creates and removes those; everything else is plain MPh.
|
|
250
|
+
"""
|
|
251
|
+
|
|
252
|
+
def _workplane_parent(self):
|
|
253
|
+
if self.is_root() or self.is_group():
|
|
254
|
+
return None
|
|
255
|
+
java = self.parent().java
|
|
256
|
+
return java if is_workplane(java) else None
|
|
257
|
+
|
|
258
|
+
@property
|
|
259
|
+
def java(self):
|
|
260
|
+
workplane = self._workplane_parent()
|
|
261
|
+
if workplane is None:
|
|
262
|
+
return super().java
|
|
263
|
+
container = workplane.geom().feature()
|
|
264
|
+
for tag in container.tags():
|
|
265
|
+
member = container.get(tag)
|
|
266
|
+
if self.name() == escape(member.label()):
|
|
267
|
+
return member
|
|
268
|
+
return None
|
|
269
|
+
|
|
270
|
+
def children(self) -> list[Node]:
|
|
271
|
+
java = self.java
|
|
272
|
+
if is_workplane(java):
|
|
273
|
+
container = java.geom().feature()
|
|
274
|
+
return [self/escape(container.get(tag).label())
|
|
275
|
+
for tag in container.tags()]
|
|
276
|
+
return super().children()
|
|
277
|
+
|
|
278
|
+
def create(self, *arguments, name: str = None) -> Node:
|
|
279
|
+
if is_workplane(self.java) and arguments:
|
|
280
|
+
from .geometry import feature
|
|
281
|
+
return feature(self, *arguments[:1], name=name)
|
|
282
|
+
return super().create(*arguments, name=name)
|
|
283
|
+
|
|
284
|
+
def remove(self):
|
|
285
|
+
workplane = self._workplane_parent()
|
|
286
|
+
if workplane is None:
|
|
287
|
+
return super().remove()
|
|
288
|
+
if not self.exists():
|
|
289
|
+
raise LookupError(f'Node "{self}" does not exist in model tree.')
|
|
290
|
+
workplane.geom().feature().remove(self.tag())
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def child_node(parent: Node, label: str, cls: type = Node) -> Node:
|
|
294
|
+
"""Returns the node for the child of `parent` with the given label."""
|
|
295
|
+
return cls(parent.model, join((*parent.path, label)))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def check_tag(node: Node, tag: str):
|
|
299
|
+
"""Verifies that the node resolves to the Java object just created."""
|
|
300
|
+
if node.tag() != tag:
|
|
301
|
+
raise RuntimeError(f'Node "{node}" resolves to tag "{node.tag()}", '
|
|
302
|
+
f'expected "{tag}".')
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
##############
|
|
306
|
+
# Properties #
|
|
307
|
+
##############
|
|
308
|
+
|
|
309
|
+
def is_selection_input(java, name: str) -> bool:
|
|
310
|
+
"""Tells whether a feature property is an input selection."""
|
|
311
|
+
try:
|
|
312
|
+
return str(java.getValueType(name)) == 'Selection'
|
|
313
|
+
except Exception:
|
|
314
|
+
return False
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def convert(value):
|
|
318
|
+
"""Converts a Python value into something MPh's `cast()` accepts."""
|
|
319
|
+
if isinstance(value, Node):
|
|
320
|
+
return value.tag()
|
|
321
|
+
if isinstance(value, numpy.generic):
|
|
322
|
+
return value.item()
|
|
323
|
+
if isinstance(value, numpy.ndarray):
|
|
324
|
+
value = value.tolist()
|
|
325
|
+
if isinstance(value, (list, tuple)):
|
|
326
|
+
items = [v.tag() if isinstance(v, Node) else v for v in value]
|
|
327
|
+
if any(isinstance(v, (list, tuple, numpy.ndarray)) for v in items):
|
|
328
|
+
return [vector(row) for row in items]
|
|
329
|
+
if any(isinstance(v, bool) for v in items):
|
|
330
|
+
return list(items)
|
|
331
|
+
return vector(items)
|
|
332
|
+
return value
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def value_type(java, name: str) -> str | None:
|
|
336
|
+
"""Returns COMSOL's data type for a property, if it has one."""
|
|
337
|
+
try:
|
|
338
|
+
return str(java.getValueType(name))
|
|
339
|
+
except Exception:
|
|
340
|
+
return None
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def allowed_values(java, name: str) -> list[str]:
|
|
344
|
+
"""Returns the values COMSOL accepts for a property, if enumerated."""
|
|
345
|
+
try:
|
|
346
|
+
return [str(v) for v in java.getAllowedPropertyValues(name)]
|
|
347
|
+
except Exception:
|
|
348
|
+
return []
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def type_name(java) -> str:
|
|
352
|
+
"""
|
|
353
|
+
Names a Java object for error messages.
|
|
354
|
+
|
|
355
|
+
Features have `getType()`; physics property groups do not, so they are
|
|
356
|
+
named by class and tag, e.g. `PhysicsPropClient (ShapeProperty)`.
|
|
357
|
+
"""
|
|
358
|
+
try:
|
|
359
|
+
return str(java.getType())
|
|
360
|
+
except Exception:
|
|
361
|
+
pass
|
|
362
|
+
name = str(java.getClass().getSimpleName())
|
|
363
|
+
try:
|
|
364
|
+
return f'{name} ({java.tag()})'
|
|
365
|
+
except Exception:
|
|
366
|
+
return name
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def set_property(java, name: str, value):
|
|
370
|
+
"""
|
|
371
|
+
Sets a property.
|
|
372
|
+
|
|
373
|
+
Unknown names raise `ValueError` with a suggestion. Some switches are
|
|
374
|
+
stored as the strings `'on'`/`'off'` and reject a boolean (`sellayer`),
|
|
375
|
+
while others accept one (`selresult`), so a boolean that COMSOL refuses
|
|
376
|
+
is retried as `'on'`/`'off'`. Objects without a property list, such as
|
|
377
|
+
variables, or with an empty one, such as a new material property group
|
|
378
|
+
(it lists only what was set and takes any name), raise COMSOL's own
|
|
379
|
+
error.
|
|
380
|
+
"""
|
|
381
|
+
try:
|
|
382
|
+
java.set(name, cast(convert(value)))
|
|
383
|
+
return
|
|
384
|
+
except Exception as error:
|
|
385
|
+
if not hasattr(java, 'properties'):
|
|
386
|
+
raise
|
|
387
|
+
known = [str(p) for p in java.properties()]
|
|
388
|
+
if not known:
|
|
389
|
+
raise
|
|
390
|
+
if name not in known:
|
|
391
|
+
message = f'"{type_name(java)}" has no property "{name}".'
|
|
392
|
+
close = get_close_matches(name, known, n=3)
|
|
393
|
+
if close:
|
|
394
|
+
message += f' Did you mean {", ".join(repr(c) for c in close)}?'
|
|
395
|
+
raise ValueError(message) from error
|
|
396
|
+
if isinstance(value, bool) and value_type(java, name) == 'String':
|
|
397
|
+
try:
|
|
398
|
+
java.set(name, 'on' if value else 'off')
|
|
399
|
+
return
|
|
400
|
+
except Exception:
|
|
401
|
+
pass
|
|
402
|
+
allowed = allowed_values(java, name)
|
|
403
|
+
if allowed:
|
|
404
|
+
raise ValueError(
|
|
405
|
+
f'Property "{name}" of "{type_name(java)}" accepts '
|
|
406
|
+
f'{", ".join(repr(v) for v in allowed)}, not {value!r}.'
|
|
407
|
+
) from error
|
|
408
|
+
raise
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def set_properties(java, properties: dict, owner: Node = None,
|
|
412
|
+
container=None):
|
|
413
|
+
"""
|
|
414
|
+
Sets properties in the given order, skipping `None` values.
|
|
415
|
+
|
|
416
|
+
With `owner` (the geometry or work plane of a geometry feature) and its
|
|
417
|
+
Java feature list `container`, input selections such as `input2` are
|
|
418
|
+
assigned geometry objects and `contributeto` takes a cumulative
|
|
419
|
+
selection by node or name; otherwise every key is a plain property.
|
|
420
|
+
"""
|
|
421
|
+
for key, value in properties.items():
|
|
422
|
+
if value is None:
|
|
423
|
+
continue
|
|
424
|
+
if owner is not None and key == 'contributeto' and value != 'none':
|
|
425
|
+
if is_workplane(owner.java):
|
|
426
|
+
raise ValueError('Features inside a work plane cannot '
|
|
427
|
+
'contribute to a cumulative selection.')
|
|
428
|
+
set_property(java, key, cumulative_tag(geometry_of(owner), value))
|
|
429
|
+
elif owner is not None and is_selection_input(java, key):
|
|
430
|
+
set_input(owner, container, java, key, value)
|
|
431
|
+
else:
|
|
432
|
+
set_property(java, key, value)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
####################
|
|
436
|
+
# Input selections #
|
|
437
|
+
####################
|
|
438
|
+
|
|
439
|
+
def sequence_selection_tag(geom: Node, node: Node) -> str | None:
|
|
440
|
+
"""
|
|
441
|
+
Returns the feature tag of a geometry-sequence selection.
|
|
442
|
+
|
|
443
|
+
A selection feature such as `boxsel1` in geometry `geom1` shows up among
|
|
444
|
+
the model's selections as `geom1_boxsel1`. Returns `None` for any other
|
|
445
|
+
selection node (component selections, result selections).
|
|
446
|
+
"""
|
|
447
|
+
tag = node.tag()
|
|
448
|
+
gtag = geom.tag()
|
|
449
|
+
for ftag in geom.java.feature().tags():
|
|
450
|
+
if f'{gtag}_{ftag}' == tag:
|
|
451
|
+
return str(ftag)
|
|
452
|
+
return None
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
def resolve_object(container, ref: str) -> str:
|
|
456
|
+
"""Resolves an object reference given as tag or label to a tag."""
|
|
457
|
+
tags = [str(t) for t in container.tags()]
|
|
458
|
+
if ref in tags:
|
|
459
|
+
return ref
|
|
460
|
+
for tag in tags:
|
|
461
|
+
if str(container.get(tag).label()) == ref:
|
|
462
|
+
return tag
|
|
463
|
+
if '(' in ref and ref.split('(')[0] in tags:
|
|
464
|
+
return ref # array object, e.g. arr1(1,1)
|
|
465
|
+
raise LookupError(f'No geometry object with tag or name "{ref}".')
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
INPUT_MODES = {
|
|
469
|
+
('Delete', 'input'): 'objects',
|
|
470
|
+
('Fillet3D', 'edge'): 'all', ('Chamfer3D', 'edge'): 'all',
|
|
471
|
+
('Fillet', 'point'): 'all', ('Chamfer', 'point'): 'all',
|
|
472
|
+
}
|
|
473
|
+
"""
|
|
474
|
+
How inputs that start at an entity level take geometry objects:
|
|
475
|
+
`'objects'` switches the input to objects (a Delete removes whole
|
|
476
|
+
objects), `'all'` selects every entity of the objects (the edges or
|
|
477
|
+
points a fillet rounds). Other entity-level inputs refuse objects.
|
|
478
|
+
"""
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def selection_source(owner: Node, ref: Node):
|
|
482
|
+
"""
|
|
483
|
+
Returns `(tag, level, kind)` for a selection node used as an input.
|
|
484
|
+
|
|
485
|
+
`owner` is the geometry or work plane of the feature taking the input.
|
|
486
|
+
`kind` is `'cumulative'` for a node from `sel.cumulative()` and
|
|
487
|
+
`'sequence'` for a selection in the geometry or work plane sequence,
|
|
488
|
+
given by its derived node or by the feature node itself (object-level
|
|
489
|
+
and work-plane selections). `level` is the entity dimension, -1 for
|
|
490
|
+
objects. Returns `None` for a node that is no selection; raises for a
|
|
491
|
+
selection that cannot be an input here.
|
|
492
|
+
"""
|
|
493
|
+
if (ref.path[0] == 'geometries' and len(ref.path) >= 4
|
|
494
|
+
and not isinstance(ref, WorkPlaneNode)):
|
|
495
|
+
ref = WorkPlaneNode(ref.model, join(ref.path)) # resolves in a plane
|
|
496
|
+
if is_workplane(owner.java):
|
|
497
|
+
if ref.path[0] == 'geometries' and ref.parent() == owner:
|
|
498
|
+
if is_selection_feature(ref.java):
|
|
499
|
+
return ref.tag(), sequence_level(owner, ref.tag()), \
|
|
500
|
+
'sequence'
|
|
501
|
+
return None
|
|
502
|
+
if (ref.path[0] == 'geometries' and len(ref.path) >= 4
|
|
503
|
+
and is_selection_feature(ref.java)):
|
|
504
|
+
raise ValueError(f'Selection "{ref}" belongs to "{ref.parent()}", '
|
|
505
|
+
f'not to the work plane "{owner}".')
|
|
506
|
+
geom = geometry_of(owner)
|
|
507
|
+
try:
|
|
508
|
+
found = selection_source(geom, ref)
|
|
509
|
+
except TypeError:
|
|
510
|
+
raise TypeError(f'Selection "{ref}" cannot be used in a work '
|
|
511
|
+
'plane; use a selection made in that plane or '
|
|
512
|
+
'geometry objects.') from None
|
|
513
|
+
if found is not None:
|
|
514
|
+
raise ValueError(f'Selection "{ref}" belongs to "{geom}", not to '
|
|
515
|
+
'the work plane.')
|
|
516
|
+
return None
|
|
517
|
+
geom = owner
|
|
518
|
+
if ref.path[0] == 'selections':
|
|
519
|
+
found = find_cumulative(geom, ref)
|
|
520
|
+
if found is not None:
|
|
521
|
+
return (*found, 'cumulative')
|
|
522
|
+
ftag = sequence_selection_tag(geom, ref)
|
|
523
|
+
if ftag is None:
|
|
524
|
+
raise TypeError(
|
|
525
|
+
f'Selection "{ref}" cannot be used as input of a geometry '
|
|
526
|
+
"operation. Use a selection made with where='geometry', a "
|
|
527
|
+
'cumulative selection of this geometry, or geometry objects.')
|
|
528
|
+
return ftag, sequence_level(geom, ftag), 'sequence'
|
|
529
|
+
if ref.path[0] == 'geometries' and is_selection_feature(ref.java):
|
|
530
|
+
if len(ref.path) == 3 and geometry_of(ref) == geom:
|
|
531
|
+
return ref.tag(), sequence_level(geom, ref.tag()), 'sequence'
|
|
532
|
+
where = 'the work plane ' if len(ref.path) > 3 else ''
|
|
533
|
+
raise ValueError(f'Selection "{ref}" belongs to {where}'
|
|
534
|
+
f'"{ref.parent()}", not to "{geom}".')
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def is_selection_feature(java) -> bool:
|
|
539
|
+
"""Tells whether a Java geometry feature is a selection, e.g. a box."""
|
|
540
|
+
return (java is not None and hasattr(java, 'getType')
|
|
541
|
+
and str(java.getType()).endswith('Selection'))
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
def sequence_level(parent: Node, ftag: str) -> int:
|
|
545
|
+
"""
|
|
546
|
+
Returns the entity level of a sequence selection, -1 for objects.
|
|
547
|
+
|
|
548
|
+
In a geometry it is read from the derived selection, not `entitydim`:
|
|
549
|
+
an Adjacent selection keeps its input level there and an Explicit one
|
|
550
|
+
has none. Object-level selections derive one selection per level
|
|
551
|
+
instead, so there is none under the plain tag. A work plane derives
|
|
552
|
+
nothing; its own selection list reports the level instead.
|
|
553
|
+
"""
|
|
554
|
+
if is_workplane(parent.java):
|
|
555
|
+
selections = parent.java.geom().selection()
|
|
556
|
+
if ftag not in [str(t) for t in selections.tags()]:
|
|
557
|
+
raise LookupError(f'Work plane "{parent}" lists no selection '
|
|
558
|
+
f'"{ftag}".')
|
|
559
|
+
levels = [int(d) for d in selections.get(ftag).dimension()]
|
|
560
|
+
return levels[0] if levels else -1
|
|
561
|
+
selections = parent.model.java.selection()
|
|
562
|
+
derived = f'{parent.tag()}_{ftag}'
|
|
563
|
+
if derived in [str(t) for t in selections.tags()]:
|
|
564
|
+
levels = [int(d) for d in selections.get(derived).dimension()]
|
|
565
|
+
if levels:
|
|
566
|
+
return levels[0]
|
|
567
|
+
return -1
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def set_input(owner: Node, container, java, key: str, value):
|
|
571
|
+
"""
|
|
572
|
+
Assigns geometry objects or one selection to the input `key` of `java`.
|
|
573
|
+
|
|
574
|
+
`owner` is the geometry or work plane the feature belongs to,
|
|
575
|
+
`container` its Java feature list. The value is geometry objects
|
|
576
|
+
(nodes, tags, labels), or one selection: made with `where='geometry'`
|
|
577
|
+
(any level, including objects) or a cumulative selection. Everything is
|
|
578
|
+
checked before the input changes. See `INPUT_MODES` for inputs that
|
|
579
|
+
start at an entity level.
|
|
580
|
+
"""
|
|
581
|
+
selection = java.selection(key)
|
|
582
|
+
mode = INPUT_MODES.get((type_name(java), key))
|
|
583
|
+
levels = [int(d) for d in selection.dimension()]
|
|
584
|
+
dim = levels[0] if levels else None
|
|
585
|
+
refs = value if isinstance(value, (list, tuple)) else [value]
|
|
586
|
+
sources, objects = [], []
|
|
587
|
+
for ref in refs:
|
|
588
|
+
source = selection_source(owner, ref) if isinstance(ref, Node) \
|
|
589
|
+
else None
|
|
590
|
+
if source is not None:
|
|
591
|
+
sources.append(source)
|
|
592
|
+
elif isinstance(ref, Node):
|
|
593
|
+
if ref.parent() != owner:
|
|
594
|
+
raise ValueError(f'Node "{ref}" does not belong to "{owner}".')
|
|
595
|
+
objects.append(ref.tag())
|
|
596
|
+
elif isinstance(ref, str):
|
|
597
|
+
objects.append(resolve_object(container, ref))
|
|
598
|
+
else:
|
|
599
|
+
raise TypeError(f'Cannot use {ref!r} as a geometry input.')
|
|
600
|
+
if sources:
|
|
601
|
+
if len(sources) > 1 or objects:
|
|
602
|
+
raise ValueError('An input takes either geometry objects or '
|
|
603
|
+
'exactly one selection.')
|
|
604
|
+
tag, level, kind = sources[0]
|
|
605
|
+
if mode == 'objects':
|
|
606
|
+
if level < 0 or kind == 'cumulative':
|
|
607
|
+
selection.init() # removes whole objects
|
|
608
|
+
else:
|
|
609
|
+
selection.init(level) # removes these entities only
|
|
610
|
+
elif dim is None:
|
|
611
|
+
if kind == 'sequence' and level >= 0:
|
|
612
|
+
selection.init(level)
|
|
613
|
+
else:
|
|
614
|
+
if level != dim:
|
|
615
|
+
raise ValueError(f'Input "{key}" takes a selection of level '
|
|
616
|
+
f'{dim}, not {level} (-1: objects).')
|
|
617
|
+
selection.init(dim)
|
|
618
|
+
selection.named(tag)
|
|
619
|
+
elif mode == 'objects':
|
|
620
|
+
selection.init()
|
|
621
|
+
selection.set(objects)
|
|
622
|
+
elif dim is None:
|
|
623
|
+
selection.set(objects)
|
|
624
|
+
elif mode == 'all':
|
|
625
|
+
selection.init(dim)
|
|
626
|
+
for tag in objects:
|
|
627
|
+
selection.all(tag)
|
|
628
|
+
else:
|
|
629
|
+
raise TypeError(f'Input "{key}" takes entities of level {dim}, not '
|
|
630
|
+
"objects; pass a selection made with "
|
|
631
|
+
"where='geometry'.")
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
#####################
|
|
635
|
+
# Result selections #
|
|
636
|
+
#####################
|
|
637
|
+
|
|
638
|
+
def result_tag(geom: Node, feature: Node, entity: str) -> str:
|
|
639
|
+
"""Returns the tag of a feature's result selection for an entity."""
|
|
640
|
+
return f'{geom.tag()}_{feature.tag()}_{RESULT_SUFFIX[entity]}'
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def layer_tag(geom: Node, feature: Node, index: int | None) -> str:
|
|
644
|
+
"""
|
|
645
|
+
Returns the tag of the selection for one of a feature's layers.
|
|
646
|
+
|
|
647
|
+
Layers are numbered from 1 in the order of the `layername` property;
|
|
648
|
+
`index=None` is the core, the part that is not in any layer. COMSOL
|
|
649
|
+
creates these selections for domains only.
|
|
650
|
+
"""
|
|
651
|
+
part = 'core' if index is None else f'layer{index}'
|
|
652
|
+
return f'{geom.tag()}_{feature.tag()}_{part}'
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
def cumulative_tags(geom: Node) -> dict[str, str]:
|
|
656
|
+
"""
|
|
657
|
+
Returns the cumulative selections of a geometry, label → tag.
|
|
658
|
+
|
|
659
|
+
They live in the geometry's own selection list, which lists each one
|
|
660
|
+
also per level (`csel1.dom`, ...) and the object selections of the
|
|
661
|
+
features contributing to them (`cyl1`, `cyl1.dom`, ...). Cumulative
|
|
662
|
+
selections have no type, only their client class tells them apart.
|
|
663
|
+
"""
|
|
664
|
+
selections = geom.java.selection()
|
|
665
|
+
found = {}
|
|
666
|
+
for tag in selections.tags():
|
|
667
|
+
member = selections.get(tag)
|
|
668
|
+
if ('.' not in str(tag) and str(member.getClass().getSimpleName())
|
|
669
|
+
== 'CumulativeSelectionClient'):
|
|
670
|
+
found[str(member.label())] = str(tag)
|
|
671
|
+
return found
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
def find_cumulative(geom: Node, node: Node):
|
|
675
|
+
"""
|
|
676
|
+
Returns `(tag, level)` if `node` came from `sel.cumulative()` for `geom`.
|
|
677
|
+
|
|
678
|
+
Such a node is a component Union of one selection COMSOL derived from a
|
|
679
|
+
cumulative selection. Returns `None` for any other node.
|
|
680
|
+
"""
|
|
681
|
+
dim = sdim(geom)
|
|
682
|
+
levels = {'dom': dim, 'bnd': dim - 1, 'edg': 1, 'pnt': 0}
|
|
683
|
+
gtag = geom.tag()
|
|
684
|
+
derived = {f'{gtag}_{tag}_{suffix}': (tag, level)
|
|
685
|
+
for tag in cumulative_tags(geom).values()
|
|
686
|
+
for suffix, level in levels.items()}
|
|
687
|
+
java = node.java
|
|
688
|
+
if (java is None or not hasattr(java, 'getType')
|
|
689
|
+
or str(java.getType()) != 'Union'):
|
|
690
|
+
return None
|
|
691
|
+
inputs = [str(t) for t in java.getStringArray('input')]
|
|
692
|
+
if len(inputs) == 1 and inputs[0] in derived:
|
|
693
|
+
return derived[inputs[0]]
|
|
694
|
+
return None
|
|
695
|
+
|
|
696
|
+
|
|
697
|
+
def cumulative_tag(geom: Node, value) -> str:
|
|
698
|
+
"""Returns the tag of a cumulative selection given by node, label or tag."""
|
|
699
|
+
found = cumulative_tags(geom)
|
|
700
|
+
if isinstance(value, Node):
|
|
701
|
+
match = find_cumulative(geom, value)
|
|
702
|
+
if match is None:
|
|
703
|
+
raise ValueError(f'"{value}" is not a cumulative selection of '
|
|
704
|
+
f'"{geom}"; use one returned by '
|
|
705
|
+
'sel.cumulative().')
|
|
706
|
+
return match[0]
|
|
707
|
+
value = str(value)
|
|
708
|
+
if value in found:
|
|
709
|
+
return found[value]
|
|
710
|
+
if value in found.values():
|
|
711
|
+
return value
|
|
712
|
+
raise ValueError(f'Geometry "{geom}" has no cumulative selection '
|
|
713
|
+
f'"{value}". Known: {sorted(found) or "none"}. Create '
|
|
714
|
+
'it first with sel.cumulative(..., create=True).')
|
|
715
|
+
|
|
716
|
+
|
|
717
|
+
def names(values: Iterable) -> list[str]:
|
|
718
|
+
"""Returns the tags of selection nodes, passing strings through."""
|
|
719
|
+
return [v.tag() if isinstance(v, Node) else str(v) for v in values]
|