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/_sel.py ADDED
@@ -0,0 +1,487 @@
1
+ """Geometry-based selections; documented in `mphkit.sel`."""
2
+ from __future__ import annotations
3
+
4
+ from mph import Node
5
+ from mph.node import escape
6
+
7
+ from . import _comsol
8
+ from .geometry import feature
9
+
10
+ WHERE = ('component', 'geometry')
11
+
12
+
13
+ def _where(parent: Node, where: str | None) -> str:
14
+ """
15
+ Resolves `where`: by default the component for a geometry and the
16
+ plane's own sequence for a work plane, which has no component
17
+ selections.
18
+ """
19
+ workplane = _comsol.is_workplane(parent.java)
20
+ if where is None:
21
+ return 'geometry' if workplane else 'component'
22
+ if where not in WHERE:
23
+ raise ValueError(f'where must be one of {WHERE}, not {where!r}.')
24
+ if workplane and where == 'component':
25
+ raise ValueError('A work plane has no component selections; its '
26
+ 'selections are made in the plane (leave out '
27
+ 'where).')
28
+ return where
29
+
30
+
31
+ def _level(geom: Node, entity: str, where: str | None) -> int:
32
+ """Maps an entity name to `entitydim`; `'object'` is -1 (geometry only)."""
33
+ where = _where(geom, where)
34
+ if entity == 'object':
35
+ if where != 'geometry':
36
+ raise ValueError("The 'object' level is for inputs of geometry "
37
+ "operations; use it with where='geometry'.")
38
+ return -1
39
+ return _comsol.entity_dim(geom, entity)
40
+
41
+
42
+ def _create(geom: Node, type: str, where: str, name: str | None,
43
+ properties: dict, default: str = None) -> Node:
44
+ """Creates a selection of `type` (component names, e.g. 'Box')."""
45
+ where = _where(geom, where)
46
+ model = geom.model
47
+ if _comsol.is_workplane(geom.java):
48
+ # A work plane derives no model-level selections: the feature in
49
+ # the plane is the input for later operations there.
50
+ return feature(geom, f'{type}Selection', name=name, **properties)
51
+ if where == 'geometry':
52
+ node = feature(geom, f'{type}Selection', name=name, **properties)
53
+ if properties.get('entitydim') == -1:
54
+ # Objects: COMSOL derives four same-labeled selections, none of
55
+ # them for physics. The feature itself is the input to use.
56
+ return node
57
+ derived = model/'selections'/escape(node.name())
58
+ _comsol.check_tag(derived, f'{geom.tag()}_{node.tag()}')
59
+ return derived
60
+ container = _comsol.component_of(geom).selection()
61
+ taken = _comsol.selection_labels(model)
62
+ tag, label = _comsol.create_java(container, 'selections', type, name,
63
+ taken, tags=model.java.selection(),
64
+ default=default)
65
+ node = model/'selections'/escape(label)
66
+ try:
67
+ _comsol.check_tag(node, tag)
68
+ java = container.get(tag)
69
+ _comsol.set_properties(java, properties)
70
+ except Exception:
71
+ container.remove(tag)
72
+ raise
73
+ return node
74
+
75
+
76
+ def _inputs(geom: Node, where: str, values) -> list[str]:
77
+ """Returns selection tags for the inputs of a set operation."""
78
+ where = _where(geom, where)
79
+ values = [values] if isinstance(values, (Node, str)) else list(values)
80
+ if where == 'component':
81
+ for value in values:
82
+ if isinstance(value, Node) and value.path[0] == 'geometries':
83
+ raise TypeError(f'"{value}" is a selection in the geometry '
84
+ "sequence; use where='geometry' with it.")
85
+ return _comsol.names(values)
86
+ tags = []
87
+ for value in values:
88
+ if isinstance(value, Node):
89
+ source = _comsol.selection_source(geom, value)
90
+ if source is None:
91
+ raise TypeError(f'"{value}" is not a selection.')
92
+ if source[2] == 'cumulative':
93
+ raise TypeError(
94
+ f'Selection "{value}" is a cumulative selection; set '
95
+ "operations with where='geometry' take selections made "
96
+ "with where='geometry'.")
97
+ tags.append(source[0])
98
+ else:
99
+ tags.append(str(value))
100
+ return tags
101
+
102
+
103
+ def _bounds(geom: Node, **ranges) -> dict:
104
+ """Turns x/y/z ranges into Box properties; a scalar is a plane."""
105
+ dim = _comsol.parent_dim(geom)
106
+ properties = {}
107
+ for axis, value in ranges.items():
108
+ if value is None:
109
+ continue
110
+ if 'xyz'.index(axis) >= dim:
111
+ raise ValueError(f'A {dim}D geometry has no {axis} coordinate.')
112
+ if isinstance(value, (list, tuple)):
113
+ low, high = value
114
+ else:
115
+ low = high = value
116
+ properties[f'{axis}min'] = low
117
+ properties[f'{axis}max'] = high
118
+ return properties
119
+
120
+
121
+ def box(geom: Node, entity: str, /, x=None, y=None, z=None, *,
122
+ condition: str = 'inside', where: str | None = None,
123
+ name: str = None, **properties) -> Node:
124
+ """
125
+ Selects the entities inside a box.
126
+
127
+ `x`, `y`, `z` are `(min, max)` ranges; a single value such as `z=0`
128
+ selects on that plane. Omitted coordinates are unbounded. With
129
+ `where='geometry'` (or in a work plane), `entity` may be `'object'` to
130
+ select whole objects as input of a geometry operation. `geom` may also
131
+ be a work plane, in the plane's coordinates, e.g. to fillet one corner
132
+ there (see `mphkit.sel` for what such selections can be used for).
133
+
134
+ `condition` defaults to `'inside'` (entity entirely inside the box).
135
+ This differs from COMSOL's default `'intersects'`, which on a plane
136
+ would also pick every face touching it. Other values: `'intersects'`,
137
+ `'allvertices'`, `'somevertex'`.
138
+ """
139
+ properties = {'entitydim': _level(geom, entity, where),
140
+ 'condition': condition,
141
+ **_bounds(geom, x=x, y=y, z=z), **properties}
142
+ return _create(geom, 'Box', where, name, properties)
143
+
144
+
145
+ def ball(geom: Node, entity: str, /, center, r, *,
146
+ condition: str = 'inside', where: str | None = None,
147
+ name: str = None, **properties) -> Node:
148
+ """
149
+ Selects the entities inside a ball of radius `r` around `center`.
150
+
151
+ `condition` defaults to `'inside'`, see `box()`.
152
+ """
153
+ dim = _comsol.parent_dim(geom)
154
+ if len(center) != dim:
155
+ raise ValueError(f'center needs {dim} coordinates.')
156
+ position = {f'pos{a}': c for a, c in zip('xyz', center)}
157
+ properties = {'entitydim': _level(geom, entity, where),
158
+ 'condition': condition, 'r': r, **position, **properties}
159
+ return _create(geom, 'Ball', where, name, properties)
160
+
161
+
162
+ def cylinder(geom: Node, entity: str, /, pos, r, *, axis=None, top=None,
163
+ bottom=None, rin=None, condition: str = 'inside',
164
+ where: str | None = None, name: str = None,
165
+ **properties) -> Node:
166
+ """
167
+ Selects the entities inside a cylinder (3D).
168
+
169
+ `pos` is the center of the base and `axis` the direction, `'x'`, `'y'`,
170
+ `'z'` (default) or a vector. `top` and `bottom` are measured from `pos`
171
+ along the axis; left out, the cylinder is unbounded. With `rin` it is a
172
+ shell, e.g. `r=2.1, rin=1.9` picks the side faces of an r=2 cylinder.
173
+ `condition` defaults to `'inside'`, see `box()`. In 2D use `disk()`.
174
+ """
175
+ _comsol.check_not_workplane(geom, 'sel.cylinder')
176
+ if _comsol.sdim(geom) != 3:
177
+ raise ValueError('sel.cylinder needs a 3D geometry; use sel.disk in '
178
+ '2D.')
179
+ _comsol.check_vector(geom, 'pos', pos)
180
+ if axis is not None and not isinstance(axis, str):
181
+ _comsol.check_vector(geom, 'axis', axis)
182
+ properties = {'entitydim': _level(geom, entity, where),
183
+ 'condition': condition, 'pos': pos, 'r': r, 'rin': rin,
184
+ 'top': top, 'bottom': bottom,
185
+ **_comsol.axis_properties(axis), **properties}
186
+ return _create(geom, 'Cylinder', where, name, properties)
187
+
188
+
189
+ def disk(geom: Node, entity: str, /, center, r, *, rin=None,
190
+ condition: str = 'inside', where: str | None = None,
191
+ name: str = None, **properties) -> Node:
192
+ """
193
+ Selects the entities inside a disk of radius `r` around `center` (2D).
194
+
195
+ With `rin` it is a ring. `condition` defaults to `'inside'`, see
196
+ `box()`. In 3D use `cylinder()` or `ball()`.
197
+ """
198
+ if _comsol.parent_dim(geom) != 2:
199
+ raise ValueError('sel.disk needs a 2D geometry; use sel.cylinder or '
200
+ 'sel.ball in 3D.')
201
+ _comsol.check_vector(geom, 'center', center)
202
+ properties = {'entitydim': _level(geom, entity, where),
203
+ 'condition': condition, 'posx': center[0],
204
+ 'posy': center[1], 'r': r, 'rin': rin, **properties}
205
+ return _create(geom, 'Disk', where, name, properties)
206
+
207
+
208
+ def all_(geom: Node, entity: str, /, *, where: str | None = None,
209
+ name: str = None) -> Node:
210
+ """Selects all entities of one kind, e.g. all boundaries."""
211
+ properties = {'entitydim': _level(geom, entity, where),
212
+ 'condition': 'intersects'}
213
+ return _create(geom, 'Box', where, name, properties)
214
+
215
+
216
+ def union(geom: Node, entity: str, /, input, *, where: str | None = None,
217
+ name: str = None) -> Node:
218
+ """Selects the union of the `input` selections."""
219
+ properties = {'entitydim': _level(geom, entity, where),
220
+ 'input': _inputs(geom, where, input)}
221
+ return _create(geom, 'Union', where, name, properties)
222
+
223
+
224
+ def intersection(geom: Node, entity: str, /, input, *,
225
+ where: str | None = None, name: str = None) -> Node:
226
+ """Selects the entities that all `input` selections have in common."""
227
+ properties = {'entitydim': _level(geom, entity, where),
228
+ 'input': _inputs(geom, where, input)}
229
+ return _create(geom, 'Intersection', where, name, properties)
230
+
231
+
232
+ def difference(geom: Node, entity: str, /, add, subtract, *,
233
+ where: str | None = None, name: str = None) -> Node:
234
+ """Selects the entities in `add` that are not in `subtract`."""
235
+ properties = {'entitydim': _level(geom, entity, where),
236
+ 'add': _inputs(geom, where, add),
237
+ 'subtract': _inputs(geom, where, subtract)}
238
+ return _create(geom, 'Difference', where, name, properties)
239
+
240
+
241
+ def complement(geom: Node, entity: str, /, input, *,
242
+ where: str | None = None, name: str = None) -> Node:
243
+ """Selects all entities that are not in the `input` selections."""
244
+ properties = {'entitydim': _level(geom, entity, where),
245
+ 'input': _inputs(geom, where, input)}
246
+ return _create(geom, 'Complement', where, name, properties)
247
+
248
+
249
+ def adjacent(geom: Node, /, input, entity: str = 'boundary', *,
250
+ input_entity: str = 'domain', exterior: bool = True,
251
+ interior: bool = False, where: str | None = None,
252
+ name: str = None) -> Node:
253
+ """
254
+ Selects the entities of kind `entity` adjacent to the `input` selections.
255
+
256
+ For example the exterior boundaries of a domain selection. The inputs
257
+ are of kind `input_entity`.
258
+ """
259
+ properties = {'entitydim': _comsol.entity_dim(geom, input_entity),
260
+ 'outputdim': _comsol.entity_dim(geom, entity),
261
+ 'input': _inputs(geom, where, input),
262
+ 'exterior': exterior, 'interior': interior}
263
+ return _create(geom, 'Adjacent', where, name, properties)
264
+
265
+
266
+ def result(geom: Node, feature: Node, entity: str, /, *,
267
+ name: str = None) -> Node:
268
+ """
269
+ Selects the entities created by a geometry feature.
270
+
271
+ Turns on the feature's result selection (`selresult`) and returns a
272
+ named component selection that physics can use, without relying on
273
+ COMSOL's tag convention. Calling it again returns the same selection.
274
+ Because it switches on `selresult`, a Java export of the model shows
275
+ that setting on the feature; the geometry is not affected.
276
+ """
277
+ if len(feature.path) != 3 or _comsol.geometry_of(feature) != geom:
278
+ raise ValueError(f'"{feature}" is not a top-level feature of '
279
+ f'"{geom}".')
280
+ entity = _comsol.entity_name(geom, entity)
281
+ suffix = _comsol.RESULT_SUFFIX[entity]
282
+ java = feature.java
283
+ known = [str(p) for p in java.properties()]
284
+ if 'selresult' not in known:
285
+ raise ValueError(f'"{feature}" has no result selection.')
286
+ own = f'{geom.tag()}_{feature.tag()}'
287
+ clash = _comsol.selection_labels(geom.model, exclude_prefix=own)
288
+ if feature.name() in clash:
289
+ raise ValueError(f'A selection is already named "{feature.name()}"; '
290
+ 'rename the feature first.')
291
+ was_on = str(java.getString('selresult')) == 'on'
292
+ _comsol.set_property(java, 'selresult', True)
293
+ if 'selresultshow' in known:
294
+ show = str(java.getString('selresultshow'))
295
+ if not was_on:
296
+ java.set('selresultshow', suffix)
297
+ elif show not in (suffix, 'all'):
298
+ java.set('selresultshow', 'all')
299
+ derived = _comsol.result_tag(geom, feature, entity)
300
+ return _wrap(geom, derived, entity, name,
301
+ default=f'{feature.name()} ({entity})')
302
+
303
+
304
+ def layer(geom: Node, feature: Node, layer, /, *, name: str = None) -> Node:
305
+ """
306
+ Selects the domains of one layer of a geometry feature.
307
+
308
+ Layers are the shells a Block, Cylinder, Sphere or Rectangle can be
309
+ given (`layername`, `layer`, `layertop`, ...), typically a perfectly
310
+ matched layer. `layer` is a name from `layername`, its number counted
311
+ from 1, or `'core'` for the part outside every layer.
312
+
313
+ COMSOL derives these selections for domains only, and they survive
314
+ boolean operations on the feature. They are empty when the feature is
315
+ consumed, for instance as the subtracted object of a difference; a
316
+ `sel.box` over the same region is the alternative then. Calling this
317
+ again returns the same selection. Like `result()`, it switches a
318
+ setting on the feature (`sellayer`), which shows in a Java export.
319
+ """
320
+ if len(feature.path) != 3 or _comsol.geometry_of(feature) != geom:
321
+ raise ValueError(f'"{feature}" is not a top-level feature of '
322
+ f'"{geom}".')
323
+ java = feature.java
324
+ known = [str(p) for p in java.properties()]
325
+ if 'sellayer' not in known:
326
+ raise ValueError(f'"{feature}" does not support layers.')
327
+ names = [str(n) for n in java.getStringArray('layername')] \
328
+ if 'layername' in known else []
329
+ if isinstance(layer, str) and layer.lower() == 'core':
330
+ index, expected = None, 'Core'
331
+ elif isinstance(layer, bool) or not isinstance(layer, (int, str)):
332
+ raise TypeError(f'A layer is a name, a number or "core", '
333
+ f'not {layer!r}.')
334
+ elif isinstance(layer, int):
335
+ index = layer
336
+ expected = names[index - 1] if 0 < index <= len(names) else None
337
+ elif layer in names:
338
+ index = names.index(layer) + 1
339
+ expected = layer
340
+ else:
341
+ raise ValueError(f'"{feature}" has no layer named "{layer}". '
342
+ f'Known names: {names or "none"} (or a number, '
343
+ f'or "core").')
344
+ _comsol.set_property(java, 'sellayer', True)
345
+ derived = _comsol.layer_tag(geom, feature, index)
346
+ selections = geom.model.java.selection()
347
+ if derived not in [str(t) for t in selections.tags()]:
348
+ prefix = f'{geom.tag()}_{feature.tag()}_'
349
+ found = [str(t) for t in selections.tags()
350
+ if str(t).startswith(prefix) and 'layer' in str(t)
351
+ or str(t) == f'{prefix}core']
352
+ raise ValueError(f'"{feature}" has no layer selection "{derived}". '
353
+ f'Found: {found or "none"}.')
354
+ label = str(selections.get(derived).label())
355
+ if expected and not label.startswith(expected):
356
+ raise RuntimeError(f'Selection "{derived}" is labeled "{label}", '
357
+ f'expected it to start with "{expected}". The '
358
+ 'COMSOL naming convention may have changed.')
359
+ return _wrap(geom, derived, 'domain', name,
360
+ default=f'{feature.name()} ({expected or index})')
361
+
362
+
363
+ def entities(geom: Node, selection: Node, /) -> list[int]:
364
+ """
365
+ Returns the entity numbers of a selection, in ascending order.
366
+
367
+ The numbers are at the selection's own level: an adjacent selection of
368
+ domains gives boundary numbers. Entity numbers change when the geometry
369
+ changes, so use them to check or inspect a model, not to set up physics.
370
+ The geometry must be built; a selection made with `where='geometry'`
371
+ changes the geometry sequence, so build again after creating one.
372
+ """
373
+ _comsol.check_not_workplane(geom, 'sel.entities')
374
+ _comsol.check_built(geom)
375
+ java = _comsol.check_selection(geom, selection)
376
+ return sorted(int(e) for e in java.entities())
377
+
378
+
379
+ def find(geom: Node, entity: str, /, x=None, y=None, z=None, *,
380
+ condition: str = 'inside') -> list[int]:
381
+ """
382
+ Returns the numbers of the entities inside a box, without a selection.
383
+
384
+ Takes the same arguments as `box()`, but removes the selection again
385
+ and returns the entity numbers. Meant for checks and lookups: entity
386
+ numbers change with the geometry, so physics should use `box()`. For a
387
+ lookup of another kind, create the selection, read it with `entities()`
388
+ and `remove()` it; leave out `name` to avoid label clashes.
389
+ """
390
+ _comsol.check_not_workplane(geom, 'sel.find')
391
+ node = box(geom, entity, x, y, z, condition=condition)
392
+ try:
393
+ return entities(geom, node)
394
+ finally:
395
+ _comsol.component_of(geom).selection().remove(node.tag())
396
+
397
+
398
+ def cumulative(geom: Node, group, entity: str, /, *, create: bool = False,
399
+ name: str = None) -> Node:
400
+ """
401
+ Selects the entities of a cumulative selection, COMSOL's way of
402
+ collecting what several features create.
403
+
404
+ In the COMSOL Desktop a feature's "Contribute to" list has a New
405
+ button that creates a cumulative selection; other features pick it
406
+ from the list, and physics offers it per level, e.g.
407
+ "holes (Boundary)". The same steps here:
408
+
409
+ ```python
410
+ holes = mk.sel.cumulative(geom, 'holes', 'domain', create=True) # New
411
+ mk.cylinder(geom, 1, 5, (x, 0, 0), contributeto=holes) # Contribute to
412
+ walls = mk.sel.cumulative(geom, 'holes', 'boundary') # holes (Boundary)
413
+ ```
414
+
415
+ `group` is the cumulative selection's name, or a node returned by this
416
+ function. `create=True` creates it and fails if it exists; otherwise it
417
+ must exist, so a typo raises instead of giving an empty selection.
418
+ Features take the node or the name as `contributeto`. It holds every
419
+ entity of the contributing objects and survives later Boolean
420
+ operations. Only top-level features of the geometry can contribute,
421
+ not features inside a work plane. Calling this again returns the same
422
+ selection.
423
+ """
424
+ _comsol.check_not_workplane(geom, 'sel.cumulative')
425
+ entity = _comsol.entity_name(geom, entity)
426
+ model = geom.model
427
+ selections = geom.java.selection()
428
+ known = _comsol.cumulative_tags(geom)
429
+ if create:
430
+ if not isinstance(group, str):
431
+ raise TypeError('create=True takes the name of a new cumulative '
432
+ f'selection, not {group!r}.')
433
+ if group == 'none':
434
+ raise ValueError('"none" means "no cumulative selection" in '
435
+ 'COMSOL; choose another name.')
436
+ if group in known:
437
+ raise ValueError(f'Cumulative selection "{group}" already exists '
438
+ f'in "{geom}".')
439
+ taken = _comsol.selection_labels(model)
440
+ if group in taken:
441
+ raise ValueError(f'A selection is already named "{group}".')
442
+ if name is not None:
443
+ _comsol.pick_label(name, name, taken)
444
+ tag = str(selections.uniquetag('csel'))
445
+ selections.create(tag, 'CumulativeSelection')
446
+ selections.get(tag).label(group)
447
+ else:
448
+ tag = _comsol.cumulative_tag(geom, group)
449
+ try:
450
+ derived = f'{geom.tag()}_{tag}_{_comsol.RESULT_SUFFIX[entity]}'
451
+ tags = [str(t) for t in model.java.selection().tags()]
452
+ if derived not in tags:
453
+ prefix = f'{geom.tag()}_{tag}_'
454
+ found = [t for t in tags if t.startswith(prefix)]
455
+ raise ValueError(f'Cumulative selection "{group}" has no '
456
+ f'selection "{derived}". Found: {found}.')
457
+ label = str(selections.get(tag).label())
458
+ return _wrap(geom, derived, entity, name,
459
+ default=f'{label} ({entity})')
460
+ except Exception:
461
+ if create:
462
+ selections.remove(tag)
463
+ raise
464
+
465
+
466
+ def _wrap(geom: Node, derived: str, entity: str, name: str | None,
467
+ default: str) -> Node:
468
+ """
469
+ Returns a component selection that stands for a derived selection.
470
+
471
+ MPh lists selections COMSOL derives from a geometry feature under that
472
+ feature's label, so several can share one name and none of them can be
473
+ addressed by path. A Union with a unique label and the derived tag as
474
+ its input can. An existing wrapper is found by its input, so renaming
475
+ the feature does not create a second one.
476
+ """
477
+ container = _comsol.component_of(geom).selection()
478
+ for tag in container.tags():
479
+ member = container.get(tag)
480
+ if (str(member.getType()) == 'Union'
481
+ and [str(t) for t in member.getStringArray('input')]
482
+ == [derived]):
483
+ return geom.model/'selections'/escape(member.label())
484
+ properties = {'entitydim': _comsol.entity_dim(geom, entity),
485
+ 'input': [derived]}
486
+ return _create(geom, 'Union', 'component', name, properties,
487
+ default=default)
mphkit/errors.py ADDED
@@ -0,0 +1,5 @@
1
+ """Exceptions raised by mphkit."""
2
+
3
+
4
+ class LicenseError(RuntimeError):
5
+ """Raised when a feature needs a COMSOL license that is not available."""