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/_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]