Skip to content

visualdynamics.viz.sensors

sensors

Sensor bodies drawn at their nodes: one mesh for every sensor a channel table places (core.sensors.place), so a model with hundreds of sensors is one actor rather than hundreds.

Each body is built about its own origin from its catalog size, turned into its frame and moved to its node, which is therefore the body's center. A triax's X, Y and Z faces carry their letters in black, as the sensors do; a uniaxial accelerometer or a force transducer carries a black arrow on each side along its sensing axis, pointing the positive way. The body is gray, the connector and the cable darker.

Bodies are true size where the geometry's units are defined. Where they are not, a millimeter cannot be placed, and the bodies are drawn at a size proportional to the model's instead. size multiplies either, about the node, so the node stays at the center.

The points are a VTK buffer of their own with a writable view, so an animation moves the bodies with their nodes by one write per frame (GeometryAnimator.follow).

Classes:

Name Description
SensorMesh

The sensors' mesh, and what an animation needs to move it.

Functions:

Name Description
body

A sensor's body in its own frame, in millimeters, centered on the

sensor_mesh

One mesh of every sensor's body at its node, in display units.

add_sensors

Draw the sensors a channel table places on a geometry.

Classes

SensorMesh dataclass

SensorMesh(mesh: Any, node_rows: ndarray, base: ndarray, points: Any, view: ndarray, sensors: list = list(), notes: list = list())

The sensors' mesh, and what an animation needs to move it.

node_rows is the geometry's node row of every point, base the points as drawn with every node at rest, and points and view the VTK buffer the mesh is drawn from and a writable view of it.

Functions:

body

body(entry: SensorModel, marks: bool = True)

A sensor's body in its own frame, in millimeters, centered on the origin: (points, triangles, parts), where a triangle's part is 'body', 'connector' (the plug and a force transducer's studs), 'cable', or a mark on the body: 'mark:X', 'mark:Y' and 'mark:Z' for a triax's letters, 'mark:shaft' and 'mark:head' for the arrow of a uniaxial accelerometer or a force transducer, left out when marks is False.

Source code in src/visualdynamics/viz/sensors.py
def body(entry: SensorModel, marks: bool = True):
    """A sensor's body in its own frame, in millimeters, centered on the
    origin: (points, triangles, parts), where a triangle's part is
    'body', 'connector' (the plug and a force transducer's studs),
    'cable', or a mark on the body: 'mark:X', 'mark:Y' and 'mark:Z' for
    a triax's letters, 'mark:shaft' and 'mark:head' for the arrow of a
    uniaxial accelerometer or a force transducer, left out when
    `marks` is False."""
    sx, sy, sz = entry.size
    outline = _outline(entry)
    pieces = []        # (points, triangles, part)
    points, triangles, _faces = _prism(outline, -sz / 2, sz / 2)
    pieces.append((points, triangles, 'body'))
    # the connector: a plug out of the middle of its face, stepping down
    # to its thread, and the cable beyond it
    sign = 1.0 if entry.connector == '+x' else -1.0
    reach = outline[:, 0].max() if sign > 0 else -outline[:, 0].min()
    diameter, length = entry.plug
    if length > 0:
        base = 0.45 * length
        pieces.append((*_cylinder(diameter / 2, reach, base, 12, 'x', sign),
                       'connector'))
        pieces.append((*_cylinder(0.6 * diameter / 2, reach + base,
                                  length - base, 12, 'x', sign), 'connector'))
        reach += length
    diameter, length = entry.cable
    if length > 0:
        pieces.append((*_cylinder(diameter / 2, reach, length, 8, 'x', sign),
                       'cable'))
    diameter, length = entry.stud
    if length > 0:
        for end in (1.0, -1.0):
            pieces.append((*_cylinder(diameter / 2, sz / 2, length, 12, 'z',
                                      end), 'connector'))
    if marks:
        lift = LIFT * min(entry.size)
        if entry.kind == 'triax':
            # the letter on each positive face, upright as the sensors
            # etch them: Z read with the connector below it
            faces = (('X', (sx / 2, 0, 0), (1, 0, 0), (0, 0, 1), min(sy, sz)),
                     ('Y', (0, sy / 2, 0), (0, 1, 0), (0, 0, 1), min(sx, sz)),
                     ('Z', (0, 0, sz / 2), (0, 0, 1), (1, 0, 0), min(sx, sy)))
            for letter, origin, normal, up, side in faces:
                placed = _on_face(_letter(letter, 0.6 * side), origin,
                                  np.array(normal, float), np.array(up, float),
                                  lift)
                pieces.append((*placed, f'mark:{letter}'))
        else:
            shafts_and_heads = _arrow(0.7 * sz)
            for center, normal in _arrow_sides(outline):
                origin = np.array([center[0], center[1], 0.0])
                normal = np.array([normal[0], normal[1], 0.0])
                for polygons, part in zip(shafts_and_heads, ('shaft', 'head')):
                    placed = _on_face(polygons, origin, normal,
                                      np.array([0.0, 0.0, 1.0]), lift)
                    pieces.append((*placed, f'mark:{part}'))
    all_points, all_triangles, parts = [], [], []
    offset = 0
    for points, triangles, part in pieces:
        all_points.append(points)
        all_triangles += [(a + offset, b + offset, c + offset)
                          for a, b, c in triangles]
        parts += [part] * len(triangles)
        offset += len(points)
    return (np.vstack(all_points), np.asarray(all_triangles, dtype=np.int64),
            parts)

sensor_mesh

sensor_mesh(sensors: list[Sensor], geometry: Geometry, unit_system: UnitSystem | None = None, size: float = 1.0) -> SensorMesh | None

One mesh of every sensor's body at its node, in display units.

Parameters:

Name Type Description Default
sensors list of Sensor

From core.sensors.place.

required
geometry Geometry

The geometry they are placed on.

required
unit_system UnitSystem

The display units; the default system when omitted.

None
size float

A multiple of true size (or of the proportional size, when the geometry's units are not defined), about each node.

1.0

Returns:

Type Description
SensorMesh or None

None when there are no sensors.

Source code in src/visualdynamics/viz/sensors.py
def sensor_mesh(sensors: list[Sensor], geometry: Geometry,
                unit_system: UnitSystem | None = None,
                size: float = 1.0) -> SensorMesh | None:
    """One mesh of every sensor's body at its node, in display units.

    Parameters
    ----------
    sensors : list of Sensor
        From `core.sensors.place`.
    geometry : Geometry
        The geometry they are placed on.
    unit_system : UnitSystem, optional
        The display units; the default system when omitted.
    size : float, default 1.0
        A multiple of true size (or of the proportional size, when the
        geometry's units are not defined), about each node.

    Returns
    -------
    SensorMesh or None
        None when there are no sensors.
    """
    import pyvista as pv

    if not sensors:
        return None
    unit_system = unit_system or DEFAULT_SYSTEM
    notes = []
    if geometry.units_defined:
        per_mm = 1e-3
    else:
        xyz = np.asarray(geometry.node_xyz, dtype=float)
        extent = float(np.linalg.norm(xyz.max(axis=0) - xyz.min(axis=0))) or 1.0
        per_mm = NOMINAL_MM * extent
        notes.append(NOMINAL_NOTE)
    ids = np.asarray(geometry.node_id)
    colors = {'body': _rgb(BODY_COLOR), 'connector': _rgb(CONNECTOR_COLOR),
              'cable': _rgb(CABLE_COLOR), 'mark': _rgb(MARK_COLOR)}
    all_points, all_triangles, all_colors, all_rows = [], [], [], []
    count = 0
    for sensor in sensors:
        points, triangles, parts = body(sensor.entry, marks=sensor.marked)
        placed = sensor.center + (points * per_mm * size) @ sensor.frame.T
        all_points.append(placed)
        all_triangles.append(triangles + count)
        count += len(points)
        row = int(np.flatnonzero(ids == sensor.node)[0])
        all_rows.append(np.full(len(points), row))
        all_colors += [colors[part.split(':')[0]] for part in parts]
    points = np.vstack(all_points)
    if geometry.units_defined:
        points = np.asarray(unit_system.from_si(points, 'length'), dtype=float)
    triangles = np.vstack(all_triangles)
    faces = np.column_stack([np.full(len(triangles), 3), triangles]).ravel()
    vtk_points, view = shared_points(points)
    mesh = pv.PolyData()
    mesh.SetPoints(vtk_points)
    mesh.faces = faces
    mesh.cell_data['colors'] = np.asarray(all_colors, dtype=np.uint8)
    return SensorMesh(mesh, np.concatenate(all_rows),
                      np.ascontiguousarray(points), vtk_points, view,
                      list(sensors), notes)

add_sensors

add_sensors(plotter: Any, geometry: Geometry, table: ChannelTable, unit_system: UnitSystem | None = None, size: float = 1.0, name: str = 'sensors') -> tuple[SensorMesh | None, list[str]]

Draw the sensors a channel table places on a geometry.

Parameters:

Name Type Description Default
plotter Plotter

Where they are drawn, as one actor named name.

required
geometry Geometry

The geometry the table's nodes are in.

required
table ChannelTable

The channels.

required
unit_system UnitSystem

The display units.

None
size float

A multiple of true size, about each node.

1.0
name str

The actor's name, so a redraw replaces it.

'sensors'

Returns:

Name Type Description
mesh SensorMesh or None

What was drawn, for an animation to move; None when the table places no sensor on the geometry.

notes list of str

What the placement and the drawing say about it.

Source code in src/visualdynamics/viz/sensors.py
def add_sensors(plotter: Any, geometry: Geometry, table: ChannelTable,
                unit_system: UnitSystem | None = None, size: float = 1.0,
                name: str = 'sensors') -> tuple[SensorMesh | None, list[str]]:
    """Draw the sensors a channel table places on a geometry.

    Parameters
    ----------
    plotter : pyvista.Plotter
        Where they are drawn, as one actor named `name`.
    geometry : Geometry
        The geometry the table's nodes are in.
    table : ChannelTable
        The channels.
    unit_system : UnitSystem, optional
        The display units.
    size : float, default 1.0
        A multiple of true size, about each node.
    name : str, default 'sensors'
        The actor's name, so a redraw replaces it.

    Returns
    -------
    mesh : SensorMesh or None
        What was drawn, for an animation to move; None when the table
        places no sensor on the geometry.
    notes : list of str
        What the placement and the drawing say about it.
    """
    from ..core.sensors import place

    sensors, notes = place(table, geometry)
    drawn = sensor_mesh(sensors, geometry, unit_system, size)
    if drawn is None:
        return None, notes
    plotter.add_mesh(drawn.mesh, scalars='colors', rgb=True, name=name,
                     pickable=False, reset_camera=False, render=False,
                     show_scalar_bar=False)
    return drawn, notes + drawn.notes