From d0d3467e251d83b48ef715ed0194703b03c9b4b1 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 21 Mar 2022 21:28:51 +0100 Subject: [PATCH 001/207] draft --- magpylib/_src/display/display.py | 2 +- .../_src/obj_classes/class_BaseTransform.py | 13 ++++---- magpylib/_src/obj_classes/class_Collection.py | 31 +++++-------------- 3 files changed, 15 insertions(+), 31 deletions(-) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index a9c0924bb..45d7d746d 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -11,7 +11,7 @@ check_input_animation, check_format_input_vector, ) - +#TODO allow for nested collections def show( *objects, zoom=0, diff --git a/magpylib/_src/obj_classes/class_BaseTransform.py b/magpylib/_src/obj_classes/class_BaseTransform.py index 21743c50e..689057b7f 100644 --- a/magpylib/_src/obj_classes/class_BaseTransform.py +++ b/magpylib/_src/obj_classes/class_BaseTransform.py @@ -316,14 +316,15 @@ def move(self, displacement, start='auto'): # applied to its BaseGeo and to each child. for child in getattr(self, 'children', []): - apply_move(child, displacement, start=start) + self.__class__.move(child, displacement, start=start) + #TODO avoid to apply move twice apply_move(self, displacement, start=start) return self - def rotate(self, rotation: R, anchor=None, start='auto'): + def rotate(self, rotation: R, anchor=None, start='auto', _parent_path=None): """Rotate object about a given anchor. Terminology for move/rotate methods: @@ -404,13 +405,13 @@ def rotate(self, rotation: R, anchor=None, start='auto'): # Idea: An operation applied to a Collection is individually # applied to its BaseGeo and to each child. # -> this automatically generates the rotate-Compound behavior - + #TODO avoid to apply rotate twice for child in getattr(self, 'children', []): - apply_rotation( - child, rotation, anchor=anchor, start=start, parent_path=self._position + self.__class__.rotate( + child, rotation, anchor=anchor, start=start, _parent_path=self._position ) - apply_rotation(self, rotation, anchor=anchor, start=start) + apply_rotation(self, rotation, anchor=anchor, start=start, parent_path=_parent_path) return self diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 344ce24bc..793fd3f05 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -5,8 +5,6 @@ from magpylib._src.utility import ( format_obj_input, check_duplicates, - LIBRARY_SENSORS, - LIBRARY_SOURCES, ) from magpylib._src.obj_classes.class_BaseGeo import BaseGeo @@ -39,33 +37,19 @@ def children(self): @children.setter def children(self, children): """Set Collection children.""" - obj_list = format_obj_input(children, allow="sources+sensors") self._children = [] - self.add(obj_list) + self.add(children) @property def sources(self): """Collection sources attribute getter and setter.""" return self._sources - @sources.setter - def sources(self, sources): - """Set Collection sources.""" - src_list = format_obj_input(sources, allow="sources") - self._children = [o for o in self._children if o not in self._sources] - self.add(src_list) - @property def sensors(self): """Collection sensors attribute getter and setter.""" return self._sensors - @sensors.setter - def sensors(self, sensors): - """Set Collection sensors.""" - sens_list = format_obj_input(sensors, allow="sensors") - self._children = [o for o in self._children if o not in self._sensors] - self.add(sens_list) # dunders def __sub__(self, obj): @@ -119,7 +103,7 @@ def add(self, *children): [Sensor(id=2236606343584)] """ # format input - obj_list = format_obj_input(children) + obj_list = format_obj_input(children, allow='sensors+sources+collections') # combine with original obj_list obj_list = self._children + obj_list # check and eliminate duplicates @@ -132,12 +116,9 @@ def add(self, *children): def _update_src_and_sens(self): # pylint: disable=protected-access """updates source and sensor list when a child is added or removed""" - self._sources = [ - obj for obj in self._children if obj._object_type in LIBRARY_SOURCES - ] - self._sensors = [ - obj for obj in self._children if obj._object_type in LIBRARY_SENSORS - ] + #TODO remove duplicates + self._sources = format_obj_input(self.children, allow='sources') + self._sensors = format_obj_input(self.children, allow='sensors') def remove(self, child): """Remove a specific child from the collection. @@ -165,6 +146,7 @@ def remove(self, child): >>> print(col.children) [] """ + #TODO traverse children tree to find objs to remove self._children.remove(child) self._update_src_and_sens() return self @@ -200,6 +182,7 @@ def set_children_styles(self, arg=None, **kwargs): >>> magpy.show(col, src) ---> graphic output """ + #TODO traverse children if arg is None: arg = {} From c872219cccb9bd77717272e99b39eba3bf7ede73 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 08:10:07 +0100 Subject: [PATCH 002/207] remove duplicates on src sens getters --- magpylib/_src/obj_classes/class_Collection.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 793fd3f05..ff258ac4f 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -117,8 +117,8 @@ def _update_src_and_sens(self): # pylint: disable=protected-access """updates source and sensor list when a child is added or removed""" #TODO remove duplicates - self._sources = format_obj_input(self.children, allow='sources') - self._sensors = format_obj_input(self.children, allow='sensors') + self._sources = list(dict.fromkeys(format_obj_input(self.children, allow='sources'))) + self._sensors = list(dict.fromkeys(format_obj_input(self.children, allow='sensors'))) def remove(self, child): """Remove a specific child from the collection. From 65d4f450b5ba8fab28e2462daedaea8046f3e0a1 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 11:39:17 +0100 Subject: [PATCH 003/207] add nested coll support for mpl --- magpylib/_src/display/display.py | 21 ++- magpylib/_src/display/display_matplotlib.py | 187 +++++++++----------- magpylib/_src/display/display_utility.py | 30 ++++ 3 files changed, 128 insertions(+), 110 deletions(-) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 45d7d746d..6d76156a3 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -10,8 +10,9 @@ check_input_zoom, check_input_animation, check_format_input_vector, - ) -#TODO allow for nested collections +) + +# TODO allow for nested collections def show( *objects, zoom=0, @@ -114,9 +115,10 @@ def show( markers, dims=(2,), shape_m1=3, - sig_name='markers', - sig_type='array_like of shape (n,3)', - allow_None=True) + sig_name="markers", + sig_type="array_like of shape (n,3)", + allow_None=True, + ) check_input_zoom(zoom) check_input_animation(animation) @@ -124,14 +126,15 @@ def show( markers, dims=(2,), shape_m1=3, - sig_name='markers', - sig_type='array_like of shape (n,3)', - allow_None=True) + sig_name="markers", + sig_type="array_like of shape (n,3)", + allow_None=True, + ) if backend == "matplotlib": if animation is not False: msg = "The matplotlib backend does not support animation at the moment.\n" - msg+= "Use `backend=plotly` instead." + msg += "Use `backend=plotly` instead." warnings.warn(msg) # animation = False display_matplotlib( diff --git a/magpylib/_src/display/display_matplotlib.py b/magpylib/_src/display/display_matplotlib.py index b8a8a6003..d29721351 100644 --- a/magpylib/_src/display/display_matplotlib.py +++ b/magpylib/_src/display/display_matplotlib.py @@ -15,6 +15,7 @@ system_size, faces_sphere, faces_cylinder_segment, + get_flatten_objects_properties, ) from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style @@ -333,8 +334,6 @@ def display_matplotlib( # pylint: disable=too-many-statements # apply config default values if None - if color_sequence is None: - color_sequence = Config.display.colorsequence # create or set plotting axis if axis is None: fig = plt.figure(dpi=80, figsize=(8, 8)) @@ -351,108 +350,94 @@ def display_matplotlib( points = [] dipoles = [] sensors = [] - faced_objects_color = [] - - for semi_flat_obj, color in zip(obj_list_semi_flat, cycle(color_sequence)): - flat_objs = [semi_flat_obj] - if getattr(semi_flat_obj, "children", None) is not None: - flat_objs.extend(semi_flat_obj.children) - if getattr(semi_flat_obj, "position", None) is not None: - color = ( - color - if semi_flat_obj.style.color is None - else semi_flat_obj.style.color + flat_objs_props = get_flatten_objects_properties( + *obj_list_semi_flat, color_sequence=color_sequence + ) + for obj, props in flat_objs_props.items(): + color = props["color"] + style = get_style(obj, Config, **kwargs) + path_frames = style.path.frames + if path_frames is None: + path_frames = True + obj_color = style.color if style.color is not None else color + lw = 0.25 + faces = None + if obj.style.model3d.data: + pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color) + points += pts + if obj.style.model3d.showdefault: + if obj._object_type == "Cuboid": + lw = 0.5 + faces = faces_cuboid(obj, path_frames) + elif obj._object_type == "Cylinder": + faces = faces_cylinder(obj, path_frames) + elif obj._object_type == "CylinderSegment": + faces = faces_cylinder_segment(obj, path_frames) + elif obj._object_type == "Sphere": + faces = faces_sphere(obj, path_frames) + elif obj._object_type == "Line": + if style.arrow.show: + check_excitations([obj]) + arrow_size = style.arrow.size if style.arrow.show else 0 + arrow_width = style.arrow.width + points += draw_line( + [obj], path_frames, obj_color, arrow_size, arrow_width, ax ) - - for obj in flat_objs: - style = get_style(obj, Config, **kwargs) - path_frames = style.path.frames - if path_frames is None: - path_frames = True - obj_color = style.color if style.color is not None else color - lw = 0.25 - faces = None - if obj.style.model3d.data: - pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color) - points += pts - if obj.style.model3d.showdefault: - if obj._object_type == "Cuboid": - lw = 0.5 - faces = faces_cuboid(obj, path_frames) - elif obj._object_type == "Cylinder": - faces = faces_cylinder(obj, path_frames) - elif obj._object_type == "CylinderSegment": - faces = faces_cylinder_segment(obj, path_frames) - elif obj._object_type == "Sphere": - faces = faces_sphere(obj, path_frames) - elif obj._object_type == "Line": - if style.arrow.show: - check_excitations([obj]) - arrow_size = style.arrow.size if style.arrow.show else 0 - arrow_width = style.arrow.width - points += draw_line( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Loop": - if style.arrow.show: - check_excitations([obj]) - arrow_width = style.arrow.width - arrow_size = style.arrow.size if style.arrow.show else 0 - points += draw_circular( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Sensor": - sensors.append((obj, obj_color)) - points += draw_pixel( - [obj], - ax, - obj_color, - style.pixel.color, - style.pixel.size, - style.pixel.symbol, - path_frames, - ) - elif obj._object_type == "Dipole": - dipoles.append((obj, obj_color)) - points += [obj.position] - elif obj._object_type == "CustomSource": - draw_markers( - np.array([obj.position]), ax, obj_color, symbol="*", size=10 - ) - label = ( - obj.style.label - if obj.style.label is not None - else str(type(obj).__name__) - ) - ax.text(*obj.position, label, horizontalalignment="center") - points += [obj.position] - if faces is not None: - faced_objects_color += [obj_color] - alpha = style.opacity - pts = draw_faces(faces, obj_color, lw, alpha, ax) - points += [np.vstack(pts).reshape(-1, 3)] - if style.magnetization.show: - check_excitations([obj]) - pts = draw_directs_faced( - [obj], - [obj_color], - ax, - path_frames, - style.magnetization.size, - ) - points += pts - if style.path.show: - marker, line = style.path.marker, style.path.line - points += draw_path( - obj, - obj_color, - marker.symbol, - marker.size, - marker.color, - line.style, - line.width, + elif obj._object_type == "Loop": + if style.arrow.show: + check_excitations([obj]) + arrow_width = style.arrow.width + arrow_size = style.arrow.size if style.arrow.show else 0 + points += draw_circular( + [obj], path_frames, obj_color, arrow_size, arrow_width, ax + ) + elif obj._object_type == "Sensor": + sensors.append((obj, obj_color)) + points += draw_pixel( + [obj], ax, + obj_color, + style.pixel.color, + style.pixel.size, + style.pixel.symbol, + path_frames, ) + elif obj._object_type == "Dipole": + dipoles.append((obj, obj_color)) + points += [obj.position] + elif obj._object_type == "CustomSource": + draw_markers( + np.array([obj.position]), ax, obj_color, symbol="*", size=10 + ) + label = ( + obj.style.label + if obj.style.label is not None + else str(type(obj).__name__) + ) + ax.text(*obj.position, label, horizontalalignment="center") + points += [obj.position] + if faces is not None: + alpha = style.opacity + pts = draw_faces(faces, obj_color, lw, alpha, ax) + points += [np.vstack(pts).reshape(-1, 3)] + if style.magnetization.show: + check_excitations([obj]) + pts = draw_directs_faced( + [obj], [obj_color], ax, path_frames, style.magnetization.size, + ) + points += pts + if style.path.show: + marker, line = style.path.marker, style.path.line + points += draw_path( + obj, + obj_color, + marker.symbol, + marker.size, + marker.color, + line.style, + line.width, + ax, + ) # markers ------------------------------------------------------- if markers is not None and markers: diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index d47e60166..1fac760db 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -1,9 +1,11 @@ """ Display function codes""" +from itertools import cycle from typing import Tuple import numpy as np from scipy.spatial.transform import Rotation as RotScipy from magpylib._src.style import Markers +from magpylib._src.defaults.defaults_classes import default_settings as Config class MagpyMarkers: @@ -456,3 +458,31 @@ def system_size(points): else: limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 return limx0, limx1, limy0, limy1, limz0, limz1 + + +def get_flatten_objects_properties( + *obj_list_semi_flat, color_sequence=None, color_cycle=None, parent_color=None +): + """returns a dict (obj, props) from nested collections""" + if color_sequence is None: + color_sequence = Config.display.colorsequence + if color_cycle is None: + color_cycle = cycle(color_sequence) + flat_objs = {} + for semi_flat_obj in obj_list_semi_flat: + color = parent_color + if parent_color is None: + color = next(color_cycle) + flat_objs[semi_flat_obj] = dict(color=color) + if getattr(semi_flat_obj, "children", None) is not None: + if semi_flat_obj.style.color is not None: + color = semi_flat_obj.style.color + flat_objs.update( + get_flatten_objects_properties( + *semi_flat_obj.children, + color_sequence=color_sequence, + color_cycle=color_cycle, + parent_color=color, + ) + ) + return flat_objs From 293f953942dc4944a7bafe9b4721341fdd6999a8 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 13:04:54 +0100 Subject: [PATCH 004/207] support nested collection for plotly --- magpylib/_src/display/display_utility.py | 36 +++++++---- .../_src/display/plotly/plotly_display.py | 62 +++++-------------- .../_src/display/plotly/plotly_utility.py | 14 +++++ 3 files changed, 54 insertions(+), 58 deletions(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index 1fac760db..932368ff5 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -461,28 +461,40 @@ def system_size(points): def get_flatten_objects_properties( - *obj_list_semi_flat, color_sequence=None, color_cycle=None, parent_color=None + *obj_list_semi_flat, color_sequence=None, color_cycle=None, **parent_props, ): - """returns a dict (obj, props) from nested collections""" + """returns a flat dict -> (obj: display_props, ...) from nested collections""" if color_sequence is None: color_sequence = Config.display.colorsequence if color_cycle is None: color_cycle = cycle(color_sequence) flat_objs = {} - for semi_flat_obj in obj_list_semi_flat: - color = parent_color - if parent_color is None: - color = next(color_cycle) - flat_objs[semi_flat_obj] = dict(color=color) - if getattr(semi_flat_obj, "children", None) is not None: - if semi_flat_obj.style.color is not None: - color = semi_flat_obj.style.color + for subobj in obj_list_semi_flat: + isCollection = getattr(subobj, "children", None) is not None + props = {**parent_props} + if parent_props.get("color", None) is None: + props["color"] = next(color_cycle) + if parent_props.get("legendgroup", None) is None: + props["legendgroup"] = f"{subobj}" + if parent_props.get("showlegend", None) is None: + props["showlegend"] = True + if parent_props.get("legendtext", None) is None: + legendtext = None + if isCollection: + legendtext = getattr(getattr(subobj, "style", None), "label", None) + legendtext = f"{subobj!r}" if legendtext is None else legendtext + props["legendtext"] = legendtext + flat_objs[subobj] = props + #print(props) + if isCollection: + if subobj.style.color is not None: + flat_objs[subobj]["color"] = subobj.style.color flat_objs.update( get_flatten_objects_properties( - *semi_flat_obj.children, + *subobj.children, color_sequence=color_sequence, color_cycle=color_cycle, - parent_color=color, + **flat_objs[subobj], ) ) return flat_objs diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py index 287e7f361..ac21464cb 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -31,6 +31,7 @@ draw_arrow_from_vertices, draw_arrowed_circle, place_and_orient_model3d, + get_flatten_objects_properties, ) from magpylib._src.defaults.defaults_utility import ( SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY, @@ -52,6 +53,7 @@ merge_traces, getColorscale, getIntensity, + clean_legendgroups, ) @@ -684,7 +686,7 @@ def make_path(input_obj, style, legendgroup, kwargs): return scatter_path -def draw_frame(objs, color_sequence, zoom, autosize=None, **kwargs) -> Tuple: +def draw_frame(obj_list_semi_flat, color_sequence, zoom, autosize=None, **kwargs) -> Tuple: """ Creates traces from input `objs` and provided parameters, updates the size of objects like Sensors and Dipoles in `kwargs` depending on the canvas size. @@ -701,51 +703,18 @@ def draw_frame(objs, color_sequence, zoom, autosize=None, **kwargs) -> Tuple: # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. # autosize is calculated from the other traces overall scene range traces_to_resize = {} - for obj, color in zip(objs, cycle(color_sequence)): - subobjs = [obj] - legendgroup = None - if getattr(obj, "children", None) is not None: - subobjs.extend(obj.children) - legendgroup = f"{obj}" - if getattr(obj, "position", None) is not None: - color = color if obj.style.color is None else obj.style.color - first_shown = False - for ind, subobj in enumerate(subobjs): - if legendgroup is not None: - if ( - subobj.style.model3d.showdefault or ind + 1 == len(subobjs) - ) and not first_shown: - # take name of parent - first_shown = True - if getattr(subobj, "children", None) is not None: - first_shown = any(m3.show for m3 in obj.style.model3d.data) - showlegend = True - legendtext = getattr(getattr(obj, "style", None), "label", None) - legendtext = f"{obj!r}" if legendtext is None else legendtext - else: - legendtext = None - showlegend = False - # print(f"{ind+1:02d}/{len(subobjs):02d} {legendtext=}, {showlegend=}") - else: - showlegend = True - legendtext = None - - params = { - **dict( - color=color, - legendgroup=legendgroup, - showlegend=showlegend, - legendtext=legendtext, - ), - **kwargs, - } - if isinstance(subobj, (Dipole, Sensor)): - traces_to_resize[subobj] = {**params} - # temporary coordinates to be able to calculate ranges - x, y, z = subobj.position.T - traces_dicts[subobj] = [dict(x=x, y=y, z=z)] - else: - traces_dicts[subobj] = get_plotly_traces(subobj, **params) + flat_objs_props = get_flatten_objects_properties( + *obj_list_semi_flat, color_sequence=color_sequence + ) + for obj, params in flat_objs_props.items(): + params.update(kwargs) + if isinstance(obj, (Dipole, Sensor)): + traces_to_resize[obj] = {**params} + # temporary coordinates to be able to calculate ranges + x, y, z = obj._position.T + traces_dicts[obj] = [dict(x=x, y=y, z=z)] + else: + traces_dicts[obj] = get_plotly_traces(obj, **params) traces = [t for tr in traces_dicts.values() for t in tr] ranges = get_scene_ranges(*traces, zoom=zoom) if autosize is None or autosize == "return": @@ -1135,6 +1104,7 @@ def display_plotly( fig.add_traces(traces) fig.update_layout(title_text=title) apply_fig_ranges(fig, zoom=zoom) + clean_legendgroups(fig) fig.update_layout(legend_itemsizing="constant") if show_fig: fig.show(renderer=renderer) diff --git a/magpylib/_src/display/plotly/plotly_utility.py b/magpylib/_src/display/plotly/plotly_utility.py index 2f706afcb..94aaf24b6 100644 --- a/magpylib/_src/display/plotly/plotly_utility.py +++ b/magpylib/_src/display/plotly/plotly_utility.py @@ -126,3 +126,17 @@ def getColorscale( [1.0, color_north], ] return colorscale + +def clean_legendgroups(fig): + """removes legend duplicates""" + frames = [fig.data] + if fig.frames: + data_list = [f["data"] for f in fig.frames] + frames.extend(data_list) + for f in frames: + legendgroups = [] + for t in f: + if t.legendgroup not in legendgroups and t.legendgroup is not None: + legendgroups.append(t.legendgroup) + elif t.legendgroup is not None and t.legendgrouptitle.text is None: + t.showlegend = False \ No newline at end of file From 3797dd9dc0b7f2e2e6adb4a30da940799825ce8f Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 13:28:48 +0100 Subject: [PATCH 005/207] add setters --- magpylib/_src/obj_classes/class_Collection.py | 70 ++++++++++++++----- 1 file changed, 53 insertions(+), 17 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index ff258ac4f..a69658ed7 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -5,6 +5,8 @@ from magpylib._src.utility import ( format_obj_input, check_duplicates, + LIBRARY_SENSORS, + LIBRARY_SOURCES, ) from magpylib._src.obj_classes.class_BaseGeo import BaseGeo @@ -13,19 +15,21 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput + class BaseCollection(BaseDisplayRepr): """ Collection base class without BaseGeo properties """ def __init__(self, *children): - self._object_type = 'Collection' + self._object_type = "Collection" BaseDisplayRepr.__init__(self) self._children = [] self._sources = [] self._sensors = [] + self._collections = [] self.children = children # property getters and setters @@ -45,11 +49,36 @@ def sources(self): """Collection sources attribute getter and setter.""" return self._sources + @sources.setter + def sources(self, sources): + """Set Collection sources.""" + src_list = format_obj_input(sources, allow="sources") + self._children = [o for o in self._children if o not in self._sources] + self.add(src_list) + @property def sensors(self): """Collection sensors attribute getter and setter.""" return self._sensors + @sensors.setter + def sensors(self, sensors): + """Set Collection sensors.""" + sens_list = format_obj_input(sensors, allow="sensors") + self._children = [o for o in self._children if o not in self._sensors] + self.add(sens_list) + + @property + def collections(self): + """Collection sub-collections attribute getter and setter.""" + return self._collections + + @collections.setter + def collections(self, collections): + """Set Collection sub-collections.""" + coll_list = format_obj_input(collections, allow="collections") + self._children = [o for o in self._children if o not in self._collections] + self.add(coll_list) # dunders def __sub__(self, obj): @@ -69,11 +98,11 @@ def __repr__(self) -> str: s = super().__repr__() if self._children: if not self._sources: - pref = 'Sensor' + pref = "Sensor" elif not self._sensors: - pref = 'Source' + pref = "Source" else: - pref = 'Mixed' + pref = "Mixed" return f"{pref}{s}" return s @@ -103,7 +132,7 @@ def add(self, *children): [Sensor(id=2236606343584)] """ # format input - obj_list = format_obj_input(children, allow='sensors+sources+collections') + obj_list = format_obj_input(children, allow="sensors+sources+collections") # combine with original obj_list obj_list = self._children + obj_list # check and eliminate duplicates @@ -116,9 +145,15 @@ def add(self, *children): def _update_src_and_sens(self): # pylint: disable=protected-access """updates source and sensor list when a child is added or removed""" - #TODO remove duplicates - self._sources = list(dict.fromkeys(format_obj_input(self.children, allow='sources'))) - self._sensors = list(dict.fromkeys(format_obj_input(self.children, allow='sensors'))) + self._sources = [ + obj for obj in self._children if obj._object_type in LIBRARY_SOURCES + ] + self._sensors = [ + obj for obj in self._children if obj._object_type in LIBRARY_SENSORS + ] + self._collections = [ + obj for obj in self._children if obj._object_type == "Collection" + ] def remove(self, child): """Remove a specific child from the collection. @@ -146,12 +181,11 @@ def remove(self, child): >>> print(col.children) [] """ - #TODO traverse children tree to find objs to remove + # TODO traverse children tree to find objs to remove self._children.remove(child) self._update_src_and_sens() return self - def set_children_styles(self, arg=None, **kwargs): """Set display style of all children in the collection. Only matching properties will be applied. Input can be a style dict or style underscore magic. @@ -182,7 +216,7 @@ def set_children_styles(self, arg=None, **kwargs): >>> magpy.show(col, src) ---> graphic output """ - #TODO traverse children + # TODO traverse children if arg is None: arg = {} @@ -218,7 +252,6 @@ def _validate_getBH_inputs(self, *children): sources, sensors = self, children return sources, sensors - def getB(self, *sources_observers, squeeze=True): """Compute B-field in [mT] for given sources and observer inputs. @@ -267,8 +300,7 @@ def getB(self, *sources_observers, squeeze=True): sources, sensors = self._validate_getBH_inputs(*sources_observers) - return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field='B') - + return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field="B") def getH(self, *children, squeeze=True): """Compute H-field in [kA/m] for given sources and observer inputs. @@ -318,7 +350,7 @@ def getH(self, *children, squeeze=True): sources, sensors = self._validate_getBH_inputs(*children) - return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field='H') + return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field="H") class Collection(BaseGeo, BaseCollection): @@ -407,6 +439,10 @@ class Collection(BaseGeo, BaseCollection): [ 0.00126232 -0.00093169 -0.00034448] """ - def __init__(self, *args, position=(0,0,0), orientation=None, style=None, **kwargs): - BaseGeo.__init__(self, position=position, orientation=orientation, style=style, **kwargs) + def __init__( + self, *args, position=(0, 0, 0), orientation=None, style=None, **kwargs + ): + BaseGeo.__init__( + self, position=position, orientation=orientation, style=style, **kwargs + ) BaseCollection.__init__(self, *args) From ad0ef38942602a0ab4e629c53b2f4165e25707f3 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 13:46:06 +0100 Subject: [PATCH 006/207] update set_children_styles --- magpylib/_src/obj_classes/class_Collection.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index a69658ed7..19019fc50 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -186,7 +186,7 @@ def remove(self, child): self._update_src_and_sens() return self - def set_children_styles(self, arg=None, **kwargs): + def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs): """Set display style of all children in the collection. Only matching properties will be applied. Input can be a style dict or style underscore magic. @@ -216,22 +216,27 @@ def set_children_styles(self, arg=None, **kwargs): >>> magpy.show(col, src) ---> graphic output """ - # TODO traverse children + # pylint: disable=protected-access if arg is None: arg = {} if kwargs: arg.update(kwargs) - style_kwargs = validate_style_keys(arg) - for src in self._children: + style_kwargs = arg + if _validate: + style_kwargs = validate_style_keys(arg) + + for child in self._children: # match properties false will try to apply properties from kwargs only if it finds it # without throwing an error + if child._object_type=='Collection' and recursive: + self.__class__.set_children_styles(child, style_kwargs, _validate=False) style_kwargs_specific = { k: v for k, v in style_kwargs.items() - if k.split("_")[0] in src.style.as_dict() + if k.split("_")[0] in child.style.as_dict() } - src.style.update(**style_kwargs_specific, _match_properties=True) + child.style.update(**style_kwargs_specific, _match_properties=True) return self def _validate_getBH_inputs(self, *children): From 2efd47b3a69275fcaab69eb7ec9d0370fa945414 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 14:13:10 +0100 Subject: [PATCH 007/207] update collection.remove to be search recursively --- magpylib/_src/obj_classes/class_Collection.py | 20 ++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 19019fc50..926b76a05 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -155,7 +155,7 @@ def _update_src_and_sens(self): obj for obj in self._children if obj._object_type == "Collection" ] - def remove(self, child): + def remove(self, child, recursive=True, _issearching=False): """Remove a specific child from the collection. Parameters @@ -181,8 +181,22 @@ def remove(self, child): >>> print(col.children) [] """ - # TODO traverse children tree to find objs to remove - self._children.remove(child) + # _issearching is needed to tell if we are still looking through the nested children if the + # object to be removed is found. + isfound = False + if child in self._children or not recursive: + self._children.remove(child) + isfound = True + else: + for child_col in self._collections: + isfound = self.__class__.remove(child_col, child, _issearching=True) + if isfound: + break + _issearching = False + if _issearching: + return isfound + if not isfound and not _issearching: + raise ValueError(f"""{self}.remove({child}) : {child!r} not found.""") self._update_src_and_sens() return self From bd7475708eb83106fd4811ebf67a45496b6a86d5 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 18:19:53 +0100 Subject: [PATCH 008/207] add describe method --- magpylib/_src/obj_classes/class_Collection.py | 40 ++++++++++++++----- 1 file changed, 30 insertions(+), 10 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 926b76a05..d0f40469b 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -1,6 +1,7 @@ """Collection class code DOCSTRING v4 READY """ +from collections import Counter from magpylib._src.utility import ( format_obj_input, @@ -94,18 +95,37 @@ def __len__(self): return len(self._children) def __repr__(self) -> str: - # pylint: disable=protected-access s = super().__repr__() - if self._children: - if not self._sources: - pref = "Sensor" - elif not self._sensors: - pref = "Source" - else: - pref = "Mixed" - return f"{pref}{s}" return s + def describe(self, labels_only=False, max_elems=10, return_list=False, indent=0): + """Returns a view of the nested Collection elements. If number of children is higher than + `max_elems` returns counters by object_type""" + # pylint: disable=protected-access + elems = [] + if len(self.children) > max_elems: + counts = Counter([c._object_type for c in self._children]) + elems.extend([" " * indent + f"{v}x{k}" for k, v in counts.items()]) + else: + for child in self.children: + if labels_only and child.style.label: + child_repr = f"{child.style.label}" + else: + child_repr = f"{child}" + elems.append(" " * indent + child_repr) + if child in self._collections: + children = self.__class__.describe( + child, + return_list=True, + indent=indent + 2, + labels_only=labels_only, + max_elems=max_elems, + ) + elems.extend(children) + if return_list: + return elems + print(("\n").join(elems)) + # methods ------------------------------------------------------- def add(self, *children): """Add sources, sensors or collections. @@ -243,7 +263,7 @@ def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs for child in self._children: # match properties false will try to apply properties from kwargs only if it finds it # without throwing an error - if child._object_type=='Collection' and recursive: + if child._object_type == "Collection" and recursive: self.__class__.set_children_styles(child, style_kwargs, _validate=False) style_kwargs_specific = { k: v From 1e20d765e8946c9bb4465f2017b1129998dc01cf Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 19:26:44 +0100 Subject: [PATCH 009/207] adapt getBH to nested collections --- .../_src/display/plotly/plotly_utility.py | 2 +- magpylib/_src/fields/field_wrap_BH_level2.py | 4 +- magpylib/_src/input_checks.py | 7 +-- magpylib/_src/obj_classes/class_Collection.py | 28 ++++++------ magpylib/_src/utility.py | 43 +++++++++---------- 5 files changed, 42 insertions(+), 42 deletions(-) diff --git a/magpylib/_src/display/plotly/plotly_utility.py b/magpylib/_src/display/plotly/plotly_utility.py index 94aaf24b6..60b174b49 100644 --- a/magpylib/_src/display/plotly/plotly_utility.py +++ b/magpylib/_src/display/plotly/plotly_utility.py @@ -139,4 +139,4 @@ def clean_legendgroups(fig): if t.legendgroup not in legendgroups and t.legendgroup is not None: legendgroups.append(t.legendgroup) elif t.legendgroup is not None and t.legendgrouptitle.text is None: - t.showlegend = False \ No newline at end of file + t.showlegend = False diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index b535fd462..161bda580 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -2,6 +2,7 @@ from scipy.spatial.transform import Rotation as R from magpylib._src.utility import ( check_static_sensor_orient, + format_obj_input, format_src_inputs, ) from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 @@ -199,6 +200,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # out: sources = ordered list of sources # out: src_list = ordered list of sources with flattened collections sources, src_list = format_src_inputs(sources) + print(sources, src_list) # test if all source dimensions and excitations are initialized check_dimensions(sources) @@ -293,7 +295,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: if l > l0: for i,src in enumerate(sources): if src._object_type == 'Collection': - col_len = len(src.sources) + col_len = len(format_obj_input(src, allow="sources")) B[i] = np.sum(B[i:i+col_len],axis=0) # set B[i] to sum of slice B = np.delete(B,np.s_[i+1:i+col_len],0) # delete remaining part of slice diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index e074efdaf..862fc69f2 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -9,7 +9,7 @@ ) from magpylib._src.defaults.defaults_classes import default_settings from magpylib import _src -from magpylib._src.utility import wrong_obj_msg +from magpylib._src.utility import format_obj_input, wrong_obj_msg ################################################################# @@ -411,9 +411,10 @@ def check_format_input_observers(inp): if getattr(obj, "_object_type", "") == "Sensor": sensors.append(obj) elif getattr(obj, "_object_type", "") == "Collection": - if not obj.sensors: + child_sensors = format_obj_input(obj, allow='sensors') + if not child_sensors: raise MagpylibBadUserInput(wrong_obj_msg(obj, allow="observers")) - sensors.extend(obj.sensors) + sensors.extend(child_sensors) else: # if its not a Sensor or a Collection it can only be a pos_vec try: obj = np.array(obj, dtype=float) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index d0f40469b..b09ab550f 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -273,25 +273,26 @@ def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs child.style.update(**style_kwargs_specific, _match_properties=True) return self - def _validate_getBH_inputs(self, *children): - # pylint: disable=too-many-branches + def _validate_getBH_inputs(self, *inputs): """validate Collection.getBH inputs""" # pylint: disable=protected-access - sources, sensors = list(self._sources), list(self._sensors) - if self._sensors and self._sources: + # pylint: disable=too-many-branches + current_sources = format_obj_input(self, allow='sources') + current_sensors = format_obj_input(self, allow='sensors') + if current_sensors and current_sources: sources, sensors = self, self - if children: + if inputs: raise MagpylibBadUserInput( "Collections with sensors and sources do not allow `collection.getB()` inputs." "Consider using `magpy.getB()` instead." ) - elif not sources: - sources, sensors = children, self - elif not sensors: - sources, sensors = self, children + elif not current_sources: + sources, sensors = inputs, self + elif not current_sensors: + sources, sensors = self, inputs return sources, sensors - def getB(self, *sources_observers, squeeze=True): + def getB(self, *inputs, squeeze=True): """Compute B-field in [mT] for given sources and observer inputs. Parameters @@ -337,11 +338,10 @@ def getB(self, *sources_observers, squeeze=True): [ 0. 0. 166.66666667]] """ - sources, sensors = self._validate_getBH_inputs(*sources_observers) - + sources, sensors = self._validate_getBH_inputs(*inputs) return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field="B") - def getH(self, *children, squeeze=True): + def getH(self, *inputs, squeeze=True): """Compute H-field in [kA/m] for given sources and observer inputs. Parameters @@ -387,7 +387,7 @@ def getH(self, *children, squeeze=True): [ 0. 0. 66.31455962]] """ - sources, sensors = self._validate_getBH_inputs(*children) + sources, sensors = self._validate_getBH_inputs(*inputs) return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field="H") diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index f166ac2a1..aad86aee7 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -1,5 +1,5 @@ """ some utility functions""" -#import numbers +# import numbers from math import log10 from typing import Sequence import numpy as np @@ -91,26 +91,25 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> obj_list = [] flatten_collection = not "collections" in allow.split("+") for obj in objects: - if isinstance(obj, (tuple, list)): - obj_list += format_obj_input( - *obj, allow=allow, warn=warn - ) # recursive flattening - else: - try: - if obj._object_type == "Collection": - if flatten_collection: - obj_list += obj.children - else: - obj_list += [obj] - elif obj._object_type in list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS): + try: + if getattr(obj, "_object_type", None) in list(LIBRARY_SOURCES) + list( + LIBRARY_SENSORS + ): + obj_list += [obj] + else: + if flatten_collection or isinstance(obj, (list, tuple)): + obj_list += format_obj_input( + *obj, allow=allow, warn=warn, + ) # recursive flattening + else: obj_list += [obj] - except Exception as error: - raise MagpylibBadUserInput(wrong_obj_msg(obj, allow=allow)) from error + except Exception as error: + raise MagpylibBadUserInput(wrong_obj_msg(obj, allow=allow)) from error obj_list = filter_objects(obj_list, allow=allow, warn=False) return obj_list -def format_src_inputs(sources) -> list: +def format_src_inputs(*sources) -> list: """ - input: allow only bare src objects or 1D lists/tuple of src and col - out: sources, src_list @@ -127,19 +126,17 @@ def format_src_inputs(sources) -> list: """ # pylint: disable=protected-access - # if bare source make into list - if not isinstance(sources, (list, tuple)): - sources = [sources] # flatten collections src_list = [] if not sources: raise MagpylibBadUserInput(wrong_obj_msg(allow="sources")) for src in sources: - obj_type = getattr(src, "_object_type", None) + obj_type = getattr(src, "_object_type", "") if obj_type == "Collection": - if not src.sources: + child_sources = format_obj_input(src, allow="sources") + if not child_sources: raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources")) - src_list += src.sources + src_list += child_sources elif obj_type in LIBRARY_SOURCES: src_list += [src] else: @@ -295,7 +292,7 @@ def add_iteration_suffix(name): m = re.search(r"\d+$", name) n = "00" endstr = None - midchar = "_" if name[-1]!= '_' else "" + midchar = "_" if name[-1] != "_" else "" if m is not None: midchar = "" n = m.group() From a0aba8a7d4387dc4dddc2e437635c0cd1c8737a0 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 19:28:16 +0100 Subject: [PATCH 010/207] remove irrelevant todos --- magpylib/_src/display/display.py | 1 - magpylib/_src/obj_classes/class_BaseTransform.py | 2 -- 2 files changed, 3 deletions(-) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 6d76156a3..fcc58b8a0 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -12,7 +12,6 @@ check_format_input_vector, ) -# TODO allow for nested collections def show( *objects, zoom=0, diff --git a/magpylib/_src/obj_classes/class_BaseTransform.py b/magpylib/_src/obj_classes/class_BaseTransform.py index 689057b7f..380b3f9f9 100644 --- a/magpylib/_src/obj_classes/class_BaseTransform.py +++ b/magpylib/_src/obj_classes/class_BaseTransform.py @@ -318,7 +318,6 @@ def move(self, displacement, start='auto'): for child in getattr(self, 'children', []): self.__class__.move(child, displacement, start=start) - #TODO avoid to apply move twice apply_move(self, displacement, start=start) return self @@ -405,7 +404,6 @@ def rotate(self, rotation: R, anchor=None, start='auto', _parent_path=None): # Idea: An operation applied to a Collection is individually # applied to its BaseGeo and to each child. # -> this automatically generates the rotate-Compound behavior - #TODO avoid to apply rotate twice for child in getattr(self, 'children', []): self.__class__.rotate( child, rotation, anchor=anchor, start=start, _parent_path=self._position From b2596232f1b71d3e69e162aaac666bfc4240b671 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 19:29:45 +0100 Subject: [PATCH 011/207] add TODOS --- magpylib/_src/obj_classes/class_Collection.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index b09ab550f..98d3184be 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -16,7 +16,8 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput - +#TODO Forbid duplicates and forbid referencing itself +#TODO Implement child-parent philosophy class BaseCollection(BaseDisplayRepr): """ Collection base class without BaseGeo properties """ From 32c245701c06291d94ef969bae3d855219036192 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 22 Mar 2022 19:40:59 +0100 Subject: [PATCH 012/207] remove print statement --- magpylib/_src/fields/field_wrap_BH_level2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 161bda580..3ce0eb8f7 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -200,7 +200,6 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # out: sources = ordered list of sources # out: src_list = ordered list of sources with flattened collections sources, src_list = format_src_inputs(sources) - print(sources, src_list) # test if all source dimensions and excitations are initialized check_dimensions(sources) From 1346ff38bb1001251d80775b1df0a39075343344 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 08:14:39 +0100 Subject: [PATCH 013/207] add self to collection.describe() --- magpylib/_src/obj_classes/class_Collection.py | 25 +++++++++++-------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 98d3184be..4aa8fda11 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -16,8 +16,8 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput -#TODO Forbid duplicates and forbid referencing itself -#TODO Implement child-parent philosophy +# TODO Forbid duplicates and forbid referencing itself +# TODO Implement child-parent philosophy class BaseCollection(BaseDisplayRepr): """ Collection base class without BaseGeo properties """ @@ -103,22 +103,25 @@ def describe(self, labels_only=False, max_elems=10, return_list=False, indent=0) """Returns a view of the nested Collection elements. If number of children is higher than `max_elems` returns counters by object_type""" # pylint: disable=protected-access - elems = [] + def repr_obj(obj, indent=0): + if labels_only and obj.style.label: + obj_repr = f"{obj.style.label}" + else: + obj_repr = f"{obj}" + return " " * indent + obj_repr + elems = [repr_obj(self, indent=indent)] if len(self.children) > max_elems: counts = Counter([c._object_type for c in self._children]) elems.extend([" " * indent + f"{v}x{k}" for k, v in counts.items()]) else: for child in self.children: - if labels_only and child.style.label: - child_repr = f"{child.style.label}" + if child not in self._collections: + elems.append(repr_obj(child, indent=indent+2)) else: - child_repr = f"{child}" - elems.append(" " * indent + child_repr) - if child in self._collections: children = self.__class__.describe( child, return_list=True, - indent=indent + 2, + indent=indent+2, labels_only=labels_only, max_elems=max_elems, ) @@ -278,8 +281,8 @@ def _validate_getBH_inputs(self, *inputs): """validate Collection.getBH inputs""" # pylint: disable=protected-access # pylint: disable=too-many-branches - current_sources = format_obj_input(self, allow='sources') - current_sensors = format_obj_input(self, allow='sensors') + current_sources = format_obj_input(self, allow="sources") + current_sensors = format_obj_input(self, allow="sensors") if current_sensors and current_sources: sources, sensors = self, self if inputs: From 84dd3593df43d7d7601fa965655cd4414eb3a118 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 10:55:20 +0100 Subject: [PATCH 014/207] add parent/child philosophy --- magpylib/_src/obj_classes/class_BaseGeo.py | 87 +++++++++++++------ magpylib/_src/obj_classes/class_Collection.py | 59 +++++++++---- 2 files changed, 103 insertions(+), 43 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index ec418f6f9..53b5161e7 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -12,7 +12,9 @@ from magpylib._src.input_checks import ( check_format_input_orientation, check_format_input_vector, - ) +) +from magpylib._src.exceptions import MagpylibBadUserInput + from magpylib._src.utility import add_iteration_suffix @@ -23,12 +25,13 @@ def pad_slice_path(path1, path2): return: path2 with format (N,x) """ delta_path = len(path1) - len(path2) - if delta_path>0: - return np.pad(path2, ((0,delta_path), (0,0)), 'edge') - if delta_path<0: + if delta_path > 0: + return np.pad(path2, ((0, delta_path), (0, 0)), "edge") + if delta_path < 0: return path2[-delta_path:] return path2 + class BaseGeo(BaseTransform): """Initializes position and orientation properties of an object in a global CS. @@ -61,14 +64,17 @@ class BaseGeo(BaseTransform): """ - def __init__(self, position=(0.,0.,0.,), orientation=None, style=None, **kwargs): + def __init__( + self, position=(0.0, 0.0, 0.0,), orientation=None, style=None, **kwargs + ): + self._parent = None # set _position and _orientation attributes self._init_position_orientation(position, orientation) # style self.style_class = self._get_style_class() - if style is not None or kwargs: #avoid style creation cost if not needed + if style is not None or kwargs: # avoid style creation cost if not needed self.style = self._process_style_kwargs(style=style, **kwargs) @staticmethod @@ -98,21 +104,22 @@ def _init_position_orientation(self, position, orientation): # format position and orientation inputs pos = check_format_input_vector( position, - dims=(1,2), + dims=(1, 2), shape_m1=3, - sig_name='position', - sig_type='array_like (list, tuple, ndarray) with shape (3,) or (n,3)', - reshape=True) + sig_name="position", + sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)", + reshape=True, + ) oriQ = check_format_input_orientation(orientation, init_format=True) # padding logic: if one is longer than the other, edge-pad up the other len_pos = pos.shape[0] len_ori = oriQ.shape[0] - if len_pos>len_ori: - oriQ = np.pad(oriQ, ((0,len_pos-len_ori), (0,0)), 'edge') - elif len_pos len_ori: + oriQ = np.pad(oriQ, ((0, len_pos - len_ori), (0, 0)), "edge") + elif len_pos < len_ori: + pos = np.pad(pos, ((0, len_ori - len_pos), (0, 0)), "edge") # set attributes self._position = pos @@ -128,6 +135,25 @@ def _get_style_class(self): return get_style_class(self) # properties ---------------------------------------------------- + @property + def parent(self): + """Object parent attribute getter and setter.""" + return self._parent + + @parent.setter + def parent(self, inp): + if inp is None: + if self._parent is not None: + self._parent.remove(self) + self._parent = None + elif getattr(inp, "_object_type", "") == "Collection": + inp.add(inp) + else: + raise MagpylibBadUserInput( + f"The `parent` property of {type(self).__name__} must be a Collection." + f"Instead received {inp!r} of type {type(inp).__name__}" + ) + @property def position(self): """Object position attribute getter and setter.""" @@ -151,11 +177,12 @@ def position(self, inp): # check and set new position self._position = check_format_input_vector( inp, - dims=(1,2), + dims=(1, 2), shape_m1=3, - sig_name='position', - sig_type='array_like (list, tuple, ndarray) with shape (3,) or (n,3)', - reshape=True) + sig_name="position", + sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)", + reshape=True, + ) # pad/slice and set orientation path to same length oriQ = self._orientation.as_quat() @@ -169,7 +196,6 @@ def position(self, inp): # set child position (pad/slice orientation) child.position = self._position + rel_child_pos - @property def orientation(self): """Object orientation attribute getter and setter.""" @@ -202,8 +228,9 @@ def orientation(self, inp): child.position = pad_slice_path(self._position, child._position) # compute rotation and apply old_ori_pad = R.from_quat(np.squeeze(pad_slice_path(oriQ, old_oriQ))) - child.rotate(self.orientation*old_ori_pad.inv(), anchor=self._position, start=0) - + child.rotate( + self.orientation * old_ori_pad.inv(), anchor=self._position, start=0 + ) @property def style(self): @@ -225,7 +252,7 @@ def _validate_style(self, val=None): raise ValueError( f"Input parameter `style` must be of type {self.style_class}.\n" f"Instead received type {type(val)}" - ) + ) return val # dunders ------------------------------------------------------- @@ -238,6 +265,9 @@ def __add__(self, obj): """ # pylint: disable=import-outside-toplevel from magpylib._src.obj_classes.class_Collection import Collection + + if getattr(self, "_object_type", "") == "Collection": + return self.add(obj) return Collection(self, obj) def __radd__(self, other): @@ -247,7 +277,7 @@ def __radd__(self, other): ------- Collection: Collection """ - if other==0: + if other == 0: return self return self.__add__(other) @@ -277,7 +307,7 @@ def reset_path(self): [0. 0. 0.] [0. 0. 0.] """ - self.position = (0,0,0) + self.position = (0, 0, 0) self.orientation = None return self @@ -304,8 +334,9 @@ def copy(self, **kwargs): """ # pylint: disable=import-outside-toplevel from copy import deepcopy + obj_copy = deepcopy(self) - if getattr(self, '_style', None) is not None: + if getattr(self, "_style", None) is not None: label = self.style.label if label is None: label = f"{type(self).__name__}_01" @@ -313,11 +344,11 @@ def copy(self, **kwargs): label = add_iteration_suffix(label) obj_copy.style.label = label style_kwargs = {} - for k,v in kwargs.items(): - if k.startswith('style'): + for k, v in kwargs.items(): + if k.startswith("style"): style_kwargs[k] = v else: - setattr(obj_copy, k,v) + setattr(obj_copy, k, v) if style_kwargs: style_kwargs = self._process_style_kwargs(**style_kwargs) obj_copy.style.update(style_kwargs) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 4aa8fda11..938e34ab5 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -1,6 +1,5 @@ -"""Collection class code -DOCSTRING v4 READY -""" +"""Collection class code""" + from collections import Counter from magpylib._src.utility import ( @@ -16,13 +15,11 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput -# TODO Forbid duplicates and forbid referencing itself -# TODO Implement child-parent philosophy class BaseCollection(BaseDisplayRepr): """ Collection base class without BaseGeo properties """ - def __init__(self, *children): + def __init__(self, *children, override_parent=False): self._object_type = "Collection" @@ -32,7 +29,7 @@ def __init__(self, *children): self._sources = [] self._sensors = [] self._collections = [] - self.children = children + self.add(*children, override_parent=override_parent) # property getters and setters @property @@ -43,8 +40,11 @@ def children(self): @children.setter def children(self, children): """Set Collection children.""" + # pylint: disable=protected-access + for child in self._children: + child._parent = None self._children = [] - self.add(children) + self.add(*children) @property def sources(self): @@ -54,8 +54,15 @@ def sources(self): @sources.setter def sources(self, sources): """Set Collection sources.""" + # pylint: disable=protected-access + new_children = [] + for child in self._children: + if child in self._sources: + child._parent = None + else: + new_children.append(child) + self._children = new_children src_list = format_obj_input(sources, allow="sources") - self._children = [o for o in self._children if o not in self._sources] self.add(src_list) @property @@ -66,8 +73,15 @@ def sensors(self): @sensors.setter def sensors(self, sensors): """Set Collection sensors.""" + # pylint: disable=protected-access + new_children = [] + for child in self._children: + if child in self._sensors: + child._parent = None + else: + new_children.append(child) + self._children = new_children sens_list = format_obj_input(sensors, allow="sensors") - self._children = [o for o in self._children if o not in self._sensors] self.add(sens_list) @property @@ -109,6 +123,7 @@ def repr_obj(obj, indent=0): else: obj_repr = f"{obj}" return " " * indent + obj_repr + elems = [repr_obj(self, indent=indent)] if len(self.children) > max_elems: counts = Counter([c._object_type for c in self._children]) @@ -116,12 +131,12 @@ def repr_obj(obj, indent=0): else: for child in self.children: if child not in self._collections: - elems.append(repr_obj(child, indent=indent+2)) + elems.append(repr_obj(child, indent=indent + 2)) else: children = self.__class__.describe( child, return_list=True, - indent=indent+2, + indent=indent + 2, labels_only=labels_only, max_elems=max_elems, ) @@ -131,7 +146,7 @@ def repr_obj(obj, indent=0): print(("\n").join(elems)) # methods ------------------------------------------------------- - def add(self, *children): + def add(self, *children, override_parent=False): """Add sources, sensors or collections. Parameters @@ -155,12 +170,24 @@ def add(self, *children): >>> print(col.children) [Sensor(id=2236606343584)] """ + # pylint: disable=protected-access # format input obj_list = format_obj_input(children, allow="sensors+sources+collections") - # combine with original obj_list - obj_list = self._children + obj_list # check and eliminate duplicates obj_list = check_duplicates(obj_list) + # assign parent + for obj in obj_list: + if obj._parent is None or override_parent: + obj._parent = self + else: + raise ValueError( + f"The object `{obj!r}` already has a parent `{obj._parent!r}`. " + "You can use the `.add(*children, override_parent=True)` method to ignore and " + "override the current object parent. Note that this will remove the object " + "from the previous parent collection." + ) + # combine with original obj_list + obj_list = self._children + obj_list # set attributes self._children = obj_list self._update_src_and_sens() @@ -205,11 +232,13 @@ def remove(self, child, recursive=True, _issearching=False): >>> print(col.children) [] """ + # pylint: disable=protected-access # _issearching is needed to tell if we are still looking through the nested children if the # object to be removed is found. isfound = False if child in self._children or not recursive: self._children.remove(child) + child._parent = None isfound = True else: for child_col in self._collections: From 29094856b82f2bedeb531bab74490aa2a5dede41 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 12:18:47 +0100 Subject: [PATCH 015/207] fix regression --- magpylib/_src/utility.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index aad86aee7..767eb2ccc 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -109,7 +109,7 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> return obj_list -def format_src_inputs(*sources) -> list: +def format_src_inputs(sources) -> list: """ - input: allow only bare src objects or 1D lists/tuple of src and col - out: sources, src_list @@ -126,6 +126,9 @@ def format_src_inputs(*sources) -> list: """ # pylint: disable=protected-access + # if bare source make into list + if not isinstance(sources, (list, tuple)): + sources = [sources] # flatten collections src_list = [] if not sources: From 7b6915edc310e4f39f30fab9eb90766a22179e02 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 12:19:15 +0100 Subject: [PATCH 016/207] add override_parent to Collection --- magpylib/_src/obj_classes/class_Collection.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 938e34ab5..3fac7304f 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -15,6 +15,7 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput + class BaseCollection(BaseDisplayRepr): """ Collection base class without BaseGeo properties """ @@ -181,7 +182,8 @@ def add(self, *children, override_parent=False): obj._parent = self else: raise ValueError( - f"The object `{obj!r}` already has a parent `{obj._parent!r}`. " + f"`{self!r}` cannot receive `{obj!r}`, as the child already has a parent " + f"(`{obj._parent!r}`). " "You can use the `.add(*children, override_parent=True)` method to ignore and " "override the current object parent. Note that this will remove the object " "from the previous parent collection." @@ -512,9 +514,15 @@ class Collection(BaseGeo, BaseCollection): """ def __init__( - self, *args, position=(0, 0, 0), orientation=None, style=None, **kwargs + self, + *args, + position=(0, 0, 0), + orientation=None, + style=None, + override_parent=False, + **kwargs, ): BaseGeo.__init__( - self, position=position, orientation=orientation, style=style, **kwargs + self, position=position, orientation=orientation, style=style, **kwargs, ) - BaseCollection.__init__(self, *args) + BaseCollection.__init__(self, *args, override_parent=override_parent) From 8fb87b7dc6833f2e935659dd5d9f414d6604bd5f Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 12:44:04 +0100 Subject: [PATCH 017/207] fix __add__ --- magpylib/_src/obj_classes/class_BaseGeo.py | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 53b5161e7..39c68f274 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -266,9 +266,20 @@ def __add__(self, obj): # pylint: disable=import-outside-toplevel from magpylib._src.obj_classes.class_Collection import Collection - if getattr(self, "_object_type", "") == "Collection": - return self.add(obj) - return Collection(self, obj) + override_parent=False + obj1, obj2 = self, obj + iscol1 = getattr(self, "_object_type", "") == "Collection" + iscol2 = getattr(obj, "_object_type", "") == "Collection" + if not iscol1: + obj1 = [self] + if not iscol2: + obj2 = [obj] + if obj._parent is None: + override_parent=True + elif iscol1 and iscol2: + obj1, obj2 = [self], [obj] + coll = Collection(*obj1, *obj2, override_parent=override_parent) + return coll def __radd__(self, other): """Add up sources to a Collection object. Allows to use `sum(objects)`. From fa1ab95af851a5db1e8a3e9e90cffcfdefd47542 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 12:52:53 +0100 Subject: [PATCH 018/207] add collection repr, also nested --- magpylib/_src/obj_classes/class_Collection.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 3fac7304f..a81707f24 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -111,7 +111,18 @@ def __len__(self): return len(self._children) def __repr__(self) -> str: + # pylint: disable=protected-access s = super().__repr__() + if self._children: + if self._collections: + pref = "Nested" + elif not self._sources: + pref = "Sensor" + elif not self._sensors: + pref = "Source" + else: + pref = "Mixed" + return f"{pref}{s}" return s def describe(self, labels_only=False, max_elems=10, return_list=False, indent=0): From e699379e61453492652716f4bc3aa688da8d7326 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 13:31:55 +0100 Subject: [PATCH 019/207] fix remove and make private --- magpylib/_src/obj_classes/class_Collection.py | 62 +++++++++++++------ 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index a81707f24..46cae5aef 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -219,7 +219,44 @@ def _update_src_and_sens(self): obj for obj in self._children if obj._object_type == "Collection" ] - def remove(self, child, recursive=True, _issearching=False): + @staticmethod + def _remove(parent, child, recursive=True, issearching=False): + """Remove a specific child from a parent collection. + + Parameters + ---------- + parent: parent collection object + + child: child object + + recursive: bool, default=True + If True, the method will also search in lower nested levels + + issearching: bool, default=False + Tells the current searching status over the nested collection. This avoids the search, + to be stopped to early if the child has not been found yet + """ + # pylint: disable=protected-access + isfound = False + if child in parent._children or not recursive: + parent._children.remove(child) + child._parent = None + isfound = True + else: + for child_col in parent._collections: + isfound = parent.__class__._remove( + child_col, child, recursive=True, issearching=True + ) + if isfound: + break + if issearching: + return isfound + if isfound: + parent._update_src_and_sens() + elif not isfound: + raise ValueError(f"""{parent}.remove({child}) : {child!r} not found.""") + + def remove(self, child, recursive=True): """Remove a specific child from the collection. Parameters @@ -227,6 +264,9 @@ def remove(self, child, recursive=True, _issearching=False): child: child object Remove the given child from the collection. + recursive: bool, default=True + If True, the method will also search in lower nested levels + Returns ------- self: `Collection` object @@ -245,25 +285,7 @@ def remove(self, child, recursive=True, _issearching=False): >>> print(col.children) [] """ - # pylint: disable=protected-access - # _issearching is needed to tell if we are still looking through the nested children if the - # object to be removed is found. - isfound = False - if child in self._children or not recursive: - self._children.remove(child) - child._parent = None - isfound = True - else: - for child_col in self._collections: - isfound = self.__class__.remove(child_col, child, _issearching=True) - if isfound: - break - _issearching = False - if _issearching: - return isfound - if not isfound and not _issearching: - raise ValueError(f"""{self}.remove({child}) : {child!r} not found.""") - self._update_src_and_sens() + self._remove(self, child, recursive=recursive) return self def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs): From a6646c0fd30be5215eee440faf470363b34735d2 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 13:32:04 +0100 Subject: [PATCH 020/207] testbug fix --- magpylib/_src/obj_classes/class_BaseGeo.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 39c68f274..3dc3696ad 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -274,7 +274,7 @@ def __add__(self, obj): obj1 = [self] if not iscol2: obj2 = [obj] - if obj._parent is None: + if getattr(obj, "_parent", None) is None: override_parent=True elif iscol1 and iscol2: obj1, obj2 = [self], [obj] From 6b0d953de03b583db2e074ef99ce8038500c9100 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 14:34:42 +0100 Subject: [PATCH 021/207] mini syntax sugar --- .../_src/obj_classes/class_BaseTransform.py | 59 +++++++++---------- 1 file changed, 29 insertions(+), 30 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseTransform.py b/magpylib/_src/obj_classes/class_BaseTransform.py index 380b3f9f9..943497964 100644 --- a/magpylib/_src/obj_classes/class_BaseTransform.py +++ b/magpylib/_src/obj_classes/class_BaseTransform.py @@ -66,7 +66,7 @@ def path_padding_param(scalar_input: bool, lenop: int, lenip: int, start: int): pad_behind = 0 # start='auto': apply to all if scalar, append if vector - if start == 'auto': + if start == "auto": if scalar_input: start = 0 else: @@ -120,8 +120,8 @@ def path_padding(inpath, start, target_object): # pad old path depending on input padding, start = path_padding_param(scalar_input, len(ppath), lenip, start) if padding: - ppath = np.pad(ppath, (padding, (0, 0)), 'edge') - opath = np.pad(opath, (padding, (0, 0)), 'edge') + ppath = np.pad(ppath, (padding, (0, 0)), "edge") + opath = np.pad(opath, (padding, (0, 0)), "edge") # set end-index end = len(ppath) if scalar_input else start + lenip @@ -129,7 +129,7 @@ def path_padding(inpath, start, target_object): return ppath, opath, start, end, bool(padding) -def apply_move(target_object, displacement, start='auto'): +def apply_move(target_object, displacement, start="auto"): """Implementation of the move() functionality. Parameters @@ -154,11 +154,12 @@ def apply_move(target_object, displacement, start='auto'): # check and format inputs inpath = check_format_input_vector( - displacement, - dims=(1,2), - shape_m1=3, - sig_name='displacement', - sig_type='array_like (list, tuple, ndarray) with shape (3,) or (n,3)') + displacement, + dims=(1, 2), + shape_m1=3, + sig_name="displacement", + sig_type="array_like (list, tuple, ndarray) with shape (3,) or (n,3)", + ) check_start_type(start) # pad target_object path and compute start and end-index for rotation application @@ -173,7 +174,9 @@ def apply_move(target_object, displacement, start='auto'): return target_object -def apply_rotation(target_object, rotation: R, anchor=None, start='auto', parent_path=None): +def apply_rotation( + target_object, rotation: R, anchor=None, start="auto", parent_path=None +): """Implementation of the rotate() functionality. Parameters @@ -248,7 +251,7 @@ def apply_rotation(target_object, rotation: R, anchor=None, start='auto', parent class BaseTransform: """Inherit this class to provide rotation() and move() methods.""" - def move(self, displacement, start='auto'): + def move(self, displacement, start="auto"): """Move object by the displacement input. Terminology for move/rotate methods: @@ -315,15 +318,14 @@ def move(self, displacement, start='auto'): # Idea: An operation applied to a Collection is individually # applied to its BaseGeo and to each child. - for child in getattr(self, 'children', []): - self.__class__.move(child, displacement, start=start) + for child in getattr(self, "children", []): + child.move(displacement, start=start) apply_move(self, displacement, start=start) return self - - def rotate(self, rotation: R, anchor=None, start='auto', _parent_path=None): + def rotate(self, rotation: R, anchor=None, start="auto", _parent_path=None): """Rotate object about a given anchor. Terminology for move/rotate methods: @@ -404,17 +406,18 @@ def rotate(self, rotation: R, anchor=None, start='auto', _parent_path=None): # Idea: An operation applied to a Collection is individually # applied to its BaseGeo and to each child. # -> this automatically generates the rotate-Compound behavior - for child in getattr(self, 'children', []): - self.__class__.rotate( - child, rotation, anchor=anchor, start=start, _parent_path=self._position + for child in getattr(self, "children", []): + child.rotate( + rotation, anchor=anchor, start=start, _parent_path=self._position ) - apply_rotation(self, rotation, anchor=anchor, start=start, parent_path=_parent_path) + apply_rotation( + self, rotation, anchor=anchor, start=start, parent_path=_parent_path + ) return self - - def rotate_from_angax(self, angle, axis, anchor=None, start='auto', degrees=True): + def rotate_from_angax(self, angle, axis, anchor=None, start="auto", degrees=True): """Rotates object using angle-axis input. Terminology for move/rotate methods: @@ -513,8 +516,7 @@ def rotate_from_angax(self, angle, axis, anchor=None, start='auto', degrees=True rot = R.from_rotvec(axis) return self.rotate(rot, anchor, start) - - def rotate_from_rotvec(self, rotvec, anchor=None, start='auto', degrees=True): + def rotate_from_rotvec(self, rotvec, anchor=None, start="auto", degrees=True): """Rotates object using rotation vector input. Terminology for move/rotate methods: @@ -592,8 +594,7 @@ def rotate_from_rotvec(self, rotvec, anchor=None, start='auto', degrees=True): rot = R.from_rotvec(rotvec, degrees=degrees) return self.rotate(rot, anchor=anchor, start=start) - - def rotate_from_euler(self, angle, seq, anchor=None, start='auto', degrees=True): + def rotate_from_euler(self, angle, seq, anchor=None, start="auto", degrees=True): """Rotates object using Euler angle input. Terminology for move/rotate methods: @@ -676,8 +677,7 @@ def rotate_from_euler(self, angle, seq, anchor=None, start='auto', degrees=True) rot = R.from_euler(seq, angle, degrees=degrees) return self.rotate(rot, anchor=anchor, start=start) - - def rotate_from_matrix(self, matrix, anchor=None, start='auto'): + def rotate_from_matrix(self, matrix, anchor=None, start="auto"): """Rotates object using matrix input. Terminology for move/rotate methods: @@ -738,7 +738,7 @@ def rotate_from_matrix(self, matrix, anchor=None, start='auto'): rot = R.from_matrix(matrix) return self.rotate(rot, anchor=anchor, start=start) - def rotate_from_mrp(self, mrp, anchor=None, start='auto'): + def rotate_from_mrp(self, mrp, anchor=None, start="auto"): """Rotates object using Modified Rodrigues Parameters (MRPs) input. Terminology for move/rotate methods: @@ -799,8 +799,7 @@ def rotate_from_mrp(self, mrp, anchor=None, start='auto'): rot = R.from_mrp(mrp) return self.rotate(rot, anchor=anchor, start=start) - - def rotate_from_quat(self, quat, anchor=None, start='auto'): + def rotate_from_quat(self, quat, anchor=None, start="auto"): """Rotates object using quaternion input. Terminology for move/rotate methods: From 9df03fcdaab0d8d54cc4646ebe816114c1bbbd2b Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 16:06:29 +0100 Subject: [PATCH 022/207] fix nested rotation --- .../_src/obj_classes/class_BaseTransform.py | 44 ++++++++++++------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseTransform.py b/magpylib/_src/obj_classes/class_BaseTransform.py index 943497964..b5b457066 100644 --- a/magpylib/_src/obj_classes/class_BaseTransform.py +++ b/magpylib/_src/obj_classes/class_BaseTransform.py @@ -325,7 +325,33 @@ def move(self, displacement, start="auto"): return self - def rotate(self, rotation: R, anchor=None, start="auto", _parent_path=None): + def _rotate(self, rotation: R, anchor=None, start="auto", parent_path=None): + """Rotate object about a given anchor. + + See `rotate` docstring for other parameters. + + Parameters + ---------- + parent_path: if there is no parent else parent._position + needs to be transmitted from the top level for nested collections, hence using a + private `_rotate` method to do so. + + """ + # Idea: An operation applied to a Collection is individually + # applied to its BaseGeo and to each child. + # -> this automatically generates the rotate-Compound behavior + + # pylint: disable=no-member + for child in getattr(self, "children", []): + ppth = self._position if parent_path is None else parent_path + child._rotate(rotation, anchor=anchor, start=start, parent_path=ppth) + + apply_rotation( + self, rotation, anchor=anchor, start=start, parent_path=parent_path + ) + return self + + def rotate(self, rotation: R, anchor=None, start="auto"): """Rotate object about a given anchor. Terminology for move/rotate methods: @@ -401,21 +427,7 @@ def rotate(self, rotation: R, anchor=None, start="auto", _parent_path=None): [ 0. 0. 135.]] """ - # pylint: disable=no-member - - # Idea: An operation applied to a Collection is individually - # applied to its BaseGeo and to each child. - # -> this automatically generates the rotate-Compound behavior - for child in getattr(self, "children", []): - child.rotate( - rotation, anchor=anchor, start=start, _parent_path=self._position - ) - - apply_rotation( - self, rotation, anchor=anchor, start=start, parent_path=_parent_path - ) - - return self + return self._rotate(rotation=rotation, anchor=anchor, start=start) def rotate_from_angax(self, angle, axis, anchor=None, start="auto", degrees=True): """Rotates object using angle-axis input. From 4f85bbb6705972c7b06a2257d2dff801dc786a66 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 16:09:34 +0100 Subject: [PATCH 023/207] remove unused imports --- magpylib/_src/display/display_matplotlib.py | 1 - magpylib/_src/display/plotly/plotly_display.py | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/magpylib/_src/display/display_matplotlib.py b/magpylib/_src/display/display_matplotlib.py index d29721351..de83064a7 100644 --- a/magpylib/_src/display/display_matplotlib.py +++ b/magpylib/_src/display/display_matplotlib.py @@ -1,6 +1,5 @@ """ matplotlib draw-functionalities""" -from itertools import cycle import numpy as np import matplotlib.pyplot as plt from mpl_toolkits.mplot3d.art3d import Poly3DCollection diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py index ac21464cb..8363dafdd 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -3,7 +3,7 @@ # pylint: disable=too-many-branches import numbers -from itertools import cycle, combinations +from itertools import combinations from typing import Tuple import warnings From 81b7361eb5e3e74c3ddc1c386eacb137abc477e7 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 16:39:02 +0100 Subject: [PATCH 024/207] mini color fix --- magpylib/_src/display/display_utility.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index 932368ff5..fbdd691b8 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -472,7 +472,10 @@ def get_flatten_objects_properties( for subobj in obj_list_semi_flat: isCollection = getattr(subobj, "children", None) is not None props = {**parent_props} - if parent_props.get("color", None) is None: + parent_color = parent_props.get("color", '!!!missing!!!') + if parent_color is None: + props["color"] = parent_color + elif parent_color == "!!!missing!!!": props["color"] = next(color_cycle) if parent_props.get("legendgroup", None) is None: props["legendgroup"] = f"{subobj}" @@ -485,7 +488,6 @@ def get_flatten_objects_properties( legendtext = f"{subobj!r}" if legendtext is None else legendtext props["legendtext"] = legendtext flat_objs[subobj] = props - #print(props) if isCollection: if subobj.style.color is not None: flat_objs[subobj]["color"] = subobj.style.color From 0865e9665e340d971ff58619e83fa98b05110b22 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 23 Mar 2022 19:18:07 +0100 Subject: [PATCH 025/207] rework collection.describe() --- magpylib/_src/obj_classes/class_Collection.py | 87 ++++++++++++------- 1 file changed, 57 insertions(+), 30 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 46cae5aef..7f0efcf94 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -16,6 +16,48 @@ from magpylib._src.exceptions import MagpylibBadUserInput +def repr_obj(obj, labels=False): + """Returns obj repr based on label""" + if labels and obj.style.label: + return f"{obj.style.label}" + return f"{obj!r}" + + +def collection_tree_generator( + dir_child, + prefix="", + space=" ", + branch="│ ", + tee="├── ", + last="└── ", + labels=False, + max_elems=20, +): + """A recursive generator, given a collection child object + will yield a visual tree structure line by line + with each line prefixed by the same characters + """ + # pylint: disable=protected-access + contents = getattr(dir_child, "children", []) + if len(contents) > max_elems: + counts = Counter([c._object_type for c in contents]) + contents = [f"{v}x {k}s" for k, v in counts.items()] + # contents each get pointers that are ├── with a final └── : + pointers = [tee] * (len(contents) - 1) + [last] + for pointer, child in zip(pointers, contents): + child_repr = child if isinstance(child, str) else repr_obj(child, labels) + yield prefix + pointer + child_repr + if getattr(child, "children", False): # extend the prefix and recurse: + extension = branch if pointer == tee else space + # i.e. space because last, └── , above so no more | + yield from collection_tree_generator( + child, + prefix=prefix + extension, + labels=labels, + max_elems=max_elems, + ) + + class BaseCollection(BaseDisplayRepr): """ Collection base class without BaseGeo properties """ @@ -125,37 +167,22 @@ def __repr__(self) -> str: return f"{pref}{s}" return s - def describe(self, labels_only=False, max_elems=10, return_list=False, indent=0): - """Returns a view of the nested Collection elements. If number of children is higher than - `max_elems` returns counters by object_type""" - # pylint: disable=protected-access - def repr_obj(obj, indent=0): - if labels_only and obj.style.label: - obj_repr = f"{obj.style.label}" - else: - obj_repr = f"{obj}" - return " " * indent + obj_repr + def describe(self, labels=False, max_elems=10): + """Returns a tree view of the nested collection elements. - elems = [repr_obj(self, indent=indent)] - if len(self.children) > max_elems: - counts = Counter([c._object_type for c in self._children]) - elems.extend([" " * indent + f"{v}x{k}" for k, v in counts.items()]) - else: - for child in self.children: - if child not in self._collections: - elems.append(repr_obj(child, indent=indent + 2)) - else: - children = self.__class__.describe( - child, - return_list=True, - indent=indent + 2, - labels_only=labels_only, - max_elems=max_elems, - ) - elems.extend(children) - if return_list: - return elems - print(("\n").join(elems)) + Parameters + ---------- + labels: bool, default=False + If True, `object.style.label` is used if available, instead of `repr(object)` + max_elems: + If number of children at any level is higher than `max_elems`, elements are replaced by + counters by object type. + """ + print(repr_obj(self, labels)) + for line in collection_tree_generator( + self, labels=labels, max_elems=max_elems + ): + print(line) # methods ------------------------------------------------------- def add(self, *children, override_parent=False): From e882fc271337f11722af09b3eb2d8f90664177c1 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Thu, 24 Mar 2022 10:38:18 +0100 Subject: [PATCH 026/207] fix parent setter --- magpylib/_src/input_checks.py | 17 +++++++++++++++ magpylib/_src/obj_classes/class_BaseGeo.py | 21 ++++++++----------- magpylib/_src/obj_classes/class_Collection.py | 7 +++---- 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 862fc69f2..25d5c96c8 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -143,6 +143,23 @@ def validate_field_lambda(val, bh): return val +def check_input_parent(inp): + """ + parent input must be None or Collection. + return - true if its a collection + - false if its None + raise error if neither + """ + if (getattr(inp, "_object_type", "") == "Collection"): + return True + elif inp is None: + return False + + raise MagpylibBadUserInput( + "Input `parent` must be `None` or a `Collection` object." + f"Instead received {type(inp)}." + ) + ################################################################# ################################################################# # CHECK - FORMAT diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 3dc3696ad..3e429f51f 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -12,8 +12,8 @@ from magpylib._src.input_checks import ( check_format_input_orientation, check_format_input_vector, + check_input_parent, ) -from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.utility import add_iteration_suffix @@ -142,17 +142,14 @@ def parent(self): @parent.setter def parent(self, inp): - if inp is None: - if self._parent is not None: - self._parent.remove(self) - self._parent = None - elif getattr(inp, "_object_type", "") == "Collection": - inp.add(inp) - else: - raise MagpylibBadUserInput( - f"The `parent` property of {type(self).__name__} must be a Collection." - f"Instead received {inp!r} of type {type(inp).__name__}" - ) + is_collection = check_input_parent(inp) + + if self._parent is not None: + self._parent.remove(self) + self._parent = None + + if is_collection: + inp.add(self) @property def position(self): diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 7f0efcf94..9cb304a55 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -190,9 +190,8 @@ def add(self, *children, override_parent=False): Parameters ---------- - children: source, `Sensor` or `Collection` objects or arbitrary lists thereof + children: sources, sensors or collections or arbitrary lists thereof Add arbitrary sources, sensors or other collections to this collection. - Duplicate children will automatically be eliminated. Returns ------- @@ -216,7 +215,7 @@ def add(self, *children, override_parent=False): obj_list = check_duplicates(obj_list) # assign parent for obj in obj_list: - if obj._parent is None or override_parent: + if (obj._parent is None) or override_parent: obj._parent = self else: raise ValueError( @@ -284,7 +283,7 @@ def _remove(parent, child, recursive=True, issearching=False): raise ValueError(f"""{parent}.remove({child}) : {child!r} not found.""") def remove(self, child, recursive=True): - """Remove a specific child from the collection. + """Remove a specific object from the collection tree. Parameters ---------- From ed06142c15fc76912ed402da95cc9c3f0628adcf Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Thu, 24 Mar 2022 12:14:16 +0100 Subject: [PATCH 027/207] fix override --- magpylib/_src/obj_classes/class_BaseGeo.py | 20 +++++++------- magpylib/_src/obj_classes/class_Collection.py | 27 ++++++++++++------- 2 files changed, 28 insertions(+), 19 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 3e429f51f..c7c1723c5 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -12,9 +12,8 @@ from magpylib._src.input_checks import ( check_format_input_orientation, check_format_input_vector, - check_input_parent, ) - +from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.utility import add_iteration_suffix @@ -142,14 +141,17 @@ def parent(self): @parent.setter def parent(self, inp): - is_collection = check_input_parent(inp) - - if self._parent is not None: - self._parent.remove(self) + if getattr(inp, "_object_type", "") == "Collection": + inp.add(self, override_parent=True) + elif inp is None: + if self._parent is not None: + self._parent.remove(self) self._parent = None - - if is_collection: - inp.add(self) + else: + raise MagpylibBadUserInput( + "Input `parent` must be `None` or a `Collection` object." + f"Instead received {type(inp)}." + ) @property def position(self): diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 9cb304a55..2c8ea874f 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -193,6 +193,10 @@ def add(self, *children, override_parent=False): children: sources, sensors or collections or arbitrary lists thereof Add arbitrary sources, sensors or other collections to this collection. + override_parent: bool, default=`True` + Accept objects as children that already have parents. Automatically + removes objects from previous parent collection. + Returns ------- self: `Collection` object @@ -209,27 +213,30 @@ def add(self, *children, override_parent=False): [Sensor(id=2236606343584)] """ # pylint: disable=protected-access - # format input + # check and format input obj_list = format_obj_input(children, allow="sensors+sources+collections") - # check and eliminate duplicates obj_list = check_duplicates(obj_list) + # assign parent for obj in obj_list: - if (obj._parent is None) or override_parent: + if obj._parent is None: + obj._parent = self + elif override_parent: + obj._parent.remove(obj) obj._parent = self else: - raise ValueError( - f"`{self!r}` cannot receive `{obj!r}`, as the child already has a parent " - f"(`{obj._parent!r}`). " - "You can use the `.add(*children, override_parent=True)` method to ignore and " - "override the current object parent. Note that this will remove the object " - "from the previous parent collection." + raise MagpylibBadUserInput( + f"Cannot add {obj!r} to {self!r} because it already has a parent." + "Consider using `override_parent=True`." ) - # combine with original obj_list + + # add input to children obj_list = self._children + obj_list + # set attributes self._children = obj_list self._update_src_and_sens() + return self def _update_src_and_sens(self): From c8a122c045119fec14a743f1ae4b409ec8443162 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Thu, 24 Mar 2022 14:55:07 +0100 Subject: [PATCH 028/207] fix plus, remove minus --- magpylib/_src/input_checks.py | 17 ----------------- magpylib/_src/obj_classes/class_BaseGeo.py | 18 ++---------------- magpylib/_src/obj_classes/class_Collection.py | 4 +--- 3 files changed, 3 insertions(+), 36 deletions(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 25d5c96c8..862fc69f2 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -143,23 +143,6 @@ def validate_field_lambda(val, bh): return val -def check_input_parent(inp): - """ - parent input must be None or Collection. - return - true if its a collection - - false if its None - raise error if neither - """ - if (getattr(inp, "_object_type", "") == "Collection"): - return True - elif inp is None: - return False - - raise MagpylibBadUserInput( - "Input `parent` must be `None` or a `Collection` object." - f"Instead received {type(inp)}." - ) - ################################################################# ################################################################# # CHECK - FORMAT diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index c7c1723c5..92dec87d4 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -256,7 +256,7 @@ def _validate_style(self, val=None): # dunders ------------------------------------------------------- def __add__(self, obj): - """Add up sources to a Collection object. + """ Add up sources to a Collection object. Returns ------- @@ -264,21 +264,7 @@ def __add__(self, obj): """ # pylint: disable=import-outside-toplevel from magpylib._src.obj_classes.class_Collection import Collection - - override_parent=False - obj1, obj2 = self, obj - iscol1 = getattr(self, "_object_type", "") == "Collection" - iscol2 = getattr(obj, "_object_type", "") == "Collection" - if not iscol1: - obj1 = [self] - if not iscol2: - obj2 = [obj] - if getattr(obj, "_parent", None) is None: - override_parent=True - elif iscol1 and iscol2: - obj1, obj2 = [self], [obj] - coll = Collection(*obj1, *obj2, override_parent=override_parent) - return coll + return Collection(self, obj) def __radd__(self, other): """Add up sources to a Collection object. Allows to use `sum(objects)`. diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 2c8ea874f..db098b8de 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -140,9 +140,6 @@ def collections(self, collections): self.add(coll_list) # dunders - def __sub__(self, obj): - return self.remove(obj) - def __iter__(self): yield from self._children @@ -270,6 +267,7 @@ def _remove(parent, child, recursive=True, issearching=False): to be stopped to early if the child has not been found yet """ # pylint: disable=protected-access + isfound = False if child in parent._children or not recursive: parent._children.remove(child) From 2f732ed5e9ad97c081e2e0fb061e1def9b55837c Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 24 Mar 2022 18:11:14 +0100 Subject: [PATCH 029/207] add describe and repr_html to all objects --- .../_src/obj_classes/class_BaseDisplayRepr.py | 55 +++++++++++++++++++ magpylib/_src/obj_classes/class_Collection.py | 11 ++++ 2 files changed, 66 insertions(+) diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index 7c4262560..6b588852a 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -4,12 +4,67 @@ from magpylib._src.display.display import show +UNITS = { + "parent": None, + "position": "mm", + "orientation": "degrees", + "dimension": "mm", + "diameter": "mm", + "current": "A", + "magnetization": "mT", +} + + class BaseDisplayRepr: """Provides the display(self) and self.repr methods for all objects""" show = show _object_type = None + def _property_names_generator(self): + """returns a generator with class properties only""" + return ( + attr + for attr in dir(self) + if isinstance(getattr(type(self), attr, None), property) + ) + + def _get_description(self, exclude=None): + """Returns list of string describing the object properties""" + if exclude is None: + exclude = () + params = list(self._property_names_generator()) + lines = [f"{self!r}"] + for k in list(dict.fromkeys(list(UNITS) + list(params))): + if k in params and k not in exclude: + unit = UNITS.get(k, None) + unit_str = f"[{unit}]" if unit else "" + if k == "position": + val = getattr(self, "_position") + if val.shape[0] != 1: + lines.append(f" • path length: {val.shape[0]}") + k = f"{k} (last)" + val = f"{val[-1]}" + elif k == "orientation": + val = getattr(self, "_orientation") + val = val.as_rotvec(degrees=True) + if len(val) != 1: + k = f"{k} (last)" + val = f"{val[-1]}" + else: + val = getattr(self, k) + lines.append(f" • {k}: {val} {unit_str}") + return lines + + def describe(self, exclude=("style",)): + """Returns a view of the object properties""" + lines = self._get_description(exclude=exclude) + print("\n".join(lines)) + + def _repr_html_(self): + lines = self._get_description(exclude=("style",)) + return f"""
{'
'.join(lines)}
""" + def __repr__(self) -> str: name = getattr(self, "name", None) if name is None and hasattr(self, "style"): diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 7f0efcf94..895246d09 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -167,6 +167,17 @@ def __repr__(self) -> str: return f"{pref}{s}" return s + def _repr_html_(self): + lines = [] + labels = False + max_elems = 10 + lines.append(repr_obj(self, labels)) + for line in collection_tree_generator( + self, labels=labels, max_elems=max_elems + ): + lines.append(line) + return f"""
{'
'.join(lines)}
""" + def describe(self, labels=False, max_elems=10): """Returns a tree view of the nested collection elements. From 3d1a23e3a90e047a3494e1766627766e85562b1a Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 24 Mar 2022 19:52:50 +0100 Subject: [PATCH 030/207] add properties --- magpylib/_src/obj_classes/class_Collection.py | 28 +++++++++++++------ 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 895246d09..cb3c39909 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -18,7 +18,7 @@ def repr_obj(obj, labels=False): """Returns obj repr based on label""" - if labels and obj.style.label: + if labels and getattr(obj, "style.label", False): return f"{obj.style.label}" return f"{obj!r}" @@ -32,22 +32,32 @@ def collection_tree_generator( last="└── ", labels=False, max_elems=20, + properties=False, ): """A recursive generator, given a collection child object will yield a visual tree structure line by line with each line prefixed by the same characters """ # pylint: disable=protected-access - contents = getattr(dir_child, "children", []) + # contents each get pointers that are ├── with a final └── : + contents = [] + desc_func = getattr(dir_child, "_get_description", False) + if properties and desc_func: + desc = desc_func( + exclude=("children", "parent", "style", "sources", "sensors", "collections") + ) + contents.extend([d.strip() for d in desc[1:]]) if len(contents) > max_elems: counts = Counter([c._object_type for c in contents]) - contents = [f"{v}x {k}s" for k, v in counts.items()] - # contents each get pointers that are ├── with a final └── : + contents.extend([f"{v}x {k}s" for k, v in counts.items()]) + contents.extend(getattr(dir_child, "children", [])) pointers = [tee] * (len(contents) - 1) + [last] for pointer, child in zip(pointers, contents): child_repr = child if isinstance(child, str) else repr_obj(child, labels) yield prefix + pointer + child_repr - if getattr(child, "children", False): # extend the prefix and recurse: + if getattr(child, "children", False) or ( + getattr(dir_child, "_get_description", False) and properties + ): # extend the prefix and recurse: extension = branch if pointer == tee else space # i.e. space because last, └── , above so no more | yield from collection_tree_generator( @@ -55,6 +65,7 @@ def collection_tree_generator( prefix=prefix + extension, labels=labels, max_elems=max_elems, + properties=properties, ) @@ -170,15 +181,14 @@ def __repr__(self) -> str: def _repr_html_(self): lines = [] labels = False - max_elems = 10 lines.append(repr_obj(self, labels)) for line in collection_tree_generator( - self, labels=labels, max_elems=max_elems + self, labels=labels, max_elems=10, properties=False ): lines.append(line) return f"""
{'
'.join(lines)}
""" - def describe(self, labels=False, max_elems=10): + def describe(self, labels=False, max_elems=10, properties=False): """Returns a tree view of the nested collection elements. Parameters @@ -191,7 +201,7 @@ def describe(self, labels=False, max_elems=10): """ print(repr_obj(self, labels)) for line in collection_tree_generator( - self, labels=labels, max_elems=max_elems + self, labels=labels, max_elems=max_elems, properties=properties ): print(line) From cefbfcf7dd98fe4c761ca5dd17c42715200eb7fe Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 24 Mar 2022 20:27:51 +0100 Subject: [PATCH 031/207] prettify --- .../_src/obj_classes/class_BaseDisplayRepr.py | 2 +- magpylib/_src/obj_classes/class_Collection.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index 6b588852a..fd42d3c32 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -38,7 +38,7 @@ def _get_description(self, exclude=None): for k in list(dict.fromkeys(list(UNITS) + list(params))): if k in params and k not in exclude: unit = UNITS.get(k, None) - unit_str = f"[{unit}]" if unit else "" + unit_str = f"{unit}" if unit else "" if k == "position": val = getattr(self, "_position") if val.shape[0] != 1: diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index cb3c39909..6b5421f24 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -18,7 +18,7 @@ def repr_obj(obj, labels=False): """Returns obj repr based on label""" - if labels and getattr(obj, "style.label", False): + if labels and getattr(getattr(obj, "style", False), "label", False): return f"{obj.style.label}" return f"{obj!r}" @@ -41,17 +41,21 @@ def collection_tree_generator( # pylint: disable=protected-access # contents each get pointers that are ├── with a final └── : contents = [] + children = getattr(dir_child, "children", []) desc_func = getattr(dir_child, "_get_description", False) + props = [] if properties and desc_func: desc = desc_func( exclude=("children", "parent", "style", "sources", "sensors", "collections") ) - contents.extend([d.strip() for d in desc[1:]]) - if len(contents) > max_elems: - counts = Counter([c._object_type for c in contents]) - contents.extend([f"{v}x {k}s" for k, v in counts.items()]) - contents.extend(getattr(dir_child, "children", [])) + props = [d.strip() for d in desc[1:]] + if len(children) > max_elems: + counts = Counter([c._object_type for c in children]) + children = [f"{v}x {k}s" for k, v in counts.items()] + contents.extend(props) + contents.extend(children) pointers = [tee] * (len(contents) - 1) + [last] + pointers[:len(props)] = [branch if children else space]*len(props) for pointer, child in zip(pointers, contents): child_repr = child if isinstance(child, str) else repr_obj(child, labels) yield prefix + pointer + child_repr From 35d1e8f6436063c91074d0a9e3e7eeb94f03d260 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 24 Mar 2022 21:37:25 +0100 Subject: [PATCH 032/207] fix collection setter --- magpylib/_src/obj_classes/class_Collection.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 7f0efcf94..dc283db33 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -135,9 +135,16 @@ def collections(self): @collections.setter def collections(self, collections): """Set Collection sub-collections.""" - coll_list = format_obj_input(collections, allow="collections") - self._children = [o for o in self._children if o not in self._collections] - self.add(coll_list) + # pylint: disable=protected-access + new_children = [] + for child in self._children: + if child in self._collections: + child._parent = None + else: + new_children.append(child) + self._children = new_children + sens_list = format_obj_input(collections, allow="collections") + self.add(sens_list) # dunders def __sub__(self, obj): From 08040ad76aba8b97a5e06b9094f78389799eaff0 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 24 Mar 2022 23:40:50 +0100 Subject: [PATCH 033/207] docstring --- .../_src/obj_classes/class_BaseDisplayRepr.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index fd42d3c32..3e882b29c 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -16,7 +16,7 @@ class BaseDisplayRepr: - """Provides the display(self) and self.repr methods for all objects""" + """Provides the show and repr methods for all objects""" show = show _object_type = None @@ -30,7 +30,13 @@ def _property_names_generator(self): ) def _get_description(self, exclude=None): - """Returns list of string describing the object properties""" + """Returns list of string describing the object properties. + + Parameters + ---------- + exclude: bool, default=("style",) + properties to be excluded in the description view. + """ if exclude is None: exclude = () params = list(self._property_names_generator()) @@ -57,7 +63,13 @@ def _get_description(self, exclude=None): return lines def describe(self, exclude=("style",)): - """Returns a view of the object properties""" + """Returns a view of the object properties. + + Parameters + ---------- + exclude: bool, default=("style",) + properties to be excluded in the description view. + """ lines = self._get_description(exclude=exclude) print("\n".join(lines)) From 77ac6d1c91bb04a7cdd77d5a73978ff0961b1f41 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 24 Mar 2022 23:43:11 +0100 Subject: [PATCH 034/207] docstrings --- magpylib/_src/obj_classes/class_Collection.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 6b5421f24..143f0e403 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -55,7 +55,7 @@ def collection_tree_generator( contents.extend(props) contents.extend(children) pointers = [tee] * (len(contents) - 1) + [last] - pointers[:len(props)] = [branch if children else space]*len(props) + pointers[: len(props)] = [branch if children else space] * len(props) for pointer, child in zip(pointers, contents): child_repr = child if isinstance(child, str) else repr_obj(child, labels) yield prefix + pointer + child_repr @@ -67,6 +67,10 @@ def collection_tree_generator( yield from collection_tree_generator( child, prefix=prefix + extension, + space=space, + branch=branch, + tee=tee, + last=last, labels=labels, max_elems=max_elems, properties=properties, @@ -74,8 +78,7 @@ def collection_tree_generator( class BaseCollection(BaseDisplayRepr): - """ Collection base class without BaseGeo properties - """ + """Collection base class without BaseGeo properties""" def __init__(self, *children, override_parent=False): @@ -193,6 +196,7 @@ def _repr_html_(self): return f"""
{'
'.join(lines)}
""" def describe(self, labels=False, max_elems=10, properties=False): + # pylint: disable=arguments-differ """Returns a tree view of the nested collection elements. Parameters @@ -202,6 +206,8 @@ def describe(self, labels=False, max_elems=10, properties=False): max_elems: If number of children at any level is higher than `max_elems`, elements are replaced by counters by object type. + properties: bool, default=False + If True, adds object properties to the view """ print(repr_obj(self, labels)) for line in collection_tree_generator( @@ -513,7 +519,7 @@ def getH(self, *inputs, squeeze=True): class Collection(BaseGeo, BaseCollection): - """ Group multiple children (sources and sensors) in one Collection for + """Group multiple children (sources and sensors) in one Collection for common manipulation. Collections can be used as `sources` and `observers` input for magnetic field @@ -608,6 +614,10 @@ def __init__( **kwargs, ): BaseGeo.__init__( - self, position=position, orientation=orientation, style=style, **kwargs, + self, + position=position, + orientation=orientation, + style=style, + **kwargs, ) BaseCollection.__init__(self, *args, override_parent=override_parent) From 80b90ea20ec21ad24df3eb87e344554619c60f4f Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Fri, 25 Mar 2022 08:58:09 +0100 Subject: [PATCH 035/207] + - behavior fixing --- magpylib/_src/input_checks.py | 61 ++++++++++++- magpylib/_src/obj_classes/class_Collection.py | 85 ++++++++----------- magpylib/_src/utility.py | 20 ++++- 3 files changed, 111 insertions(+), 55 deletions(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 862fc69f2..1bbd0ce0d 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -1,6 +1,7 @@ """ input checks code""" import numbers +from typing import Sequence import numpy as np from scipy.spatial.transform import Rotation from magpylib._src.exceptions import ( @@ -9,7 +10,7 @@ ) from magpylib._src.defaults.defaults_classes import default_settings from magpylib import _src -from magpylib._src.utility import format_obj_input, wrong_obj_msg +from magpylib._src.utility import format_obj_input, wrong_obj_msg, LIBRARY_SOURCES, LIBRARY_SENSORS ################################################################# @@ -432,6 +433,64 @@ def check_format_input_observers(inp): return sensors +def check_format_input_obj( + *inp: Sequence, + allow: str, + recursive: bool, + ) -> list: + """ + Returns a flat list of all wanted objects in input + + Parameters + ---------- + input: can be objects (sources, sensors, collections), lists or tuples + thereof, arbitrarily nested + + allow: str + Specify which object types are wanted, separate by +, + e.g. sensors+collections+sources + + recursive: bool + Flatten Collection objects + """ + # pylint: disable=protected-access + # pylint: disable=import-outside-toplevel + from magpylib import Collection + + # select wanted + wanted = [] + if "sources" in allow.split("+"): + wanted += list(LIBRARY_SOURCES) + if "sensors" in allow.split("+"): + wanted += list(LIBRARY_SENSORS) + if "collections" in allow.split("+"): + wanted += ['Collection'] + + # all_types = list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS) + ["Collection"] + + obj_list = [] + for obj in inp: + # add to list if wanted type + if getattr(obj, "_object_type", None) in wanted: + obj_list.append(obj) + + # recursion + if isinstance(obj, (Collection, tuple, list)): + if isinstance(obj, Collection) and not recursive: + continue + obj_list += check_format_input_obj(*obj, allow=allow, recursive=recursive) + + # # check if allowed inputs + # if not getattr(obj, "_object_type", None) in all_types: + # if not isinstance(obj, ( tuple, list)): + # raise MagpylibBadUserInput( + # f"Input objects must be {allow}, lists and tuples thereof." + # f"Instead received {type(obj)}" + # ) + + return obj_list + + ############################################################################################ ############################################################################################ # SHOW AND GETB CHECKS diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index db098b8de..e3d5ed2a9 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -7,6 +7,7 @@ check_duplicates, LIBRARY_SENSORS, LIBRARY_SOURCES, + rec_obj_remover, ) from magpylib._src.obj_classes.class_BaseGeo import BaseGeo @@ -14,7 +15,7 @@ from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput - +from magpylib._src.input_checks import check_format_input_obj def repr_obj(obj, labels=False): """Returns obj repr based on label""" @@ -249,45 +250,7 @@ def _update_src_and_sens(self): obj for obj in self._children if obj._object_type == "Collection" ] - @staticmethod - def _remove(parent, child, recursive=True, issearching=False): - """Remove a specific child from a parent collection. - - Parameters - ---------- - parent: parent collection object - - child: child object - - recursive: bool, default=True - If True, the method will also search in lower nested levels - - issearching: bool, default=False - Tells the current searching status over the nested collection. This avoids the search, - to be stopped to early if the child has not been found yet - """ - # pylint: disable=protected-access - - isfound = False - if child in parent._children or not recursive: - parent._children.remove(child) - child._parent = None - isfound = True - else: - for child_col in parent._collections: - isfound = parent.__class__._remove( - child_col, child, recursive=True, issearching=True - ) - if isfound: - break - if issearching: - return isfound - if isfound: - parent._update_src_and_sens() - elif not isfound: - raise ValueError(f"""{parent}.remove({child}) : {child!r} not found.""") - - def remove(self, child, recursive=True): + def remove(self, child, recursive=True, raise_err=True): """Remove a specific object from the collection tree. Parameters @@ -295,8 +258,11 @@ def remove(self, child, recursive=True): child: child object Remove the given child from the collection. - recursive: bool, default=True - If True, the method will also search in lower nested levels + raise_err: bool, default=`True` + Raise error if child is not found. + + recursive: bool, default=`True` + Continue search also in child collections. Returns ------- @@ -307,16 +273,33 @@ def remove(self, child, recursive=True): In this example we remove a child from a Collection: >>> import magpylib as magpy - >>> sens = magpy.Sensor() - >>> col = magpy.Collection(sens) - >>> print(col.children) - [Sensor(id=2048351734560)] - - >>> col.remove(sens) - >>> print(col.children) - [] + >>> x1 = magpy.Sensor(style_label='x1') + >>> x2 = magpy.Sensor(style_label='x2') + >>> col = magpy.Collection(x1, x2, style_label='col') + >>> col.describe(labels=True) + col + ├── x1 + └── x2 + + >>> col.remove(x1) + >>> col.describe(labels=True) + col + └── x2 """ - self._remove(self, child, recursive=recursive) + #pylint: disable=protected-access + all_objects = check_format_input_obj( + self, + allow='sensors+sources+collections', + recursive=recursive, + ) + if child in all_objects: + rec_obj_remover(self, child) + child._parent = None + else: + if raise_err: + raise MagpylibBadUserInput( + "Object not found." + ) return self def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs): diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 767eb2ccc..17ec0d167 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -81,7 +81,7 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> - sources (sequence): input sources ### Returns: - - list: flattened, ordered list f sources + - list: flattened, ordered list of sources ### Info: - exits if invalid sources are given @@ -126,13 +126,16 @@ def format_src_inputs(sources) -> list: """ # pylint: disable=protected-access + # store all sources here + src_list = [] + # if bare source make into list if not isinstance(sources, (list, tuple)): sources = [sources] - # flatten collections - src_list = [] + if not sources: raise MagpylibBadUserInput(wrong_obj_msg(allow="sources")) + for src in sources: obj_type = getattr(src, "_object_type", "") if obj_type == "Collection": @@ -326,3 +329,14 @@ def cyl_field_to_cart(phi, Br, Bphi=None): By = Br * np.sin(phi) return Bx, By + + +def rec_obj_remover(parent, child): + """ remove known child from parent collection""" + # pylint: disable=protected-access + for obj in parent: + if obj == child: + parent._children.remove(child) + parent._update_src_and_sens() + elif getattr(obj, "_object_type", "") == "Collection": + rec_obj_remover(obj, child) From 43a29c24a180306e44a7583425c79fb20bd91e41 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 25 Mar 2022 12:07:55 +0100 Subject: [PATCH 036/207] replace labels with desc --- magpylib/_src/obj_classes/class_Collection.py | 48 ++++++++++--------- 1 file changed, 25 insertions(+), 23 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 143f0e403..fbc3b312f 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -16,11 +16,18 @@ from magpylib._src.exceptions import MagpylibBadUserInput -def repr_obj(obj, labels=False): - """Returns obj repr based on label""" - if labels and getattr(getattr(obj, "style", False), "label", False): - return f"{obj.style.label}" - return f"{obj!r}" +def repr_obj(obj, desc="type+id+label"): + """Returns obj repr based on description paramter string""" + rp = "" + lbl = "label" in desc and getattr(getattr(obj, "style", False), "label", False) + if "type" in desc or not lbl: + rp += f"{type(obj).__name__}" + if lbl: + rp += f" {obj.style.label}" + if "id" in desc or not lbl: + id_str = f"id={id(obj)}" + rp += f" ({id_str})" if rp else id_str + return rp.strip() def collection_tree_generator( @@ -30,7 +37,7 @@ def collection_tree_generator( branch="│ ", tee="├── ", last="└── ", - labels=False, + desc="type+id+label", max_elems=20, properties=False, ): @@ -45,10 +52,10 @@ def collection_tree_generator( desc_func = getattr(dir_child, "_get_description", False) props = [] if properties and desc_func: - desc = desc_func( + desc_out = desc_func( exclude=("children", "parent", "style", "sources", "sensors", "collections") ) - props = [d.strip() for d in desc[1:]] + props = [d.strip() for d in desc_out[1:]] if len(children) > max_elems: counts = Counter([c._object_type for c in children]) children = [f"{v}x {k}s" for k, v in counts.items()] @@ -57,7 +64,7 @@ def collection_tree_generator( pointers = [tee] * (len(contents) - 1) + [last] pointers[: len(props)] = [branch if children else space] * len(props) for pointer, child in zip(pointers, contents): - child_repr = child if isinstance(child, str) else repr_obj(child, labels) + child_repr = child if isinstance(child, str) else repr_obj(child, desc) yield prefix + pointer + child_repr if getattr(child, "children", False) or ( getattr(dir_child, "_get_description", False) and properties @@ -71,7 +78,7 @@ def collection_tree_generator( branch=branch, tee=tee, last=last, - labels=labels, + desc=desc, max_elems=max_elems, properties=properties, ) @@ -187,31 +194,30 @@ def __repr__(self) -> str: def _repr_html_(self): lines = [] - labels = False - lines.append(repr_obj(self, labels)) + lines.append(repr_obj(self)) for line in collection_tree_generator( - self, labels=labels, max_elems=10, properties=False + self, desc="type+label+id", max_elems=10, properties=False ): lines.append(line) return f"""
{'
'.join(lines)}
""" - def describe(self, labels=False, max_elems=10, properties=False): + def describe(self, desc="type+label+id", max_elems=10, properties=False): # pylint: disable=arguments-differ """Returns a tree view of the nested collection elements. Parameters ---------- - labels: bool, default=False - If True, `object.style.label` is used if available, instead of `repr(object)` + desc: bool, default="type+label+id" + Object description. max_elems: If number of children at any level is higher than `max_elems`, elements are replaced by counters by object type. properties: bool, default=False If True, adds object properties to the view """ - print(repr_obj(self, labels)) + print(repr_obj(self, desc)) for line in collection_tree_generator( - self, labels=labels, max_elems=max_elems, properties=properties + self, desc=desc, max_elems=max_elems, properties=properties ): print(line) @@ -614,10 +620,6 @@ def __init__( **kwargs, ): BaseGeo.__init__( - self, - position=position, - orientation=orientation, - style=style, - **kwargs, + self, position=position, orientation=orientation, style=style, **kwargs, ) BaseCollection.__init__(self, *args, override_parent=override_parent) From d55b75b20bb105b2033a862da1f1bcc3f808f652 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 25 Mar 2022 12:08:06 +0100 Subject: [PATCH 037/207] add basic tests --- tests/test_obj_BaseGeo.py | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 701e2b90f..586a891ba 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -373,3 +373,25 @@ def test_copy(): # check if style is passed correctly assert bg2c.style.color == "orange" + + +def test_describe(): + """testing descibe method""" + s1 = lambda: magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1), (0,0,0), style_label="cuboid1", style_color='cyan') + s2 = lambda: magpy.magnet.Cylinder((0, 0, 1000), (1, 1), (2,0,0), style_label="cylinder1") + s3 = magpy.magnet.Sphere((0, 0, 1000), 1, (4,0,0), style_label="sphere1") + sens1 = magpy.Sensor((1,0,2),style_label="sensor1") + sens2 = magpy.Sensor((3,0,2),style_label="sensor2") + s3.move([[1,2,3]]) + + src_col = magpy.Collection([s1() for _ in range(6)], s2(), style_label="src_col", style_color='orange') + sens_col = magpy.Collection(sens1, style_label="sens_col") + mixed_col = magpy.Collection(s3, sens2, style_label="mixed_col") + nested_col = magpy.Collection(src_col, sens_col, mixed_col, style_label="nested_col") + + assert s3.describe(exclude=None) is None + assert s3._repr_html_() + assert src_col._repr_html_() + assert nested_col.describe(max_elems=6) is None + assert nested_col.describe(desc='label') is None + assert nested_col.describe(properties=True, desc='label') is None From ebe3afe3b3a2f3a74a9fd983a674772ae260e3d6 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Fri, 25 Mar 2022 12:57:12 +0100 Subject: [PATCH 038/207] include errors='raise' in remove --- magpylib/_src/obj_classes/class_Collection.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index e3d5ed2a9..93cc4ef69 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -250,7 +250,7 @@ def _update_src_and_sens(self): obj for obj in self._children if obj._object_type == "Collection" ] - def remove(self, child, recursive=True, raise_err=True): + def remove(self, child, recursive=True, errors='raise'): """Remove a specific object from the collection tree. Parameters @@ -258,8 +258,8 @@ def remove(self, child, recursive=True, raise_err=True): child: child object Remove the given child from the collection. - raise_err: bool, default=`True` - Raise error if child is not found. + errors: str, default=`'raise'` + Can be 'raise' or 'ignore'. recursive: bool, default=`True` Continue search also in child collections. @@ -296,10 +296,15 @@ def remove(self, child, recursive=True, raise_err=True): rec_obj_remover(self, child) child._parent = None else: - if raise_err: + if errors == 'raise': raise MagpylibBadUserInput( "Object not found." ) + elif errors != 'ignore': + raise MagpylibBadUserInput( + "Input `errors` must be one of ('raise', 'ignore').\n" + f"Instead received {errors}" + ) return self def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs): From 4fa9e79d2231a00cb8c8ce24bcb238dca56c611a Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Fri, 25 Mar 2022 14:51:26 +0100 Subject: [PATCH 039/207] only *arg object inputs for collections --- magpylib/_src/input_checks.py | 55 ++++++++++--------- magpylib/_src/obj_classes/class_BaseGeo.py | 2 +- magpylib/_src/obj_classes/class_Collection.py | 18 +++--- 3 files changed, 39 insertions(+), 36 deletions(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 1bbd0ce0d..e91701c8a 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -434,17 +434,17 @@ def check_format_input_observers(inp): def check_format_input_obj( - *inp: Sequence, + inp: Sequence, allow: str, - recursive: bool, + recursive = True, + typechecks = False, ) -> list: """ - Returns a flat list of all wanted objects in input + Returns a flat list of all wanted objects in input. Parameters ---------- - input: can be objects (sources, sensors, collections), lists or tuples - thereof, arbitrarily nested + input: can be sources, sensor or collection objects allow: str Specify which object types are wanted, separate by +, @@ -453,40 +453,43 @@ def check_format_input_obj( recursive: bool Flatten Collection objects """ - # pylint: disable=protected-access - # pylint: disable=import-outside-toplevel - from magpylib import Collection # select wanted - wanted = [] + wanted_types = [] if "sources" in allow.split("+"): - wanted += list(LIBRARY_SOURCES) + wanted_types += list(LIBRARY_SOURCES) if "sensors" in allow.split("+"): - wanted += list(LIBRARY_SENSORS) + wanted_types += list(LIBRARY_SENSORS) if "collections" in allow.split("+"): - wanted += ['Collection'] + wanted_types += ['Collection'] - # all_types = list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS) + ["Collection"] + if typechecks: + all_types = list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS) + ["Collection"] obj_list = [] for obj in inp: + obj_type = getattr(obj, "_object_type", None) + # add to list if wanted type - if getattr(obj, "_object_type", None) in wanted: + if obj_type in wanted_types: obj_list.append(obj) # recursion - if isinstance(obj, (Collection, tuple, list)): - if isinstance(obj, Collection) and not recursive: - continue - obj_list += check_format_input_obj(*obj, allow=allow, recursive=recursive) - - # # check if allowed inputs - # if not getattr(obj, "_object_type", None) in all_types: - # if not isinstance(obj, ( tuple, list)): - # raise MagpylibBadUserInput( - # f"Input objects must be {allow}, lists and tuples thereof." - # f"Instead received {type(obj)}" - # ) + if (obj_type == "Collection") and recursive: + obj_list += check_format_input_obj( + obj, + allow=allow, + recursive=recursive, + typechecks=typechecks, + ) + + # typechecks + if typechecks: + if not obj_type in all_types: + raise MagpylibBadUserInput( + f"Input objects must be {allow}.\n" + f"Instead received {type(obj)}." + ) return obj_list diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 92dec87d4..d012bfaea 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -263,7 +263,7 @@ def __add__(self, obj): Collection: Collection """ # pylint: disable=import-outside-toplevel - from magpylib._src.obj_classes.class_Collection import Collection + from magpylib import Collection return Collection(self, obj) def __radd__(self, other): diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 93cc4ef69..52d095ff8 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -4,7 +4,6 @@ from magpylib._src.utility import ( format_obj_input, - check_duplicates, LIBRARY_SENSORS, LIBRARY_SOURCES, rec_obj_remover, @@ -188,7 +187,7 @@ def add(self, *children, override_parent=False): Parameters ---------- - children: sources, sensors or collections or arbitrary lists thereof + children: sources, sensors or collections Add arbitrary sources, sensors or other collections to this collection. override_parent: bool, default=`True` @@ -212,8 +211,12 @@ def add(self, *children, override_parent=False): """ # pylint: disable=protected-access # check and format input - obj_list = format_obj_input(children, allow="sensors+sources+collections") - obj_list = check_duplicates(obj_list) + obj_list = check_format_input_obj( + children, + allow="sensors+sources+collections", + recursive=False, + typechecks=True, + ) # assign parent for obj in obj_list: @@ -228,11 +231,8 @@ def add(self, *children, override_parent=False): "Consider using `override_parent=True`." ) - # add input to children - obj_list = self._children + obj_list - # set attributes - self._children = obj_list + self._children += obj_list self._update_src_and_sens() return self @@ -300,7 +300,7 @@ def remove(self, child, recursive=True, errors='raise'): raise MagpylibBadUserInput( "Object not found." ) - elif errors != 'ignore': + if errors != 'ignore': raise MagpylibBadUserInput( "Input `errors` must be one of ('raise', 'ignore').\n" f"Instead received {errors}" From a8f35c11bb5df5d38fd0c5d2ef7a02de9afa9ef2 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Fri, 25 Mar 2022 14:55:21 +0100 Subject: [PATCH 040/207] removed Collection "special" repr --- magpylib/_src/obj_classes/class_Collection.py | 15 --------------- 1 file changed, 15 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 52d095ff8..07f3c607d 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -149,21 +149,6 @@ def __getitem__(self, i): def __len__(self): return len(self._children) - def __repr__(self) -> str: - # pylint: disable=protected-access - s = super().__repr__() - if self._children: - if self._collections: - pref = "Nested" - elif not self._sources: - pref = "Sensor" - elif not self._sensors: - pref = "Source" - else: - pref = "Mixed" - return f"{pref}{s}" - return s - def describe(self, labels=False, max_elems=10): """Returns a tree view of the nested collection elements. From 52db636186794f7995ab6ee2f8b602b22d7aa684 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Fri, 25 Mar 2022 15:06:45 +0100 Subject: [PATCH 041/207] remove allows *args --- magpylib/_src/obj_classes/class_Collection.py | 39 ++++++++++++------- 1 file changed, 24 insertions(+), 15 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 07f3c607d..fbdbf3cba 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -235,7 +235,7 @@ def _update_src_and_sens(self): obj for obj in self._children if obj._object_type == "Collection" ] - def remove(self, child, recursive=True, errors='raise'): + def remove(self, *children, recursive=True, errors='raise'): """Remove a specific object from the collection tree. Parameters @@ -272,24 +272,33 @@ def remove(self, child, recursive=True, errors='raise'): └── x2 """ #pylint: disable=protected-access - all_objects = check_format_input_obj( + + # check and format input + remove_objects = check_format_input_obj( + children, + allow="sensors+sources+collections", + recursive=False, + typechecks=True, + ) + self_objects = check_format_input_obj( self, allow='sensors+sources+collections', recursive=recursive, ) - if child in all_objects: - rec_obj_remover(self, child) - child._parent = None - else: - if errors == 'raise': - raise MagpylibBadUserInput( - "Object not found." - ) - if errors != 'ignore': - raise MagpylibBadUserInput( - "Input `errors` must be one of ('raise', 'ignore').\n" - f"Instead received {errors}" - ) + for child in remove_objects: + if child in self_objects: + rec_obj_remover(self, child) + child._parent = None + else: + if errors == 'raise': + raise MagpylibBadUserInput( + f"Cannot find and remove {child} from {self}." + ) + if errors != 'ignore': + raise MagpylibBadUserInput( + "Input `errors` must be one of ('raise', 'ignore').\n" + f"Instead received {errors}." + ) return self def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs): From ed28492609a82e4144db606e2620ac9b67fd1fa2 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 25 Mar 2022 22:52:12 +0100 Subject: [PATCH 042/207] draft --- magpylib/_src/fields/field_wrap_BH_level2.py | 15 +++++- magpylib/_src/fields/field_wrap_BH_level3.py | 40 +++++++++++++--- magpylib/_src/obj_classes/class_BaseGetBH.py | 28 +++++++---- magpylib/_src/obj_classes/class_Collection.py | 47 ++++++++++++------- magpylib/_src/obj_classes/class_Sensor.py | 28 ++++++----- 5 files changed, 113 insertions(+), 45 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index b535fd462..4cc2c8d39 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -155,6 +155,8 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: - 'sumup' (bool): False returns [B1,B2,...] for every source, True returns sum(Bi) for all sources. - 'squeeze' (bool): True output is squeezed (axes of length 1 are eliminated) + - 'pixel_agg' : str: A compatible numpy aggregator string (e.g. `'min', 'max', 'mean'`) + which applies on pixel output values. - 'field' (str): 'B' computes B field, 'H' computes H-field - getBH_dict inputs @@ -186,7 +188,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # bad user inputs mixing getBH_dict kwargs with object oriented interface kwargs_check = kwargs.copy() - for popit in ['field', 'sumup', 'squeeze']: + for popit in ['field', 'sumup', 'squeeze', 'pixel_agg']: kwargs_check.pop(popit) if kwargs_check: raise MagpylibBadUserInput( @@ -322,10 +324,19 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: sens_px_shape = (k,) + pix_shape B = B.reshape((l0,m)+sens_px_shape) - # + # sumup over sources if kwargs['sumup']: B = np.sum(B, axis=0, keepdims=True) + # aggregate pixel values + pixel_agg = kwargs['pixel_agg'] + if pixel_agg is not None: + B = getattr(np, pixel_agg)(B, axis=tuple(range(3-B.ndim,-1))) + if not kwargs['squeeze']: + # add missing dimension since `pixel_agg` reduces pixel + # dimensions to zero. Only needed if `squeeze is False`` + B = np.expand_dims(B, axis=-2) + # reduce all size-1 levels if kwargs['squeeze']: B = np.squeeze(B) diff --git a/magpylib/_src/fields/field_wrap_BH_level3.py b/magpylib/_src/fields/field_wrap_BH_level3.py index dce4ac868..664dfbce0 100644 --- a/magpylib/_src/fields/field_wrap_BH_level3.py +++ b/magpylib/_src/fields/field_wrap_BH_level3.py @@ -1,7 +1,9 @@ from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 -def getB(sources=None, observers=None, sumup=False, squeeze=True, **kwargs): +def getB( + sources=None, observers=None, sumup=False, squeeze=True, pixel_agg=None, **kwargs +): """Compute B-field in [mT] for given sources and observers. Field implementations can be directly accessed (avoiding the object oriented @@ -34,6 +36,10 @@ def getB(sources=None, observers=None, sumup=False, squeeze=True, **kwargs): If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only a single sensor or only a single source) are eliminated. + pixel_agg: str, default=`None` + A compatible numpy aggregator string (e.g. `'min', 'max', 'mean'`) which applies on pixel + output values. + Other Parameters (Direct interface) ----------------------------------- position: array_like, shape (3,) or (n,3), default=`(0,0,0)` @@ -138,10 +144,20 @@ def getB(sources=None, observers=None, sumup=False, squeeze=True, **kwargs): [ 1.14713551 2.29427102 -0.22065346] [-2.48213467 -2.48213467 -0.79683487]] """ - return getBH_level2(sources, observers, sumup=sumup, squeeze=squeeze, field='B', **kwargs) - - -def getH(sources=None, observers=None, sumup=False, squeeze=True, **kwargs): + return getBH_level2( + sources, + observers, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + field="B", + **kwargs + ) + + +def getH( + sources=None, observers=None, sumup=False, squeeze=True, pixel_agg=None, **kwargs +): """Compute H-field in [kA/m] for given sources and observers. Field implementations can be directly accessed (avoiding the object oriented @@ -174,6 +190,10 @@ def getH(sources=None, observers=None, sumup=False, squeeze=True, **kwargs): If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only a single sensor or only a single source) are eliminated. + pixel_agg: str, default=`None` + A compatible numpy aggregator string (e.g. `'min', 'max', 'mean'`) which applies on pixel + output values. + Other Parameters (Direct interface) ----------------------------------- position: array_like, shape (3,) or (n,3), default=`(0,0,0)` @@ -278,4 +298,12 @@ def getH(sources=None, observers=None, sumup=False, squeeze=True, **kwargs): [ 0.91286143 1.82572286 -0.17559045] [-1.97522001 -1.97522001 -0.63410104]] """ - return getBH_level2(sources, observers, sumup=sumup, squeeze=squeeze, field='H', **kwargs) + return getBH_level2( + sources, + observers, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + field="H", + **kwargs + ) diff --git a/magpylib/_src/obj_classes/class_BaseGetBH.py b/magpylib/_src/obj_classes/class_BaseGetBH.py index 148970e0c..ae07948e5 100644 --- a/magpylib/_src/obj_classes/class_BaseGetBH.py +++ b/magpylib/_src/obj_classes/class_BaseGetBH.py @@ -7,10 +7,9 @@ class BaseGetBH: - """provides getB and getH methods for source objects - """ + """provides getB and getH methods for source objects""" - def getB(self, *observers, squeeze=True): + def getB(self, *observers, squeeze=True, pixel_agg=None): """Compute the B-field in units of [mT] generated by the source. Parameters @@ -57,10 +56,16 @@ def getB(self, *observers, squeeze=True): >>> [ -0.77638847 0. -5.15004352]]] """ observers = format_star_input(observers) - return getBH_level2(self, observers, sumup=False, squeeze=squeeze, field='B') - - - def getH(self, *observers, squeeze=True): + return getBH_level2( + self, + observers, + sumup=False, + squeeze=squeeze, + pixel_agg=pixel_agg, + field="B", + ) + + def getH(self, *observers, squeeze=True, pixel_agg=None): """Compute the H-field in units of [kA/m] generated by the source. Parameters @@ -109,4 +114,11 @@ def getH(self, *observers, squeeze=True): [ -0.61783031 0. -4.09827441]]] """ observers = format_star_input(observers) - return getBH_level2(self, observers, sumup=False, squeeze=squeeze, field='H') + return getBH_level2( + self, + observers, + sumup=False, + squeeze=squeeze, + pixel_agg=pixel_agg, + field="H", + ) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 344ce24bc..f39367811 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -15,13 +15,13 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput + class BaseCollection(BaseDisplayRepr): - """ Collection base class without BaseGeo properties - """ + """Collection base class without BaseGeo properties""" def __init__(self, *children): - self._object_type = 'Collection' + self._object_type = "Collection" BaseDisplayRepr.__init__(self) @@ -85,11 +85,11 @@ def __repr__(self) -> str: s = super().__repr__() if self._children: if not self._sources: - pref = 'Sensor' + pref = "Sensor" elif not self._sensors: - pref = 'Source' + pref = "Source" else: - pref = 'Mixed' + pref = "Mixed" return f"{pref}{s}" return s @@ -169,7 +169,6 @@ def remove(self, child): self._update_src_and_sens() return self - def set_children_styles(self, arg=None, **kwargs): """Set display style of all children in the collection. Only matching properties will be applied. Input can be a style dict or style underscore magic. @@ -235,8 +234,7 @@ def _validate_getBH_inputs(self, *children): sources, sensors = self, children return sources, sensors - - def getB(self, *sources_observers, squeeze=True): + def getB(self, *sources_observers, squeeze=True, pixel_agg=None): """Compute B-field in [mT] for given sources and observer inputs. Parameters @@ -284,10 +282,16 @@ def getB(self, *sources_observers, squeeze=True): sources, sensors = self._validate_getBH_inputs(*sources_observers) - return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field='B') - + return getBH_level2( + sources, + sensors, + sumup=False, + squeeze=squeeze, + pixel_agg=pixel_agg, + field="B", + ) - def getH(self, *children, squeeze=True): + def getH(self, *children, squeeze=True, pixel_agg=None): """Compute H-field in [kA/m] for given sources and observer inputs. Parameters @@ -335,11 +339,18 @@ def getH(self, *children, squeeze=True): sources, sensors = self._validate_getBH_inputs(*children) - return getBH_level2(sources, sensors, sumup=False, squeeze=squeeze, field='H') + return getBH_level2( + sources, + sensors, + sumup=False, + squeeze=squeeze, + pixel_agg=pixel_agg, + field="H", + ) class Collection(BaseGeo, BaseCollection): - """ Group multiple children (sources and sensors) in one Collection for + """Group multiple children (sources and sensors) in one Collection for common manipulation. Collections can be used as `sources` and `observers` input for magnetic field @@ -424,6 +435,10 @@ class Collection(BaseGeo, BaseCollection): [ 0.00126232 -0.00093169 -0.00034448] """ - def __init__(self, *args, position=(0,0,0), orientation=None, style=None, **kwargs): - BaseGeo.__init__(self, position=position, orientation=orientation, style=style, **kwargs) + def __init__( + self, *args, position=(0, 0, 0), orientation=None, style=None, **kwargs + ): + BaseGeo.__init__( + self, position=position, orientation=orientation, style=style, **kwargs + ) BaseCollection.__init__(self, *args) diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 27d3e3c5f..65ab33387 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -10,7 +10,7 @@ class Sensor(BaseGeo, BaseDisplayRepr): - """ Magnetic field sensor. + """Magnetic field sensor. Can be used as `observers` input for magnetic field computation. @@ -82,7 +82,7 @@ def __init__( # instance attributes self.pixel = pixel - self._object_type = 'Sensor' + self._object_type = "Sensor" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -91,24 +91,23 @@ def __init__( # property getters and setters @property def pixel(self): - """ Sensor pixel attribute getter and setter.""" + """Sensor pixel attribute getter and setter.""" return self._pixel @pixel.setter def pixel(self, pix): - """ Set sensor pixel positions in the local sensor coordinates. + """Set sensor pixel positions in the local sensor coordinates. Must be an array_like, float compatible with shape (..., 3) """ self._pixel = check_format_input_vector( pix, - dims=range(1,20), + dims=range(1, 20), shape_m1=3, - sig_name='pixel', - sig_type='array_like (list, tuple, ndarray) with shape (n1, n2, ..., 3)', + sig_name="pixel", + sig_type="array_like (list, tuple, ndarray) with shape (n1, n2, ..., 3)", ) - - def getB(self, *sources, sumup=False, squeeze=True): + def getB(self, *sources, sumup=False, squeeze=True, pixel_agg=None): """Compute the B-field in units of [mT] as seen by the sensor. Parameters @@ -160,10 +159,11 @@ def getB(self, *sources, sumup=False, squeeze=True): [0. 1.01415383 1.01415383]] """ sources = format_star_input(sources) - return getBH_level2(sources, self, sumup=sumup, squeeze=squeeze, field='B') - + return getBH_level2( + sources, self, sumup=sumup, squeeze=squeeze, pixel_agg=pixel_agg, field="B" + ) - def getH(self, *sources, sumup=False, squeeze=True): + def getH(self, *sources, sumup=False, squeeze=True, pixel_agg=None): """Compute the H-field in units of [kA/m] as seen by the sensor. Parameters @@ -215,4 +215,6 @@ def getH(self, *sources, sumup=False, squeeze=True): [0. 0.80703798 0.80703798]] """ sources = format_star_input(sources) - return getBH_level2(sources, self, sumup=sumup, squeeze=squeeze, field='H') + return getBH_level2( + sources, self, sumup=sumup, squeeze=squeeze, pixel_agg=pixel_agg, field="H" + ) From 13c00c60407490f45cbf4a41530a9031a97be283 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 14:00:37 +0100 Subject: [PATCH 043/207] fixing and adding tests --- magpylib/_src/fields/field_wrap_BH_level2.py | 3 +- magpylib/_src/input_checks.py | 3 +- magpylib/_src/obj_classes/class_Collection.py | 40 +++- magpylib/_src/utility.py | 4 +- tests/test_Coumpound_setters.py | 5 +- tests/test_getBH_level2.py | 43 ++-- tests/test_input_checks.py | 7 +- tests/test_obj_Collection.py | 72 +++--- tests/test_obj_Collection_child_parent.py | 208 ++++++++++++++++++ tests/test_obj_Collection_v4motion.py | 5 +- 10 files changed, 300 insertions(+), 90 deletions(-) create mode 100644 tests/test_obj_Collection_child_parent.py diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 3ce0eb8f7..a16f13d24 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -12,6 +12,7 @@ check_excitations, check_dimensions, check_format_input_observers, + check_format_input_obj ) @@ -196,7 +197,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: ) # format sources input: - # input: allow only bare src objects or 1D lists/tuple of src and col + # input: allow only one bare src object or a 1D lists/tuple of src and col # out: sources = ordered list of sources # out: src_list = ordered list of sources with flattened collections sources, src_list = format_src_inputs(sources) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index e91701c8a..ad84ab7f4 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -434,7 +434,7 @@ def check_format_input_observers(inp): def check_format_input_obj( - inp: Sequence, + inp, allow: str, recursive = True, typechecks = False, @@ -453,7 +453,6 @@ def check_format_input_obj( recursive: bool Flatten Collection objects """ - # select wanted wanted_types = [] if "sources" in allow.split("+"): diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index fbdbf3cba..246563787 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -188,11 +188,18 @@ def add(self, *children, override_parent=False): In this example we add a sensor object to a collection: >>> import magpylib as magpy - >>> col = magpy.Collection() - >>> sens = magpy.Sensor() - >>> col.add(sens) - >>> print(col.children) - [Sensor(id=2236606343584)] + >>> x1 = magpy.Sensor(style_label='x1') + >>> coll = magpy.Collection(x1, style_label='coll') + >>> coll.describe(labels=True) + coll + └── x1 + + >>> x2 = magpy.Sensor(style_label='x2') + >>> coll.add(x2) + >>> coll.describe(labels=True) + coll + ├── x1 + └── x2 """ # pylint: disable=protected-access # check and format input @@ -240,14 +247,14 @@ def remove(self, *children, recursive=True, errors='raise'): Parameters ---------- - child: child object - Remove the given child from the collection. + children: child objects + Remove the given children from the collection. errors: str, default=`'raise'` - Can be 'raise' or 'ignore'. + Can be `'raise'` or `'ignore'`. recursive: bool, default=`True` - Continue search also in child collections. + Remove children also from child collections. Returns ------- @@ -272,7 +279,7 @@ def remove(self, *children, recursive=True, errors='raise'): └── x2 """ #pylint: disable=protected-access - + # check and format input remove_objects = check_format_input_obj( children, @@ -490,8 +497,17 @@ class Collection(BaseGeo, BaseCollection): Parameters ---------- - children: sources, sensors, collections or arbitrary lists thereof - Ordered list of all children. + children: sources, sensors or collection objects + An ordered list of all children in collection. + + sensors: sensor objects + An ordered list of all sensor objects in collection. + + sources: source objects + An ordered list of all source objects in collection. + + collections: collection objects + An ordered list of all collection objects in collection. position: array_like, shape (3,) or (m,3), default=`(0,0,0)` Object position(s) in the global coordinates in units of [mm]. For m>1, the diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 17ec0d167..a2c77604c 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -132,10 +132,10 @@ def format_src_inputs(sources) -> list: # if bare source make into list if not isinstance(sources, (list, tuple)): sources = [sources] - + if not sources: raise MagpylibBadUserInput(wrong_obj_msg(allow="sources")) - + for src in sources: obj_type = getattr(src, "_object_type", "") if obj_type == "Collection": diff --git a/tests/test_Coumpound_setters.py b/tests/test_Coumpound_setters.py index 5594bf68e..2f44216b9 100644 --- a/tests/test_Coumpound_setters.py +++ b/tests/test_Coumpound_setters.py @@ -20,13 +20,12 @@ def make_wheel(Ncubes=6, height=10, diameter=36, path_len=5, label=None): s0 = cs_lambda().rotate_from_angax( np.linspace(0.0, 360.0, Ncubes, endpoint=False), "z", anchor=(0, 0, 0), start=0 ) - cs_list = [] + c = magpy.Collection() for ind in range(Ncubes): s = cs_lambda() s.position = s0.position[ind] s.orientation = s0.orientation[ind] - cs_list.append(s) - c = magpy.Collection(cs_list) + c.add(s) c.rotate_from_angax(90, "x") c.rotate_from_angax( np.linspace(90, 360, path_len), axis="z", start=0, anchor=(80, 0, 0) diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py index 841a3e61e..649af1f7c 100644 --- a/tests/test_getBH_level2.py +++ b/tests/test_getBH_level2.py @@ -13,10 +13,10 @@ def test_getB_level2_input_simple(): pm2 = magpy.magnet.Cuboid(mag,dim_cuboid) pm3 = magpy.magnet.Cylinder(mag,dim_cyl) pm4 = magpy.magnet.Cylinder(mag,dim_cyl) - col1 = magpy.Collection([pm1]) - col2 = magpy.Collection([pm1,pm2]) - col3 = magpy.Collection([pm1,pm2,pm3]) - col4 = sum([pm1,pm2,pm3,pm4]) + col1 = magpy.Collection(pm1.copy()) + col2 = magpy.Collection(pm1.copy(),pm2.copy()) + col3 = magpy.Collection(pm1.copy(),pm2.copy(),pm3.copy()) + col4 = magpy.Collection(pm1.copy(),pm2.copy(),pm3.copy(),pm4.copy()) pos_obs = (1,2,3) sens1 = magpy.Sensor(position=pos_obs) sens2 = magpy.Sensor(pixel=pos_obs) @@ -61,20 +61,19 @@ def test_getB_level2_input_shape22(): mag = (1,2,3) dim_cuboid = (1,2,3) dim_cyl = (1,2) - pm1 = magpy.magnet.Cuboid(mag,dim_cuboid) - pm2 = magpy.magnet.Cuboid(mag,dim_cuboid) - pm3 = magpy.magnet.Cylinder(mag,dim_cyl) - pm4 = magpy.magnet.Cylinder(mag,dim_cyl) - col1 = magpy.Collection([pm1]) - col2 = magpy.Collection([pm1,pm2]) - col3 = magpy.Collection([pm1,pm2,pm3]) - col4 = magpy.Collection([pm1,pm2,pm3,pm4]) - pos_obs = [[(1,2,3),(1,2,3)],[(1,2,3),(1,2,3)]] + pm1 = lambda: magpy.magnet.Cuboid(mag, dim_cuboid) + pm2 = lambda: magpy.magnet.Cuboid(mag, dim_cuboid) + pm3 = lambda: magpy.magnet.Cylinder(mag, dim_cyl) + pm4 = lambda: magpy.magnet.Cylinder(mag, dim_cyl) + col1 = magpy.Collection(pm1()) + col2 = magpy.Collection(pm1(), pm2()) + col3 = magpy.Collection(pm1(), pm2(), pm3()) + col4 = magpy.Collection(pm1(), pm2(), pm3(), pm4()) + pos_obs = [[(1,2,3), (1,2,3)], [(1,2,3), (1,2,3)]] sens1 = magpy.Sensor(pixel=pos_obs) - fb22 = magpy.getB(pm1,pos_obs) - fc22 = magpy.getB(pm3,pos_obs) - + fb22 = magpy.getB(pm1(), pos_obs) + fc22 = magpy.getB(pm3(), pos_obs) for poso,fb,fc in zip([pos_obs,sens1,[sens1,sens1,sens1]], [fb22,fb22,[fb22,fb22,fb22]], @@ -83,16 +82,16 @@ def test_getB_level2_input_shape22(): fb = np.array(fb) fc = np.array(fc) src_obs_res = [ - [pm1, poso, fb], - [pm3, poso, fc], - [[pm1,pm2], poso, [fb,fb]], - [[pm1,pm2,pm3], poso, [fb,fb,fc]], + [pm1(), poso, fb], + [pm3(), poso, fc], + [[pm1(),pm2()], poso, [fb,fb]], + [[pm1(),pm2(),pm3()], poso, [fb,fb,fc]], [col1, poso, fb], [col2, poso, 2*fb], [col3, poso, 2*fb+fc], [col4, poso, 2*fb+2*fc], - [[pm1,col1], poso, [fb, fb]], - [[pm1,col1,col2,pm2,col4], poso , [fb,fb,2*fb,fb,2*fb+2*fc]], + [[pm1(),col1], poso, [fb, fb]], + [[pm1(),col1,col2,pm2(),col4], poso , [fb,fb,2*fb,fb,2*fb+2*fc]], ] for sor in src_obs_res: diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index f3f356c13..1d92cdc1d 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -585,15 +585,16 @@ def test_input_observer_good(): pos_vec3 = [[(1,2,3)]*2]*3 sens1 = magpy.Sensor() sens2 = magpy.Sensor() - sens3 = magpy.Sensor(pixel=pos_vec3) + sens3 = magpy.Sensor() + sens4 = magpy.Sensor(pixel=pos_vec3) coll1 = magpy.Collection(sens1) - coll2 = magpy.Collection(sens1, sens2) + coll2 = magpy.Collection(sens2, sens3) goods = [ sens1, coll1, coll2, pos_vec1, pos_vec2, pos_vec3, - [sens1, coll1], [sens1, coll2], [sens1, pos_vec1], [sens3, pos_vec3], + [sens1, coll1], [sens1, coll2], [sens1, pos_vec1], [sens4, pos_vec3], [pos_vec1, coll1], [pos_vec1, coll2], [sens1, coll1, pos_vec1], [sens1, coll1, sens2, pos_vec1] diff --git a/tests/test_obj_Collection.py b/tests/test_obj_Collection.py index 8c2935774..98bc04ce5 100644 --- a/tests/test_obj_Collection.py +++ b/tests/test_obj_Collection.py @@ -49,7 +49,7 @@ def test_Collection_basics(): ) mags, dims2, dims3, posos, angs, axs, anchs, movs, rvs, _ = data - B1, B2, B3 = [], [], [] + B1, B2 = [], [] for mag, dim2, dim3, ang, ax, anch, mov, poso, rv in zip( mags, dims2, dims3, angs, axs, anchs, movs, posos, rvs ): @@ -69,14 +69,9 @@ def test_Collection_basics(): pm5 = magpy.magnet.Cylinder(mag[4], dim2[1]) pm6 = magpy.magnet.Cylinder(mag[5], dim2[2]) - col1 = magpy.Collection(pm1, [pm2, pm3]) - col1 += pm4 - col2 = magpy.Collection(pm5, pm6) - col1 += col2 - col1 - pm5 - pm4 - col1.remove(pm1) - col3 = col1 + pm5 + pm4 + pm1 - col1.add(pm5, pm4, pm1) + col1 = magpy.Collection(pm1, pm2, pm3) + col1.add(pm4, pm5, pm6) + # 18 subsequent operations for a, aa, aaa, mv in zip(ang, ax, anch, mov): @@ -87,14 +82,11 @@ def test_Collection_basics(): B1 += [magpy.getB([pm1b, pm2b, pm3b, pm4b, pm5b, pm6b], poso, sumup=True)] B2 += [col1.getB(poso)] - B3 += [col3.getB(poso)] B1 = np.array(B1) B2 = np.array(B2) - B3 = np.array(B3) - assert np.allclose(B1, B2), "Collection testfail1" - assert np.allclose(B1, B3), "Collection testfail2" + np.testing.assert_allclose(B1, B2) @pytest.mark.parametrize( @@ -264,35 +256,27 @@ def test_Collection_with_Dipole(): assert np.allclose(B, Btest) -def test_repr_collection(): - """test __repr__""" - pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) - pm2 = magpy.magnet.Cylinder((1, 2, 3), (2, 3)) - sens = magpy.Sensor() - col = magpy.Collection() - col.sources = pm1, pm2 - assert "Source" in col.__repr__(), "Collection repr failed" - col.sensors = [sens] - assert "Mixed" in col.__repr__(), "Collection repr failed" - col.sources = [] - assert "Sensor" in col.__repr__(), "Collection repr failed" - - def test_adding_sources(): """test if all sources can be added""" - src1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) - src2 = magpy.magnet.Cylinder((1, 2, 3), (1, 2)) - src3 = magpy.magnet.Sphere((1, 2, 3), 1) - src4 = magpy.current.Loop(1, 1) - src5 = magpy.current.Line(1, [(1, 2, 3), (2, 3, 4)]) - src6 = magpy.misc.Dipole((1, 2, 3)) - col = src1 + src2 + src3 + src4 + src5 + src6 + s1 = magpy.magnet.Cuboid() + s2 = magpy.magnet.Cylinder() + s3 = magpy.magnet.CylinderSegment() + s4 = magpy.magnet.Sphere() + s5 = magpy.current.Loop() + s6 = magpy.current.Line() + s7 = magpy.misc.Dipole() + x1 = magpy.Sensor() + c1 = magpy.Collection() + c2 = magpy.Collection() + + for obj in [s1, s2, s3, s4, s5, s6, s7, x1, c1]: + c2.add(obj) strs = "" - for src in col: + for src in c2: strs += str(src)[:3] - assert strs == "CubCylSphLooLinDip" + assert strs == "CubCylCylSphLooLinDipSenCol" def test_set_children_styles(): @@ -311,13 +295,19 @@ def test_set_children_styles(): def test_reprs(): """test repr strings""" - s1 = magpy.magnet.Sphere((1,2,3), 5) - x1 = magpy.Sensor() + c = magpy.Collection() assert repr(c)[:10]=='Collection' + + s1 = magpy.magnet.Sphere((1,2,3), 5) c = magpy.Collection(s1) - assert repr(c)[:10]=='SourceColl' + assert repr(c)[:10]=='Collection' + + x1 = magpy.Sensor() c = magpy.Collection(x1) - assert repr(c)[:10]=='SensorColl' + assert repr(c)[:10]=='Collection' + + x1 = magpy.Sensor() + s1 = magpy.magnet.Sphere((1,2,3), 5) c = magpy.Collection(s1,x1) - assert repr(c)[:10]=='MixedColle' + assert repr(c)[:10]=='Collection' diff --git a/tests/test_obj_Collection_child_parent.py b/tests/test_obj_Collection_child_parent.py new file mode 100644 index 000000000..17edbef2d --- /dev/null +++ b/tests/test_obj_Collection_child_parent.py @@ -0,0 +1,208 @@ +import numpy as np +import magpylib as magpy + +def test_parent_setter(): + """ setting and removing a parent""" + child_labels = lambda x: [c.style.label for c in x] + + # default parent is None + x1 = magpy.Sensor(style_label='x1') + assert x1.parent is None + + # init collection gives parent + c1 = magpy.Collection(x1, style_label='c1') + assert x1.parent.style.label == 'c1' + assert child_labels(c1) == ['x1'] + + # remove parent with setter + x1.parent=None + assert x1.parent is None + assert child_labels(c1) == [] + + # set parent + x1.parent = c1 + assert x1.parent.style.label == 'c1' + assert child_labels(c1) == ['x1'] + + # set another parent + c2 = magpy.Collection(style_label='c2') + x1.parent = c2 + assert x1.parent.style.label == 'c2' + assert child_labels(c1) == [] + assert child_labels(c2) == ['x1'] + + +def test_collection_inputs(): + """ test basic collection inputs""" + + s1 = magpy.magnet.Cuboid(style_label='s1') + s2 = magpy.magnet.Cuboid(style_label='s2') + s3 = magpy.magnet.Cuboid(style_label='s3') + x1 = magpy.Sensor(style_label='x1') + x2 = magpy.Sensor(style_label='x2') + c1 = magpy.Collection(x2, style_label='c1') + + c2 = magpy.Collection(c1, x1, s1, s2, s3) + assert [c.style.label for c in c2.children] == ['c1', 'x1', 's1', 's2', 's3'] + assert [c.style.label for c in c2.sensors] == ['x1'] + assert [c.style.label for c in c2.sources] == ['s1', 's2', 's3'] + assert [c.style.label for c in c2.collections] == ['c1'] + + +def test_collection_parent_child_relation(): + """ test if parent-child relations are properly set with collections""" + + s1 = magpy.magnet.Cuboid() + s2 = magpy.magnet.Cuboid() + s3 = magpy.magnet.Cuboid() + x1 = magpy.Sensor() + x2 = magpy.Sensor() + c1 = magpy.Collection(x2) + c2 = magpy.Collection(c1, x1, s1, s2, s3) + + assert x1.parent == c2 + assert s3.parent == c2 + assert x2.parent == c1 + assert c1.parent == c2 + assert c2.parent is None + + +def test_collections_add(): + """ test collection construction""" + child_labels = lambda x: [c.style.label for c in x] + + x1 = magpy.Sensor(style_label='x1') + x2 = magpy.Sensor(style_label='x2') + x3 = magpy.Sensor(style_label='x3') + x6 = magpy.Sensor(style_label='x6') + x7 = magpy.Sensor(style_label='x7') + + # simple add + c2 = magpy.Collection(x1, style_label='c2') + c2.add(x2, x3) + assert child_labels(c2) == ['x1', 'x2', 'x3'] + + # adding another collection + c3 = magpy.Collection(x6, style_label='c3') + c2.add(c3) + assert child_labels(c2) == ['x1', 'x2', 'x3', 'c3'] + assert child_labels(c3) == ['x6'] + + # adding to child collection should not change its parent collection + c3.add(x7) + assert child_labels(c2) == ['x1', 'x2', 'x3', 'c3'] + assert child_labels(c3) == ['x6', 'x7'] + + # add with parent override + assert x7.parent == c3 + + c4 = magpy.Collection(style_label='c4') + c4.add(x7, override_parent=True) + + assert child_labels(c3) == ['x6'] + assert child_labels(c4) == ['x7'] + assert x7.parent == c4 + + +def test_collection_plus(): + """ + testing collection adding and the += functionality + """ + child_labels = lambda x: [c.style.label for c in x] + + s1 = magpy.magnet.Cuboid(style_label='s1') + s2 = magpy.magnet.Cuboid(style_label='s2') + x1 = magpy.Sensor(style_label='x1') + x2 = magpy.Sensor(style_label='x2') + x3 = magpy.Sensor(style_label='x3') + c1 = magpy.Collection(s1, style_label='c1') + + # practical simple + + c2 = c1 + s2 + assert child_labels(c2) == ['c1', 's2'] + + # useless triple addition consistency + c3 = x1 + x2 + x3 + assert c3[0][0].style.label == 'x1' + assert c3[0][1].style.label == 'x2' + assert c3[1].style.label == 'x3' + + # useless += consistency + s3 = magpy.magnet.Cuboid(style_label='s3') + c2 += s3 + assert [c.style.label for c in c2[0]] == ['c1', 's2'] + assert c2[1] == s3 + + +def test_collection_remove(): + """ removing from collections""" + child_labels = lambda x: [c.style.label for c in x] + source_labels = lambda x: [c.style.label for c in x.sources] + sensor_labels = lambda x: [c.style.label for c in x.sensors] + + x1 = magpy.Sensor(style_label='x1') + x2 = magpy.Sensor(style_label='x2') + x3 = magpy.Sensor(style_label='x3') + x4 = magpy.Sensor(style_label='x4') + x5 = magpy.Sensor(style_label='x5') + s1 = magpy.misc.Dipole(style_label='s1') + s2 = magpy.misc.Dipole(style_label='s2') + s3 = magpy.misc.Dipole(style_label='s3') + q1 = magpy.misc.CustomSource(style_label='q1') + c1 = magpy.Collection(x1, x2, x3, x4, x5, style_label='c1') + c2 = magpy.Collection(s1, s2, s3, style_label='c2') + c3 = magpy.Collection(q1, c1, c2, style_label='c3') + + assert child_labels(c1) == ['x1', 'x2', 'x3', 'x4', 'x5'] + assert child_labels(c2) == ['s1', 's2', 's3'] + assert child_labels(c3) == ['q1', 'c1', 'c2'] + + # remove item from collection + c1.remove(x5) + assert child_labels(c1) == ['x1', 'x2', 'x3', 'x4'] + assert [c.style.label for c in c1.sensors] == ['x1', 'x2', 'x3', 'x4'] + + # remove 2 items from collection + c1.remove(x3, x4) + assert child_labels(c1) == ['x1', 'x2'] + assert sensor_labels(c1) == ['x1', 'x2'] + + # remove item from child collection + c3.remove(s3) + assert child_labels(c3) == ['q1', 'c1', 'c2'] + assert child_labels(c2) == ['s1', 's2'] + assert source_labels(c2) == ['s1', 's2'] + + # remove child collection + c3.remove(c2) + assert child_labels(c3) == ['q1', 'c1'] + assert child_labels(c2) == ['s1', 's2'] + + # attempt remove non-existant child + c3.remove(s1, errors='ignore') + assert child_labels(c3) == ['q1', 'c1'] + assert child_labels(c1) == ['x1', 'x2'] + + # attempt remove child in lower level with recursion=False + c3.remove(x1, errors='ignore', recursive=False) + assert child_labels(c3) == ['q1', 'c1'] + assert child_labels(c1) == ['x1', 'x2'] + + +def test_collection_nested_getBH(): + """ test if getBH functionality is self-consistent with nesting""" + s1 = magpy.current.Loop(1, 1) + s2 = magpy.current.Loop(1, 1) + s3 = magpy.current.Loop(1, 1) + s4 = magpy.current.Loop(1, 1) + + obs = [(1,2,3), (-2,-3,1), (2,2,-4), (4,2,-4)] + coll = s1 + s2 + s3 + s4 # nasty nesting + + B1 = s1.getB(obs) + B4 = coll.getB(obs) + np.testing.assert_allclose(4*B1, B4) + + H1 = s1.getH(obs) + H4 = coll.getH(obs) + np.testing.assert_allclose(4*H1, H4) \ No newline at end of file diff --git a/tests/test_obj_Collection_v4motion.py b/tests/test_obj_Collection_v4motion.py index a670b3e16..31f78aef1 100644 --- a/tests/test_obj_Collection_v4motion.py +++ b/tests/test_obj_Collection_v4motion.py @@ -250,7 +250,6 @@ def test_Collection_setter(): # magpy.show(*col0) POS = [] ORI = [] - col0 = magpy.Collection() for poz, roz in zip( [(0, 0, 0), (0, 0, 5), (5, 0, 0), (5, 0, 5), (10, 0, 0), (10, 0, 5)], [(0, 0, 0), (1, 0, 0), (0, 1, 0), (0, 0, 1), (1, 2, 3), (-2, -1, 3)], @@ -259,12 +258,10 @@ def test_Collection_setter(): for i in range(5): src = magpy.magnet.Cuboid((1, 0, 0), (0.5, 0.5, 0.5), (1, 0, 0)) src.rotate_from_angax(72 * i, "z", (0, 0, 0)) - col = col + src + col.add(src) col.position = poz col.orientation = R.from_rotvec(roz) - col0 = col0 + col - POS += [[src.position for src in col]] ORI += [[src.orientation.as_rotvec() for src in col]] From 6414d5f892e920f9187b9063b5b87526c6149b1c Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 14:03:02 +0100 Subject: [PATCH 044/207] remove now useless radd --- magpylib/_src/obj_classes/class_BaseGeo.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index d012bfaea..965e24aec 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -254,6 +254,7 @@ def _validate_style(self, val=None): ) return val + # dunders ------------------------------------------------------- def __add__(self, obj): """ Add up sources to a Collection object. @@ -266,16 +267,6 @@ def __add__(self, obj): from magpylib import Collection return Collection(self, obj) - def __radd__(self, other): - """Add up sources to a Collection object. Allows to use `sum(objects)`. - - Returns - ------- - Collection: Collection - """ - if other == 0: - return self - return self.__add__(other) # methods ------------------------------------------------------- def reset_path(self): From efcd8e101a13514bca63e49ff026614e971a2aaf Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 15:18:39 +0100 Subject: [PATCH 045/207] fix copy parent bug --- magpylib/_src/obj_classes/class_BaseGeo.py | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 965e24aec..64e3a890c 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -322,7 +322,14 @@ def copy(self, **kwargs): # pylint: disable=import-outside-toplevel from copy import deepcopy - obj_copy = deepcopy(self) + if not self.parent is None: + parent = self.parent + self.parent = None + obj_copy = deepcopy(self) + self.parent = parent + else: + obj_copy = deepcopy(self) + if getattr(self, "_style", None) is not None: label = self.style.label if label is None: From 8e2e396e91c70d39a05cd6a8d2673a45f0ff1763 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 15:19:02 +0100 Subject: [PATCH 046/207] fix tests and misc --- magpylib/_src/input_checks.py | 1 - magpylib/_src/obj_classes/class_Collection.py | 3 ++- magpylib/_src/utility.py | 8 ++++++-- tests/test_obj_BaseGeo.py | 7 ------- 4 files changed, 8 insertions(+), 11 deletions(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index ad84ab7f4..23ca8ffbd 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -1,7 +1,6 @@ """ input checks code""" import numbers -from typing import Sequence import numpy as np from scipy.spatial.transform import Rotation from magpylib._src.exceptions import ( diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 246563787..9b469c30f 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -231,7 +231,8 @@ def add(self, *children, override_parent=False): def _update_src_and_sens(self): # pylint: disable=protected-access - """updates source and sensor list when a child is added or removed""" + """ updates sources, sensors and collections attributes from children + """ self._sources = [ obj for obj in self._children if obj._object_type in LIBRARY_SOURCES ] diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index a2c77604c..8afec26b6 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -334,9 +334,13 @@ def cyl_field_to_cart(phi, Br, Bphi=None): def rec_obj_remover(parent, child): """ remove known child from parent collection""" # pylint: disable=protected-access + print('tick') for obj in parent: if obj == child: parent._children.remove(child) parent._update_src_and_sens() - elif getattr(obj, "_object_type", "") == "Collection": - rec_obj_remover(obj, child) + return True + if getattr(obj, "_object_type", "") == "Collection": + if rec_obj_remover(obj, child): + break + return None diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 701e2b90f..774c52304 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -346,13 +346,6 @@ def test_kwargs(): bg = BaseGeo((0, 0, 0), None, styl_label="label_02") -def test_bad_sum(): - """test when adding bad objects""" - cuboid = magpy.magnet.Cuboid((1, 1, 1), (1, 1, 1)) - with pytest.raises(MagpylibBadUserInput): - 1 + cuboid - - def test_copy(): """test copying object""" bg1 = BaseGeo((0, 0, 0), None, style_label='label1') #has style From 1277c9ccfdc01f332717d95cad56526c616f47d7 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 26 Mar 2022 18:48:37 +0100 Subject: [PATCH 047/207] refactoring --- magpylib/_src/fields/field_wrap_BH_level2.py | 98 +++++--------------- 1 file changed, 21 insertions(+), 77 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 4cc2c8d39..65b7fec55 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -14,60 +14,14 @@ ) -def tile_mag(group: list, n_pp: int): - """ tile up magnetizations of shape (3,) - """ - mags = np.array([src.magnetization for src in group]) - magv = np.tile(mags, n_pp).reshape((-1, 3)) - return magv - - -def tile_dim_cuboid(group: list, n_pp: int): - """ tile up cuboid dimension - """ - dims = np.array([src.dimension for src in group]) - dimv = np.tile(dims, n_pp).reshape((-1, 3)) - return dimv - - -def tile_dim_cylinder(group: list, n_pp: int): - """ tile up cylinder dimensions. - """ - dims = np.array([src.dimension for src in group]) - dimv = np.tile(dims, n_pp).reshape((-1, 2)) - return dimv - - -def tile_dim_cylinder_segment(group: list, n_pp: int): - """ tile up cylinder segment dimensions. - """ - dims = np.array([src.dimension for src in group]) - dimv = np.tile(dims, n_pp).reshape((-1, 5)) - return dimv - - -def tile_dia(group: list, n_pp: int): - """ tile up diameter - """ - dims = np.array([src.diameter for src in group]) - dimv = np.tile(dims, n_pp).flatten() - return dimv - - -def tile_moment(group: list, n_pp: int): - """ tile up moments of shape (3,) - """ - moms = np.array([src.moment for src in group]) - momv = np.tile(moms, n_pp).reshape((-1, 3)) - return momv - - -def tile_current(group: list, n_pp: int): - """ tile up current inputs - """ - currs = np.array([src.current for src in group]) - currv = np.tile(currs, n_pp).flatten() - return currv +def tile_group_property(group: list, n_pp: int, prop_name: str): + """ tile up group property""" + prop0 = getattr(group[0], prop_name) + out = np.array([getattr(src, prop_name) for src in group]) + out = np.tile(out, n_pp) + if np.isscalar(prop0): + return out.flatten() + return out.reshape((-1, prop0.shape[0])) def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: @@ -94,33 +48,23 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: kwargs = {'source_type': src_type, 'position': posv, 'observer': posov, 'orientation': rotobj} - if src_type == 'Sphere': - magv = tile_mag(group, n_pp) - diav = tile_dia(group, n_pp) - kwargs.update({'magnetization':magv, 'diameter':diav}) - - elif src_type == 'Cuboid': - magv = tile_mag(group, n_pp) - dimv = tile_dim_cuboid(group, n_pp) - kwargs.update({'magnetization':magv, 'dimension':dimv}) - - elif src_type == 'Cylinder': - magv = tile_mag(group, n_pp) - dimv = tile_dim_cylinder(group, n_pp) - kwargs.update({'magnetization':magv, 'dimension':dimv}) - - elif src_type == 'CylinderSegment': - magv = tile_mag(group, n_pp) - dimv = tile_dim_cylinder_segment(group, n_pp) - kwargs.update({'magnetization':magv, 'dimension':dimv}) + if src_type in ('Sphere', 'Cuboid', 'Cylinder', 'CylinderSegment'): + magv = tile_group_property(group, n_pp, 'magnetization') + kwargs.update(magnetization=magv) + if src_type=="Sphere": + diav = tile_group_property(group, n_pp, 'diameter') + kwargs.update(diameter=diav) + else: + dimv = tile_group_property(group, n_pp, 'dimension') + kwargs.update(dimension=dimv) elif src_type == 'Dipole': - momv = tile_moment(group, n_pp) + momv = tile_group_property(group, n_pp, 'moment') kwargs.update({'moment':momv}) elif src_type == 'Loop': - currv = tile_current(group, n_pp) - diav = tile_dia(group, n_pp) + currv = tile_group_property(group, n_pp, 'current') + diav = tile_group_property(group, n_pp, 'diameter') kwargs.update({'current':currv, 'diameter':diav}) elif src_type == 'Line': @@ -189,7 +133,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # bad user inputs mixing getBH_dict kwargs with object oriented interface kwargs_check = kwargs.copy() for popit in ['field', 'sumup', 'squeeze', 'pixel_agg']: - kwargs_check.pop(popit) + kwargs_check.pop(popit, None) if kwargs_check: raise MagpylibBadUserInput( f"Keyword arguments {tuple(kwargs_check.keys())} are only allowed when the source " From 5fe0dcf8b0db5b59f17c05d0b7f21b7e3ee4e9fd Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 22:37:21 +0100 Subject: [PATCH 048/207] completing tests, full coverage --- magpylib/_src/obj_classes/class_Collection.py | 17 +- magpylib/_src/utility.py | 1 - tests/test_input_checks.py | 238 +++++++++++++++--- tests/test_obj_BaseGeo.py | 14 ++ tests/test_obj_Collection_child_parent.py | 89 +++++++ 5 files changed, 319 insertions(+), 40 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 9b469c30f..587600190 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -106,7 +106,7 @@ def sources(self, sources): new_children.append(child) self._children = new_children src_list = format_obj_input(sources, allow="sources") - self.add(src_list) + self.add(*src_list) @property def sensors(self): @@ -125,7 +125,7 @@ def sensors(self, sensors): new_children.append(child) self._children = new_children sens_list = format_obj_input(sensors, allow="sensors") - self.add(sens_list) + self.add(*sens_list) @property def collections(self): @@ -134,10 +134,17 @@ def collections(self): @collections.setter def collections(self, collections): - """Set Collection sub-collections.""" + """Set Collection collections.""" + # pylint: disable=protected-access + new_children = [] + for child in self._children: + if child in self._collections: + child._parent = None + else: + new_children.append(child) + self._children = new_children coll_list = format_obj_input(collections, allow="collections") - self._children = [o for o in self._children if o not in self._collections] - self.add(coll_list) + self.add(*coll_list) # dunders def __iter__(self): diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 8afec26b6..7a4437735 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -334,7 +334,6 @@ def cyl_field_to_cart(phi, Br, Bphi=None): def rec_obj_remover(parent, child): """ remove known child from parent collection""" # pylint: disable=protected-access - print('tick') for obj in parent: if obj == child: parent._children.remove(child) diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index 1d92cdc1d..ca26f4801 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -9,7 +9,7 @@ def test_input_objects_position_good(): - """good positions""" + """good input: magpy.Sensor(position=inp)""" goods = [ (1,2,3), (0,0,0), @@ -27,7 +27,7 @@ def test_input_objects_position_good(): def test_input_objects_position_bad(): - """bad positions""" + """bad input: magpy.Sensor(position=inp)""" bads = [ (1,2), (1,2,3,4), @@ -43,7 +43,7 @@ def test_input_objects_position_bad(): def test_input_objects_pixel_good(): - """good pixel""" + """good input: magpy.Sensor(pixel=inp)""" goods = [ (1,-2,3), (0,0,0), @@ -63,7 +63,7 @@ def test_input_objects_pixel_good(): def test_input_objects_pixel_bad(): - """bad pixel""" + """bad input: magpy.Sensor(pixel=inp)""" bads = [ (1,2), (1,2,3,4), @@ -78,7 +78,7 @@ def test_input_objects_pixel_bad(): def test_input_objects_orientation_good(): - """good orientations (from rotvec)""" + """good input: magpy.Sensor(orientation=inp)""" goods = [ None, (.1,.2,.3), @@ -96,7 +96,7 @@ def test_input_objects_orientation_good(): def test_input_objects_orientation_bad(): - """bad orienations""" + """bad input: magpy.Sensor(orientation=inp)""" bads = [ (1,2), (1,2,3,4), @@ -111,7 +111,7 @@ def test_input_objects_orientation_bad(): def test_input_objects_current_good(): - """good currents""" + """good input: magpy.current.Loop(inp)""" goods = [ None, 0, @@ -131,7 +131,7 @@ def test_input_objects_current_good(): def test_input_objects_current_bad(): - """bad current""" + """bad input: magpy.current.Loop(inp)""" bads = [ (1,2), [(1,2,3,4)]*2, @@ -144,7 +144,7 @@ def test_input_objects_current_bad(): def test_input_objects_diameter_good(): - """good diameter""" + """good input: magpy.current.Loop(diameter=inp)""" goods = [ None, 0, @@ -154,7 +154,7 @@ def test_input_objects_diameter_good(): True, ] for good in goods: - src = magpy.current.Loop(1, good) + src = magpy.current.Loop(diameter=good) if good is None: assert src.diameter is None else: @@ -162,7 +162,7 @@ def test_input_objects_diameter_good(): def test_input_objects_diameter_bad(): - """bad diameter""" + """bad input: magpy.current.Loop(diameter=inp)""" bads = [ (1,2), [(1,2,3,4)]*2, @@ -173,11 +173,12 @@ def test_input_objects_diameter_bad(): -1.123, ] for bad in bads: - np.testing.assert_raises(MagpylibBadUserInput, magpy.current.Loop, 1, bad) + with np.testing.assert_raises(MagpylibBadUserInput): + magpy.current.Loop(diameter=bad) def test_input_objects_vertices_good(): - """good vertices""" + """good input: magpy.current.Line(vertices=inp)""" goods = [ None, ((0,0,0),(0,0,0)), @@ -187,7 +188,7 @@ def test_input_objects_vertices_good(): np.array(((1,2,3),(2,3,4))), ] for good in goods: - src = magpy.current.Line(1, good) + src = magpy.current.Line(vertices=good) if good is None: assert src.vertices is None else: @@ -195,7 +196,7 @@ def test_input_objects_vertices_good(): def test_input_objects_vertices_bad(): - """bad vertices""" + """bad input: magpy.current.Line(vertices=inp)""" bads = [ (1,2), [(1,2,3,4)]*2, @@ -208,11 +209,16 @@ def test_input_objects_vertices_bad(): True, ] for bad in bads: - np.testing.assert_raises(MagpylibBadUserInput, magpy.current.Line, 1, bad) + with np.testing.assert_raises(MagpylibBadUserInput): + magpy.current.Line(vertices=bad) def test_input_objects_magnetization_moment_good(): - """good magnetization and moment""" + """ + good input: + magpy.magnet.Cuboid(magnetization=inp), + magpy.misc.Dipole(moment=inp) + """ goods = [ None, (1,2,3), @@ -232,7 +238,11 @@ def test_input_objects_magnetization_moment_good(): def test_input_objects_magnetization_moment_bad(): - """bad magnetization and moment""" + """ + bad input: + magpy.magnet.Cuboid(magnetization=inp), + magpy.misc.Dipole(moment=inp) + """ bads = [ (1,2), [1,2,3,4], @@ -246,12 +256,13 @@ def test_input_objects_magnetization_moment_bad(): True, ] for bad in bads: - np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.Cuboid, bad) - np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.Dipole, bad) + with np.testing.assert_raises(MagpylibBadUserInput): + magpy.magnet.Cuboid(magnetization=bad) + magpy.misc.Dipole(moment=bad) def test_input_objects_dimension_cuboid_good(): - """good cuboid dimension""" + """good input: magpy.magnet.Cuboid(dimension=inp)""" goods = [ None, (1,2,3), @@ -259,7 +270,7 @@ def test_input_objects_dimension_cuboid_good(): np.array((1,2,3)), ] for good in goods: - src = magpy.magnet.Cuboid((1,1,1), good) + src = magpy.magnet.Cuboid(dimension=good) if good is None: assert src.dimension is None else: @@ -267,7 +278,7 @@ def test_input_objects_dimension_cuboid_good(): def test_input_objects_dimension_cuboid_bad(): - """bad cuboid dimension""" + """bad input: magpy.magnet.Cuboid(dimension=inp)""" bads = [ [-1,2,3], (0,1,2), @@ -282,11 +293,12 @@ def test_input_objects_dimension_cuboid_bad(): True, ] for bad in bads: - np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.Cuboid, (1,1,1), bad) + with np.testing.assert_raises(MagpylibBadUserInput): + magpy.magnet.Cuboid(dimension=bad) def test_input_objects_dimension_cylinder_good(): - """good cylinder dimension""" + """good input: magpy.magnet.Cylinder(dimension=inp)""" goods = [ None, (1,2), @@ -294,7 +306,7 @@ def test_input_objects_dimension_cylinder_good(): np.array((1,2)), ] for good in goods: - src = magpy.magnet.Cylinder((1,1,1), good) + src = magpy.magnet.Cylinder(dimension=good) if good is None: assert src.dimension is None else: @@ -302,7 +314,7 @@ def test_input_objects_dimension_cylinder_good(): def test_input_objects_dimension_cylinder_bad(): - """bad cylinder dimension""" + """bad input: magpy.magnet.Cylinder(dimension=inp)""" bads = [ [-1,2], (0,1), @@ -317,11 +329,12 @@ def test_input_objects_dimension_cylinder_bad(): True, ] for bad in bads: - np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.Cylinder, (1,1,1), bad) + with np.testing.assert_raises(MagpylibBadUserInput): + magpy.magnet.Cylinder(dimension=bad) def test_input_objects_dimension_cylinderSegment_good(): - """good cylinder segment dimension""" + """good input: magpy.magnet.CylinderSegment(dimension=inp)""" goods = [ None, (0,2,3,0,50), @@ -333,7 +346,7 @@ def test_input_objects_dimension_cylinderSegment_good(): (0,2,3,-10,0), ] for good in goods: - src = magpy.magnet.CylinderSegment((1,1,1), good) + src = magpy.magnet.CylinderSegment(dimension=good) if good is None: assert src.dimension is None else: @@ -341,7 +354,7 @@ def test_input_objects_dimension_cylinderSegment_good(): def test_input_objects_dimension_cylinderSegment_bad(): - """bad cylinder segment dimension""" + """good input: magpy.magnet.CylinderSegment(dimension=inp)""" bads = [ (1,2,3,4), (1,2,3,4,5,6), @@ -360,11 +373,12 @@ def test_input_objects_dimension_cylinderSegment_bad(): True ] for bad in bads: - np.testing.assert_raises(MagpylibBadUserInput, magpy.magnet.CylinderSegment, (1,1,1), bad) + with np.testing.assert_raises(MagpylibBadUserInput): + magpy.magnet.CylinderSegment(dimension=bad) def test_input_objects_fiedBHlambda_good(): - """good custom fiedBHlambda""" + """good input: magpy.misc.CustomSource(field_B_lambda=f, field_H_lambda=f)""" def f(x): """3 in 3 out""" return x @@ -375,7 +389,7 @@ def f(x): def test_input_objects_fiedBHlambda_bad(): - """bad custom fiedBlambda""" + """bad input: magpy.misc.CustomSource(field_B_lambda=f, field_H_lambda=f)""" def f(x): """bad fieldBH lambda""" return 1 @@ -629,6 +643,162 @@ def test_input_observer_bad(): for bad in bads: np.testing.assert_raises(MagpylibBadUserInput, src.getB, bad) + +def test_input_collection_good(): + """good inputs: collection(inp)""" + # pylint: disable=unnecessary-lambda + x = lambda : magpy.Sensor() + s = lambda : magpy.magnet.Cuboid() + c = lambda : magpy.Collection() + + goods = [ #unpacked + [x()], [s()], [c()], + [x(), s(), c()], + [x(), x(), s(), s(), c(), c()], + ] + + for good in goods: + col = magpy.Collection(*good) + assert getattr(col, '_object_type', '') == 'Collection' + + +def test_input_collection_bad(): + """bad inputs: collection(inp)""" + # pylint: disable=unnecessary-lambda + x = lambda : magpy.Sensor() + s = lambda : magpy.magnet.Cuboid() + c = lambda : magpy.Collection() + + bads = [ + 'some_string', None, [], True, 1, np.array((1,2,3)), + [x(), s(), c()], + [x(), [s(), c()]], + (x(), s(), c()), + ] + for bad in bads: + np.testing.assert_raises(MagpylibBadUserInput, magpy.Collection, bad) + + +def test_input_collection_add_good(): + """good inputs: collection.add(inp)""" + # pylint: disable=unnecessary-lambda + x = lambda : magpy.Sensor() + s = lambda : magpy.magnet.Cuboid() + c = lambda : magpy.Collection() + + goods = [ #unpacked + [x()], [s()], [c()], + [x(), s(), c()], + [x(), x(), s(), s(), c(), c()], + ] + + for good in goods: + col = magpy.Collection() + col.add(*good) + assert getattr(col, '_object_type', '') == 'Collection' + + +def test_input_collection_add_bad(): + """bad inputs: collection.add(inp)""" + # pylint: disable=unnecessary-lambda + x = lambda : magpy.Sensor() + s = lambda : magpy.magnet.Cuboid() + c = lambda : magpy.Collection() + + bads = [ + 'some_string', None, [], True, 1, np.array((1,2,3)), + [x(), s(), c()], + [x(), [s(), c()]], + (x(), s(), c()), + ] + for bad in bads: + col = magpy.Collection() + np.testing.assert_raises(MagpylibBadUserInput, col.add, bad) + + +def test_input_collection_remove_good(): + """good inputs: collection.remove(inp)""" + x = magpy.Sensor() + s = magpy.magnet.Cuboid() + c = magpy.Collection() + + goods = [ #unpacked + [x], [s], [c], + [x, s, c], + ] + + for good in goods: + col = magpy.Collection(*good) + assert len(col.children) == len(good) + col.remove(*good) + assert len(col.children) == 0 + + +def test_input_collection_remove_bad(): + """bad inputs: collection.remove(inp)""" + x1 = magpy.Sensor() + x2 = magpy.Sensor() + s1 = magpy.magnet.Cuboid() + s2 = magpy.magnet.Cuboid() + c1 = magpy.Collection() + c2 = magpy.Collection() + col = magpy.Collection(x1, x2, s1, s2, c1) + + bads = [ + 'some_string', None, [], True, 1, np.array((1,2,3)), + [x1], + (x2, s1), + [s2, c1], + ] + for bad in bads: + with np.testing.assert_raises(MagpylibBadUserInput): + col.remove(bad) + + # bad errors input + with np.testing.assert_raises(MagpylibBadUserInput): + col.remove(c2, errors='w00t') + + +def test_input_basegeo_parent_setter_good(): + """good inputs: obj.parent=inp""" + x = magpy.Sensor() + c = magpy.Collection() + + goods = [ + c, + None, + ] + + for good in goods: + x.parent = good + assert x.parent == good + + +def test_input_basegeo_parent_setter_bad(): + """bad inputs: obj.parent=inp""" + x = magpy.Sensor() + c = magpy.Collection() + + bads = [ + 'some_string', [], True, 1, np.array((1,2,3)), + [c], + magpy.Sensor(), + magpy.magnet.Cuboid(), + ] + + for bad in bads: + with np.testing.assert_raises(MagpylibBadUserInput): + x.parent=bad + + # when obj is good but has already a parent + x = magpy.Sensor() + magpy.Collection(x) + with np.testing.assert_raises(MagpylibBadUserInput): + magpy.Collection(x) + + + + ########################################################### ########################################################### # GET BH diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 774c52304..5ab328317 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -366,3 +366,17 @@ def test_copy(): # check if style is passed correctly assert bg2c.style.color == "orange" + + +def test_copy_parents(): + """ make sure that parents are not copied""" + x1 = magpy.Sensor() + x2 = magpy.Sensor() + x3 = magpy.Sensor() + + c = x1 + x2 + x3 + + y = x1.copy() + + assert x1.parent.parent == c + assert y.parent is None diff --git a/tests/test_obj_Collection_child_parent.py b/tests/test_obj_Collection_child_parent.py index 17edbef2d..ae55435e8 100644 --- a/tests/test_obj_Collection_child_parent.py +++ b/tests/test_obj_Collection_child_parent.py @@ -1,5 +1,6 @@ import numpy as np import magpylib as magpy +from magpylib._src.exceptions import MagpylibBadUserInput def test_parent_setter(): """ setting and removing a parent""" @@ -32,6 +33,90 @@ def test_parent_setter(): assert child_labels(c2) == ['x1'] +def test_children_setter(): + """ setting new children and removing old parents""" + x1 = magpy.Sensor() + x2 = magpy.Sensor() + x3 = magpy.Sensor() + x4 = magpy.Sensor() + + c = magpy.Collection(x1, x2) + c.children = [x3, x4] + + # remove old parents + assert x1.parent is None + assert x2.parent is None + # new children + assert c[0] == x3 + assert c[1] == x4 + + +def test_sensors_setter(): + """ setting new sensors and removing old parents""" + x1 = magpy.Sensor() + x2 = magpy.Sensor() + x3 = magpy.Sensor() + x4 = magpy.Sensor() + s1 = magpy.magnet.CylinderSegment() + + c = magpy.Collection(x1, x2, s1) + c.sensors = [x3, x4] + + # remove old parents + assert x1.parent is None + assert x2.parent is None + # keep non-sensors + assert s1.parent == c + # new sensors + assert c[0] == s1 + assert c.sensors[0] == x3 + assert c.sensors[1] == x4 + + +def test_sources_setter(): + """ setting new sources and removing old parents""" + s1 = magpy.magnet.Cylinder() + s2 = magpy.magnet.Cylinder() + s3 = magpy.magnet.Cylinder() + s4 = magpy.magnet.Cylinder() + x1 = magpy.Sensor() + + c = magpy.Collection(x1, s1, s2) + c.sources = [s3, s4] + + # old parents + assert s1.parent is None + assert s2.parent is None + # keep non-sources + assert x1.parent == c + # new children + assert c[0] == x1 + assert c.sources[0] == s3 + assert c[2] == s4 + + +def test_collections_setter(): + """ setting new sources and removing old parents""" + c1 = magpy.Collection() + c2 = magpy.Collection() + c3 = magpy.Collection() + c4 = magpy.Collection() + x1 = magpy.Sensor() + + c = magpy.Collection(c1, x1, c2) + c.collections = [c3, c4] + + # old parents + assert c1.parent is None + assert c2.parent is None + # keep non-collections + assert x1.parent == c + # new children + assert c[0] == x1 + assert c.collections[0] == c3 + assert c[2] == c4 + + def test_collection_inputs(): """ test basic collection inputs""" @@ -188,6 +273,10 @@ def test_collection_remove(): assert child_labels(c3) == ['q1', 'c1'] assert child_labels(c1) == ['x1', 'x2'] + # attempt remove of non-existing child + with np.testing.assert_raises(MagpylibBadUserInput): + c3.remove(x1, errors='raise', recursive=False) + def test_collection_nested_getBH(): """ test if getBH functionality is self-consistent with nesting""" From f69c3dae3e5abfa8fca234875e409043adf65355 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 22:40:22 +0100 Subject: [PATCH 049/207] pylint --- magpylib/_src/display/plotly/plotly_display.py | 1 + magpylib/_src/fields/field_wrap_BH_level2.py | 1 - magpylib/_src/utility.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py index 8363dafdd..d1fbdb80c 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -696,6 +696,7 @@ def draw_frame(obj_list_semi_flat, color_sequence, zoom, autosize=None, **kwargs traces_dicts, kwargs: dict, dict returns the traces in a obj/traces_list dictionary and updated kwargs """ + # pylint: disable=protected-access return_autosize = False Sensor = _src.obj_classes.Sensor Dipole = _src.obj_classes.Dipole diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index a16f13d24..61bca996c 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -12,7 +12,6 @@ check_excitations, check_dimensions, check_format_input_observers, - check_format_input_obj ) diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 7a4437735..55b2c2675 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -128,7 +128,7 @@ def format_src_inputs(sources) -> list: # store all sources here src_list = [] - + # if bare source make into list if not isinstance(sources, (list, tuple)): sources = [sources] From 7b95f963653e6d68e0fd684b6ba28f01f55e8219 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 26 Mar 2022 22:41:39 +0100 Subject: [PATCH 050/207] meaningful renaming --- magpylib/_src/fields/field_wrap_BH_level2.py | 26 +++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 65b7fec55..c140eb11b 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -182,17 +182,17 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # all obj paths that are shorter than max-length are filled up with the last # postion/orientation of the object (static paths) path_lengths = [len(obj._position) for obj in obj_list] - m = max(path_lengths) + max_path_len = max(path_lengths) # objects to tile up and reset below - mask_reset = [m!=pl for pl in path_lengths] + mask_reset = [max_path_len!=pl for pl in path_lengths] reset_obj = [obj for obj,mask in zip(obj_list,mask_reset) if mask] reset_obj_m0 = [pl for pl,mask in zip(path_lengths,mask_reset) if mask] - if m>1: + if max_path_len>1: for obj,m0 in zip(reset_obj, reset_obj_m0): # length to be tiled - m_tile = m-m0 + m_tile = max_path_len-m0 # tile up position tile_pos = np.tile(obj._position[-1], (m_tile,1)) obj._position = np.concatenate((obj._position, tile_pos)) @@ -209,7 +209,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: for sens in sensors] poso = np.concatenate(poso,axis=1).reshape(-1,3) n_pp = len(poso) - n_pix = int(n_pp/m) + n_pix = int(n_pp/max_path_len) # group similar source types---------------------------------------------- groups = {} @@ -224,13 +224,13 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: groups[group_key]['order'].append(ind) # evaluate each group in one vectorized step ------------------------------- - B = np.empty((l,m,n_pix,3)) # allocate B + B = np.empty((l,max_path_len,n_pix,3)) # allocate B for group in groups.values(): lg = len(group['sources']) gr = group['sources'] src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 - B_group = getBH_level1(field=kwargs['field'], **src_dict) # compute field - B_group = B_group.reshape((lg,m,n_pix,3)) # reshape (2% slower for large arrays) + B_group = getBH_level1(field=kwargs['field'], **src_dict) # compute field + B_group = B_group.reshape((lg,max_path_len,n_pix,3)) # reshape (2% slower for large arrays) for i in range(lg): # put into dedicated positions in B B[group['order'][i]] = B_group[i] @@ -252,13 +252,15 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # select part where rot is applied Bpart = B[:,:,i*k_pixel:(i+1)*k_pixel] # change shape from (l0,m,k_pixel,3) to (P,3) for rot package - Bpart_flat = np.reshape(Bpart, (k_pixel*l0*m,3)) + Bpart_flat = np.reshape(Bpart, (k_pixel*l0*max_path_len,3)) # apply sensor rotation Bpart_flat_rot = sens._orientation[0].inv().apply(Bpart_flat) # overwrite Bpart in B - B[:,:,i*k_pixel:(i+1)*k_pixel] = np.reshape(Bpart_flat_rot, (l0,m,k_pixel,3)) + B[:,:,i*k_pixel:(i+1)*k_pixel] = np.reshape( + Bpart_flat_rot, (l0,max_path_len,k_pixel,3) + ) else: # general case: different rotations along path - for j in range(m): # THIS LOOP IS EXTREMELY SLOW !!!! github issue #283 + for j in range(max_path_len): # THIS LOOP IS EXTREMELY SLOW !!!! github issue #283 Bpart = B[:,j,i*k_pixel:(i+1)*k_pixel] # select part Bpart_flat = np.reshape(Bpart, (k_pixel*l0,3)) # flatten for rot package Bpart_flat_rot = sens._orientation[j].inv().apply(Bpart_flat) # apply rotat @@ -266,7 +268,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # rearrange sensor-pixel shape sens_px_shape = (k,) + pix_shape - B = B.reshape((l0,m)+sens_px_shape) + B = B.reshape((l0,max_path_len)+sens_px_shape) # sumup over sources if kwargs['sumup']: From be3fd97e9efb784e7dd445439c603434fde54468 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 23:03:48 +0100 Subject: [PATCH 051/207] collection and parent docstrings --- magpylib/_src/obj_classes/class_Collection.py | 62 +++++++++++-------- magpylib/_src/obj_classes/class_Sensor.py | 3 + .../_src/obj_classes/class_current_Line.py | 3 + .../_src/obj_classes/class_current_Loop.py | 3 + magpylib/_src/obj_classes/class_mag_Cuboid.py | 3 + .../_src/obj_classes/class_mag_Cylinder.py | 3 + .../obj_classes/class_mag_CylinderSegment.py | 3 + magpylib/_src/obj_classes/class_mag_Sphere.py | 3 + .../_src/obj_classes/class_misc_Custom.py | 3 + .../_src/obj_classes/class_misc_Dipole.py | 3 + 10 files changed, 63 insertions(+), 26 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 587600190..170972b14 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -157,7 +157,7 @@ def __len__(self): return len(self._children) def describe(self, labels=False, max_elems=10): - """Returns a tree view of the nested collection elements. + """ Returns a tree view of the nested collection elements. Parameters ---------- @@ -175,7 +175,7 @@ def describe(self, labels=False, max_elems=10): # methods ------------------------------------------------------- def add(self, *children, override_parent=False): - """Add sources, sensors or collections. + """ Add sources, sensors or collections. Parameters ---------- @@ -184,7 +184,7 @@ def add(self, *children, override_parent=False): override_parent: bool, default=`True` Accept objects as children that already have parents. Automatically - removes objects from previous parent collection. + removes such objects from previous parent collection. Returns ------- @@ -251,18 +251,19 @@ def _update_src_and_sens(self): ] def remove(self, *children, recursive=True, errors='raise'): - """Remove a specific object from the collection tree. + """ Remove children from the collection tree. Parameters ---------- children: child objects Remove the given children from the collection. - errors: str, default=`'raise'` - Can be `'raise'` or `'ignore'`. - recursive: bool, default=`True` - Remove children also from child collections. + Remove children also when they are in child collections. + + errors: str, default=`'raise'` + Can be `'raise'` or `'ignore'` to toggle error output when child is + not found for removal. Returns ------- @@ -316,9 +317,17 @@ def remove(self, *children, recursive=True, errors='raise'): ) return self - def set_children_styles(self, arg=None, _validate=True, recursive=True, **kwargs): - """Set display style of all children in the collection. Only matching properties - will be applied. Input can be a style dict or style underscore magic. + def set_children_styles(self, arg=None, recursive=True, _validate=True, **kwargs): + """ Set display style of all children in the collection. Only matching properties + will be applied. + + Parameters + ---------- + arg: style dictionary or style underscore magic input + Style arguments to be applied. + + recursive: bool, default=`True` + Apply styles also to children of child collections. Returns ------- @@ -489,33 +498,31 @@ def getH(self, *inputs, squeeze=True): class Collection(BaseGeo, BaseCollection): - """ Group multiple children (sources and sensors) in one Collection for + """ Group multiple children (sources, sensors and collections) in a collection for common manipulation. + Collections span a local reference frame. All objects in a collection are held to + that reference frame when an operation (e.g. move, rotate, setter, ...) is applied + to the collection. + Collections can be used as `sources` and `observers` input for magnetic field computation. For magnetic field computation a collection that contains sources - functions like a single (compound) source. When the collection contains sensors + functions like a single source. When the collection contains sensors it functions like a list of all its sensors. - Collections function like compound-objects. They have their own `position` and - `orientation` attributes. Move, rotate and setter operations acting on a - `Collection` object are individually applied to all child objects so that the - geometric compound structure is maintained. For example, `rotate()` with - `anchor=None` rotates all children about `collection.position`. - Parameters ---------- - children: sources, sensors or collection objects - An ordered list of all children in collection. + children: sources, `Sensor` or `Collection objects + An ordered list of all children in the collection. - sensors: sensor objects - An ordered list of all sensor objects in collection. + sensors: `Sensor` objects + An ordered list of all sensor objects in the collection. sources: source objects - An ordered list of all source objects in collection. + An ordered list of all source objects`(magnets, currents, misc) in the collection. - collections: collection objects - An ordered list of all collection objects in collection. + collections: `Collection` objects + An ordered list of all collection objects in the collection. position: array_like, shape (3,) or (m,3), default=`(0,0,0)` Object position(s) in the global coordinates in units of [mm]. For m>1, the @@ -526,6 +533,9 @@ class Collection(BaseGeo, BaseCollection): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 27d3e3c5f..0de8c8104 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -34,6 +34,9 @@ class Sensor(BaseGeo, BaseDisplayRepr): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 97b3010ba..2b9be7b57 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -35,6 +35,9 @@ class Line(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index 15344be0d..3c6b7dd72 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -34,6 +34,9 @@ class Loop(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_mag_Cuboid.py b/magpylib/_src/obj_classes/class_mag_Cuboid.py index bf1a877cf..cbf45a635 100644 --- a/magpylib/_src/obj_classes/class_mag_Cuboid.py +++ b/magpylib/_src/obj_classes/class_mag_Cuboid.py @@ -35,6 +35,9 @@ class Cuboid(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_mag_Cylinder.py b/magpylib/_src/obj_classes/class_mag_Cylinder.py index 6e41b4bf7..40530fc1d 100644 --- a/magpylib/_src/obj_classes/class_mag_Cylinder.py +++ b/magpylib/_src/obj_classes/class_mag_Cylinder.py @@ -35,6 +35,9 @@ class Cylinder(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py index 63380b845..4315404c7 100644 --- a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py @@ -39,6 +39,9 @@ class CylinderSegment(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_mag_Sphere.py b/magpylib/_src/obj_classes/class_mag_Sphere.py index 8d39168c4..785816138 100644 --- a/magpylib/_src/obj_classes/class_mag_Sphere.py +++ b/magpylib/_src/obj_classes/class_mag_Sphere.py @@ -34,6 +34,9 @@ class Sphere(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_misc_Custom.py b/magpylib/_src/obj_classes/class_misc_Custom.py index 65b228519..557341b45 100644 --- a/magpylib/_src/obj_classes/class_misc_Custom.py +++ b/magpylib/_src/obj_classes/class_misc_Custom.py @@ -36,6 +36,9 @@ class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index a7926469a..8c1af449a 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -31,6 +31,9 @@ class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH): a unit-rotation. For m>1, the `position` and `orientation` attributes together represent an object path. + parent: `Collection` object or `None` + The object is a child of it's parent collection. + style: dict Object style inputs must be in dictionary form, e.g. `{'color':'red'}` or using style underscore magic, e.g. `style_color='red'`. From 8953368f6c3bb4e7ef77501d81cf3ee08e3cb421 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 26 Mar 2022 23:06:40 +0100 Subject: [PATCH 052/207] use orient concat on rotated sensors --- magpylib/_src/fields/field_wrap_BH_level2.py | 48 ++++++++++---------- 1 file changed, 23 insertions(+), 25 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index c140eb11b..6109a7df5 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -198,6 +198,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: obj._position = np.concatenate((obj._position, tile_pos)) # tile up orientation tile_orient = np.tile(obj._orientation.as_quat()[-1], (m_tile,1)) + # TODO use scipy.spatial.transfor.Rotation.concatenate() requires scipy>=1.8 tile_orient = np.concatenate((obj._orientation.as_quat(), tile_orient)) obj._orientation = R.from_quat(tile_orient) @@ -231,40 +232,37 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 B_group = getBH_level1(field=kwargs['field'], **src_dict) # compute field B_group = B_group.reshape((lg,max_path_len,n_pix,3)) # reshape (2% slower for large arrays) - for i in range(lg): # put into dedicated positions in B - B[group['order'][i]] = B_group[i] + for si in range(lg): # put into dedicated positions in B + B[group['order'][si]] = B_group[si] # reshape output ---------------------------------------------------------------- # rearrange B when there is at least one Collection with more than one source if l > l0: - for i,src in enumerate(sources): + for si,src in enumerate(sources): if src._object_type == 'Collection': col_len = len(src.sources) - B[i] = np.sum(B[i:i+col_len],axis=0) # set B[i] to sum of slice - B = np.delete(B,np.s_[i+1:i+col_len],0) # delete remaining part of slice + B[si] = np.sum(B[si:si+col_len],axis=0) # set B[i] to sum of slice + B = np.delete(B,np.s_[si+1:si+col_len],0) # delete remaining part of slice # apply sensor rotations (after summation over collections to reduce rot.apply operations) # note: replace by math.prod with python 3.8 or later - k_pixel = int(np.product(pix_shape[:-1])) # total number of pixel positions - for i,sens in enumerate(sensors): # cycle through all sensors - if not unrotated_sensors[i]: # apply operations only to rotated sensors - if static_sensor_rot[i]: # special case: same rotation along path - # select part where rot is applied - Bpart = B[:,:,i*k_pixel:(i+1)*k_pixel] - # change shape from (l0,m,k_pixel,3) to (P,3) for rot package - Bpart_flat = np.reshape(Bpart, (k_pixel*l0*max_path_len,3)) - # apply sensor rotation - Bpart_flat_rot = sens._orientation[0].inv().apply(Bpart_flat) - # overwrite Bpart in B - B[:,:,i*k_pixel:(i+1)*k_pixel] = np.reshape( - Bpart_flat_rot, (l0,max_path_len,k_pixel,3) - ) - else: # general case: different rotations along path - for j in range(max_path_len): # THIS LOOP IS EXTREMELY SLOW !!!! github issue #283 - Bpart = B[:,j,i*k_pixel:(i+1)*k_pixel] # select part - Bpart_flat = np.reshape(Bpart, (k_pixel*l0,3)) # flatten for rot package - Bpart_flat_rot = sens._orientation[j].inv().apply(Bpart_flat) # apply rotat - B[:,j,i*k_pixel:(i+1)*k_pixel] = np.reshape(Bpart_flat_rot, (l0,k_pixel,3)) + pix_tot = int(np.product(pix_shape[:-1])) # total number of pixel positions + for si,sens in enumerate(sensors): # cycle through all sensors + if not unrotated_sensors[si]: # apply operations only to rotated sensors + # select part where rot is applied + Bpart = B[:,:,si*pix_tot:(si+1)*pix_tot] + # change shape from (l0,m,k_pixel,3) to (P,3) for rot package + Bpart_flat = np.reshape(Bpart, (pix_tot*l0*max_path_len,3)) + # apply sensor rotation + if static_sensor_rot[si]: # special case: same rotation along path + sens_orient = sens._orientation[0] + else: + sens_orient = R.concatenate([sens._orientation]*l0) + Bpart_flat_rot = sens_orient.inv().apply(Bpart_flat) + # overwrite Bpart in B + B[:,:,si*pix_tot:(si+1)*pix_tot] = np.reshape( + Bpart_flat_rot, (l0,max_path_len,pix_tot,3) + ) # rearrange sensor-pixel shape sens_px_shape = (k,) + pix_shape From 2cad99a65ef3aee6aef909d13fb1a2bd0f548569 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 23:23:18 +0100 Subject: [PATCH 053/207] docstings + long forgotten properties --- magpylib/_src/obj_classes/class_BaseGeo.py | 18 +++++++++++++---- magpylib/_src/obj_classes/class_Collection.py | 10 +++++----- magpylib/_src/obj_classes/class_Sensor.py | 4 +++- .../_src/obj_classes/class_current_Line.py | 6 +++++- .../_src/obj_classes/class_current_Loop.py | 2 +- magpylib/_src/obj_classes/class_mag_Cuboid.py | 2 +- .../_src/obj_classes/class_mag_Cylinder.py | 2 +- .../obj_classes/class_mag_CylinderSegment.py | 11 +++++++--- magpylib/_src/obj_classes/class_mag_Sphere.py | 2 +- .../_src/obj_classes/class_misc_Custom.py | 20 +++++++++---------- .../_src/obj_classes/class_misc_Dipole.py | 7 +++---- 11 files changed, 52 insertions(+), 32 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 64e3a890c..68d562372 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -136,7 +136,7 @@ def _get_style_class(self): # properties ---------------------------------------------------- @property def parent(self): - """Object parent attribute getter and setter.""" + """The object is a child of it's parent collection.""" return self._parent @parent.setter @@ -155,7 +155,10 @@ def parent(self, inp): @property def position(self): - """Object position attribute getter and setter.""" + """ + Object position(s) in the global coordinates in units of [mm]. For m>1, the + `position` and `orientation` attributes together represent an object path. + """ return np.squeeze(self._position) @position.setter @@ -197,7 +200,11 @@ def position(self, inp): @property def orientation(self): - """Object orientation attribute getter and setter.""" + """ + Object orientation(s) in the global coordinates. `None` corresponds to + a unit-rotation. For m>1, the `position` and `orientation` attributes + together represent an object path. + """ # cannot squeeze (its a Rotation object) if len(self._orientation) == 1: # single path orientation - reduce dimension return self._orientation[0] @@ -233,7 +240,10 @@ def orientation(self, inp): @property def style(self): - """instance of MagpyStyle for display styling options""" + """ + Object style in the form of a BaseStyle object. Input must be + in the form of a style dictionary. + """ if not hasattr(self, "_style") or self._style is None: self._style = self._validate_style(val=None) return self._style diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 170972b14..4c5ae0545 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -77,7 +77,7 @@ def __init__(self, *children, override_parent=False): # property getters and setters @property def children(self): - """Collection children attribute getter and setter.""" + """An ordered list of all children in the collection.""" return self._children @children.setter @@ -91,7 +91,7 @@ def children(self, children): @property def sources(self): - """Collection sources attribute getter and setter.""" + """An ordered list of all source objects in the collection.""" return self._sources @sources.setter @@ -110,7 +110,7 @@ def sources(self, sources): @property def sensors(self): - """Collection sensors attribute getter and setter.""" + """An ordered list of all sensor objects in the collection.""" return self._sensors @sensors.setter @@ -129,7 +129,7 @@ def sensors(self, sensors): @property def collections(self): - """Collection sub-collections attribute getter and setter.""" + """An ordered list of all collection objects in the collection.""" return self._collections @collections.setter @@ -325,7 +325,7 @@ def set_children_styles(self, arg=None, recursive=True, _validate=True, **kwargs ---------- arg: style dictionary or style underscore magic input Style arguments to be applied. - + recursive: bool, default=`True` Apply styles also to children of child collections. diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 0de8c8104..f32e24b67 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -94,7 +94,9 @@ def __init__( # property getters and setters @property def pixel(self): - """ Sensor pixel attribute getter and setter.""" + """ Sensor pixel (=sensing elements) positions in the local object coordinates + (rotate with object), in units of [mm]. + """ return self._pixel @pixel.setter diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 2b9be7b57..2a898d509 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -105,7 +105,11 @@ def __init__( # property getters and setters @property def vertices(self): - """Object vertices attribute getter and setter.""" + """ + The current flows along the vertices which are given in units of [mm] in the + local object coordinates (move/rotate with object). At least two vertices + must be given. + """ return self._vertices @vertices.setter diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index 3c6b7dd72..a1c8416a0 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -100,7 +100,7 @@ def __init__( # property getters and setters @property def diameter(self): - """Object diameter attribute getter and setter.""" + """Diameter of the loop in units of [mm].""" return self._diameter @diameter.setter diff --git a/magpylib/_src/obj_classes/class_mag_Cuboid.py b/magpylib/_src/obj_classes/class_mag_Cuboid.py index cbf45a635..4d9c238a4 100644 --- a/magpylib/_src/obj_classes/class_mag_Cuboid.py +++ b/magpylib/_src/obj_classes/class_mag_Cuboid.py @@ -101,7 +101,7 @@ def __init__( # property getters and setters @property def dimension(self): - """Object dimension attribute getter and setter.""" + """Length of the cuboid sides [a,b,c] in units of [mm].""" return self._dimension @dimension.setter diff --git a/magpylib/_src/obj_classes/class_mag_Cylinder.py b/magpylib/_src/obj_classes/class_mag_Cylinder.py index 40530fc1d..894dd9c31 100644 --- a/magpylib/_src/obj_classes/class_mag_Cylinder.py +++ b/magpylib/_src/obj_classes/class_mag_Cylinder.py @@ -101,7 +101,7 @@ def __init__( # property getters and setters @property def dimension(self): - """Object dimension attribute getter and setter.""" + """Dimension (d,h) denote diameter and height of the cylinder in units of [mm].""" return self._dimension @dimension.setter diff --git a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py index 4315404c7..da0be6e4f 100644 --- a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py @@ -106,7 +106,12 @@ def __init__( # property getters and setters @property def dimension(self): - """Object dimension attribute getter and setter.""" + """ + Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2) + where r11, the @@ -105,9 +105,9 @@ def __init__( @property def field_B_lambda(self): - """Field function for B-field, should accept array_like positions of shape (n,3) - in units of [mm] and return a B-field array of same shape in the global - coordinate system in units of [mT]. + """ + Field function for the B-field. Must accept position input with format (n,3) and + return the B-field with similar shape in units of [mT]. """ return self._field_B_lambda @@ -117,9 +117,9 @@ def field_B_lambda(self, val): @property def field_H_lambda(self): - """Field function for H-field, should accept array_like positions of shape (n,3) - in units of [mm] and return a H-field array of same shape in the global - coordinate system in units of [kA/m]. + """ + Field function for the H-field. Must accept position input with format (n,3) and + return the H-field with similar shape in units of [kA/m]. """ return self._field_H_lambda diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 8c1af449a..a1e4cd5e4 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -18,9 +18,8 @@ class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH): Parameters ---------- moment: array_like, shape (3,), unit [mT*mm^3], default=`None` - Magnetic dipole moment in units of [mT*mm^3] given in the local object coordinates - (rotates with object). For homogeneous magnets the relation moment=magnetization*volume - holds. + Magnetic dipole moment in units of [mT*mm^3] given in the local object coordinates. + For homogeneous magnets the relation moment=magnetization*volume holds. position: array_like, shape (3,) or (m,3), default=`(0,0,0)` Object position(s) in the global coordinates in units of [mm]. For m>1, the @@ -94,7 +93,7 @@ def __init__( # property getters and setters @property def moment(self): - """Object moment attributes getter and setter.""" + """Magnetic dipole moment in units of [mT*mm^3] given in the local object coordinates.""" return self._moment @moment.setter From ff743d71c84290f2508ccf1d2036e744e67c572f Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 26 Mar 2022 23:45:02 +0100 Subject: [PATCH 054/207] make compatible with python 3.7 --- magpylib/_src/fields/field_wrap_BH_level2.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 6109a7df5..f079abc95 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -198,7 +198,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: obj._position = np.concatenate((obj._position, tile_pos)) # tile up orientation tile_orient = np.tile(obj._orientation.as_quat()[-1], (m_tile,1)) - # TODO use scipy.spatial.transfor.Rotation.concatenate() requires scipy>=1.8 + # FUTURE use Rotation.concatenate() requires scipy>=1.8 and python 3.8 tile_orient = np.concatenate((obj._orientation.as_quat(), tile_orient)) obj._orientation = R.from_quat(tile_orient) @@ -257,7 +257,9 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: if static_sensor_rot[si]: # special case: same rotation along path sens_orient = sens._orientation[0] else: - sens_orient = R.concatenate([sens._orientation]*l0) + # FUTURE use R.concatenate() requires scipy>=1.8 and python 3.8 + # sens_orient = R.concatenate([sens._orientation]*l0) + sens_orient = R.from_quat(np.concatenate([sens._orientation.as_quat()]*l0)) Bpart_flat_rot = sens_orient.inv().apply(Bpart_flat) # overwrite Bpart in B B[:,:,si*pix_tot:(si+1)*pix_tot] = np.reshape( From 63a33929242393c5ab7c4b174fda3995fd79e4bd Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Sat, 26 Mar 2022 23:56:55 +0100 Subject: [PATCH 055/207] changelog and pull from main --- CHANGELOG.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 989ed98e4..af1d5f2ba 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ This is a major update that includes - New `CylinderSegment` class with dimension `(r1,r2,h,phi1,phi2)` with the inner radius `r1`, the outer radius `r2` the height `h` and the cylinder section angles `phi1 < phi2`. ([#386](https://github.com/magpylib/magpylib/issues/386), [#385](https://github.com/magpylib/magpylib/issues/385), [#484](https://github.com/magpylib/magpylib/pull/484), [#480](https://github.com/magpylib/magpylib/issues/480)) - New `CustomSource` class for user defined field functions ([#349](https://github.com/magpylib/magpylib/issues/349), [#409](https://github.com/magpylib/magpylib/issues/409), [#411](https://github.com/magpylib/magpylib/pull/411)) - All Magpylib objects can now be initialized without excitation and dimension attributes. +- All classes now have the unique `parent` attribute to reference to a collection they are part of. An object can only have a single parent. ### Field computation changes/fixes: - New computation core. Added top level subpackage `magpylib.core` where all field implementations can be accessed directly without the position/orienation interface. ([#376](https://github.com/magpylib/magpylib/issues/376)) @@ -70,12 +71,15 @@ This is a major update that includes - Removed `increment` argument from `move` and `rotate` functions ([#438](https://github.com/magpylib/magpylib/discussions/438), [#444](https://github.com/magpylib/magpylib/issues/444)) ### Modifications to the `Collection` class -- Collections can now have `sources`, `sensors` or both types. The `getB` and `getH` functions accommodate for all cases. ([#410](https://github.com/magpylib/magpylib/issues/410), [#415](https://github.com/magpylib/magpylib/pull/415), [#297](https://github.com/magpylib/magpylib/issues/297)) -- Instead of `Collection.sources` there is now `Collection.children`, `Collection.sources` and `Collection.sensors`. ([#446](https://github.com/magpylib/magpylib/issues/446)) -- `Collection` has it's own `position`, `orientation` and `style`. This is useful to build **compound objects**. ([#444](https://github.com/magpylib/magpylib/issues/444), [#461](https://github.com/magpylib/magpylib/issues/461)) +- Collections can now contain source, `Sensor` and other `Collection` objects. The `getB` and `getH` functions accommodate for all cases. ([#410](https://github.com/magpylib/magpylib/issues/410), [#415](https://github.com/magpylib/magpylib/pull/415), [#297](https://github.com/magpylib/magpylib/issues/297)) +- Instead of the property `Collection.sources` there are now `Collection.children`, `Collection.sources`, `Collection.sensors` and `Collection.collections` properties. ([#446](https://github.com/magpylib/magpylib/issues/446), [#502](https://github.com/magpylib/magpylib/pull/502)) +- `Collection` has it's own `position`, `orientation` and `style`. ([#444](https://github.com/magpylib/magpylib/issues/444), [#461](https://github.com/magpylib/magpylib/issues/461)) - Added `__len__` dunder for `Collection`, so that `Collection.children` length is returned. ([#383](https://github.com/magpylib/magpylib/issues/383)) -- Added `__radd__` dunder to build collections with `sum`. [#468](https://github.com/magpylib/magpylib/pull/468) -- `move` and `rotate` methods maintain collection geometry when applied to a collection. +- All methods applied to a collection maintain the relative child-position. +- `-` operation was removed. +- `+` operation now functions as `a + b = Collection(a, b)`. +- Collection input is now only `*args` anymore. List inputs like `Collection([a,b,c])` will raise an error. +- `add` and `remove` have been overhauled with additional functionality and both accept only `*args` as well. ### Other changes/fixes: - Magpylib error message improvement. Msg will now tell you what input is expected. From a69a7e6020d017f5149be4b33b972fa96ed1f34c Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 27 Mar 2022 16:48:09 +0200 Subject: [PATCH 056/207] remove describe method * will be added in another more complete PR --- magpylib/_src/obj_classes/class_Collection.py | 88 ++++--------------- 1 file changed, 16 insertions(+), 72 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 4c5ae0545..9373b8ac9 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -16,51 +16,9 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.input_checks import check_format_input_obj -def repr_obj(obj, labels=False): - """Returns obj repr based on label""" - if labels and obj.style.label: - return f"{obj.style.label}" - return f"{obj!r}" - - -def collection_tree_generator( - dir_child, - prefix="", - space=" ", - branch="│ ", - tee="├── ", - last="└── ", - labels=False, - max_elems=20, -): - """A recursive generator, given a collection child object - will yield a visual tree structure line by line - with each line prefixed by the same characters - """ - # pylint: disable=protected-access - contents = getattr(dir_child, "children", []) - if len(contents) > max_elems: - counts = Counter([c._object_type for c in contents]) - contents = [f"{v}x {k}s" for k, v in counts.items()] - # contents each get pointers that are ├── with a final └── : - pointers = [tee] * (len(contents) - 1) + [last] - for pointer, child in zip(pointers, contents): - child_repr = child if isinstance(child, str) else repr_obj(child, labels) - yield prefix + pointer + child_repr - if getattr(child, "children", False): # extend the prefix and recurse: - extension = branch if pointer == tee else space - # i.e. space because last, └── , above so no more | - yield from collection_tree_generator( - child, - prefix=prefix + extension, - labels=labels, - max_elems=max_elems, - ) - class BaseCollection(BaseDisplayRepr): - """ Collection base class without BaseGeo properties - """ + """Collection base class without BaseGeo properties""" def __init__(self, *children, override_parent=False): @@ -156,26 +114,9 @@ def __getitem__(self, i): def __len__(self): return len(self._children) - def describe(self, labels=False, max_elems=10): - """ Returns a tree view of the nested collection elements. - - Parameters - ---------- - labels: bool, default=False - If True, `object.style.label` is used if available, instead of `repr(object)` - max_elems: - If number of children at any level is higher than `max_elems`, elements are replaced by - counters by object type. - """ - print(repr_obj(self, labels)) - for line in collection_tree_generator( - self, labels=labels, max_elems=max_elems - ): - print(line) - # methods ------------------------------------------------------- def add(self, *children, override_parent=False): - """ Add sources, sensors or collections. + """Add sources, sensors or collections. Parameters ---------- @@ -238,8 +179,7 @@ def add(self, *children, override_parent=False): def _update_src_and_sens(self): # pylint: disable=protected-access - """ updates sources, sensors and collections attributes from children - """ + """updates sources, sensors and collections attributes from children""" self._sources = [ obj for obj in self._children if obj._object_type in LIBRARY_SOURCES ] @@ -250,8 +190,8 @@ def _update_src_and_sens(self): obj for obj in self._children if obj._object_type == "Collection" ] - def remove(self, *children, recursive=True, errors='raise'): - """ Remove children from the collection tree. + def remove(self, *children, recursive=True, errors="raise"): + """Remove children from the collection tree. Parameters ---------- @@ -287,7 +227,7 @@ def remove(self, *children, recursive=True, errors='raise'): col └── x2 """ - #pylint: disable=protected-access + # pylint: disable=protected-access # check and format input remove_objects = check_format_input_obj( @@ -298,7 +238,7 @@ def remove(self, *children, recursive=True, errors='raise'): ) self_objects = check_format_input_obj( self, - allow='sensors+sources+collections', + allow="sensors+sources+collections", recursive=recursive, ) for child in remove_objects: @@ -306,11 +246,11 @@ def remove(self, *children, recursive=True, errors='raise'): rec_obj_remover(self, child) child._parent = None else: - if errors == 'raise': + if errors == "raise": raise MagpylibBadUserInput( f"Cannot find and remove {child} from {self}." ) - if errors != 'ignore': + if errors != "ignore": raise MagpylibBadUserInput( "Input `errors` must be one of ('raise', 'ignore').\n" f"Instead received {errors}." @@ -318,7 +258,7 @@ def remove(self, *children, recursive=True, errors='raise'): return self def set_children_styles(self, arg=None, recursive=True, _validate=True, **kwargs): - """ Set display style of all children in the collection. Only matching properties + """Set display style of all children in the collection. Only matching properties will be applied. Parameters @@ -498,7 +438,7 @@ def getH(self, *inputs, squeeze=True): class Collection(BaseGeo, BaseCollection): - """ Group multiple children (sources, sensors and collections) in a collection for + """Group multiple children (sources, sensors and collections) in a collection for common manipulation. Collections span a local reference frame. All objects in a collection are held to @@ -603,6 +543,10 @@ def __init__( **kwargs, ): BaseGeo.__init__( - self, position=position, orientation=orientation, style=style, **kwargs, + self, + position=position, + orientation=orientation, + style=style, + **kwargs, ) BaseCollection.__init__(self, *args, override_parent=override_parent) From 690716d349954a2ebb0f0a0474cc71ba26cff585 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 27 Mar 2022 16:48:46 +0200 Subject: [PATCH 057/207] remove unused import --- magpylib/_src/obj_classes/class_Collection.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 9373b8ac9..13a407965 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -1,7 +1,5 @@ """Collection class code""" -from collections import Counter - from magpylib._src.utility import ( format_obj_input, LIBRARY_SENSORS, From 87f8f955ac5f5da32d5a3108dbb0f6069a38bafd Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 27 Mar 2022 18:32:08 +0200 Subject: [PATCH 058/207] coverage --- magpylib/_src/display/display_utility.py | 5 ++--- magpylib/_src/obj_classes/class_Collection.py | 15 --------------- tests/test_display_matplotlib.py | 7 ++++--- tests/test_display_plotly.py | 7 ++++--- tests/test_obj_BaseGeo.py | 9 ++++++--- 5 files changed, 16 insertions(+), 27 deletions(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index fbdd691b8..fb1feb2f0 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -473,9 +473,7 @@ def get_flatten_objects_properties( isCollection = getattr(subobj, "children", None) is not None props = {**parent_props} parent_color = parent_props.get("color", '!!!missing!!!') - if parent_color is None: - props["color"] = parent_color - elif parent_color == "!!!missing!!!": + if parent_color == "!!!missing!!!": props["color"] = next(color_cycle) if parent_props.get("legendgroup", None) is None: props["legendgroup"] = f"{subobj}" @@ -490,6 +488,7 @@ def get_flatten_objects_properties( flat_objs[subobj] = props if isCollection: if subobj.style.color is not None: + print('asd') flat_objs[subobj]["color"] = subobj.style.color flat_objs.update( get_flatten_objects_properties( diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index d4766d17f..ca6e7d4c2 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -181,21 +181,6 @@ def __getitem__(self, i): def __len__(self): return len(self._children) - def __repr__(self) -> str: - # pylint: disable=protected-access - s = super().__repr__() - if self._children: - if self._collections: - pref = "Nested" - elif not self._sources: - pref = "Sensor" - elif not self._sensors: - pref = "Source" - else: - pref = "Mixed" - return f"{pref}{s}" - return s - def _repr_html_(self): lines = [] lines.append(repr_obj(self)) diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py index 20edacefc..c1d2b931c 100644 --- a/tests/test_display_matplotlib.py +++ b/tests/test_display_matplotlib.py @@ -105,9 +105,10 @@ def test_col_display(): # pylint: disable=assignment-from-no-return ax = plt.subplot(projection="3d") pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) - pm2 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) - col = magpy.Collection(pm1, pm2) - x = col.show(canvas=ax) + pm2 = pm1.copy(position=(2,0,0)) + pm3 = pm1.copy(position=(4,0,0)) + nested_col = (pm1 + pm2 + pm3).set_children_styles(color='magenta') + x = nested_col.show(canvas=ax) assert x is None, "colletion display test fail" diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index 29afaeb02..8df23289c 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -107,9 +107,10 @@ def test_col_display(): magpy.defaults.display.backend = "plotly" fig = go.Figure() pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) - pm2 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) - col = magpy.Collection(pm1, pm2) - x = col.show(canvas=fig) + pm2 = pm1.copy(position=(2,0,0)) + pm3 = pm1.copy(position=(4,0,0)) + nested_col = (pm1 + pm2 + pm3).set_children_styles(color='magenta') + x = nested_col.show(canvas=fig) assert x is None, "collection display test fail" diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 8c8665c69..9cb42dacf 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -2,7 +2,6 @@ import pytest from scipy.spatial.transform import Rotation as R from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.exceptions import MagpylibBadUserInput import magpylib as magpy @@ -383,14 +382,18 @@ def test_copy_parents(): def test_describe(): """testing descibe method""" - s1 = lambda: magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1), (0,0,0), style_label="cuboid1", style_color='cyan') + s1 = lambda: magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1), (0,0,0), + style_label="cuboid1", style_color='cyan' + ) s2 = lambda: magpy.magnet.Cylinder((0, 0, 1000), (1, 1), (2,0,0), style_label="cylinder1") s3 = magpy.magnet.Sphere((0, 0, 1000), 1, (4,0,0), style_label="sphere1") sens1 = magpy.Sensor((1,0,2),style_label="sensor1") sens2 = magpy.Sensor((3,0,2),style_label="sensor2") s3.move([[1,2,3]]) - src_col = magpy.Collection([s1() for _ in range(6)], s2(), style_label="src_col", style_color='orange') + src_col = magpy.Collection(*[s1() for _ in range(6)], s2(), + style_label="src_col", style_color='orange' + ) sens_col = magpy.Collection(sens1, style_label="sens_col") mixed_col = magpy.Collection(s3, sens2, style_label="mixed_col") nested_col = magpy.Collection(src_col, sens_col, mixed_col, style_label="nested_col") From be9594d61c2b738c6840e894b0e68498382c22f8 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 27 Mar 2022 18:35:24 +0200 Subject: [PATCH 059/207] remove print statement --- magpylib/_src/display/display_utility.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index fb1feb2f0..faa5704ec 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -488,7 +488,6 @@ def get_flatten_objects_properties( flat_objs[subobj] = props if isCollection: if subobj.style.color is not None: - print('asd') flat_objs[subobj]["color"] = subobj.style.color flat_objs.update( get_flatten_objects_properties( From 9eb88337729f87423696b659672e41405a750f8e Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 27 Mar 2022 18:44:11 +0200 Subject: [PATCH 060/207] pylint --- magpylib/_src/obj_classes/class_BaseDisplayRepr.py | 2 +- magpylib/_src/obj_classes/class_Collection.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index 3e882b29c..d9c712a1b 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -62,7 +62,7 @@ def _get_description(self, exclude=None): lines.append(f" • {k}: {val} {unit_str}") return lines - def describe(self, exclude=("style",)): + def describe(self, *, exclude=("style",)): """Returns a view of the object properties. Parameters diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index ca6e7d4c2..c65ea8147 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -190,7 +190,7 @@ def _repr_html_(self): lines.append(line) return f"""
{'
'.join(lines)}
""" - def describe(self, desc="type+label+id", max_elems=10, properties=False): + def describe(self, *, desc="type+label+id", max_elems=10, properties=False): # pylint: disable=arguments-differ """Returns a tree view of the nested collection elements. From 39012182194451903701420e5180a31bfa13eb21 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 27 Mar 2022 19:25:54 +0200 Subject: [PATCH 061/207] add pixel pretty describe --- magpylib/_src/obj_classes/class_BaseDisplayRepr.py | 8 ++++++++ tests/test_obj_BaseGeo.py | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index d9c712a1b..70349dcf2 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -2,6 +2,7 @@ READY FOR V4 """ +import numpy as np from magpylib._src.display.display import show UNITS = { @@ -57,6 +58,13 @@ def _get_description(self, exclude=None): if len(val) != 1: k = f"{k} (last)" val = f"{val[-1]}" + elif k == 'pixel': + val = getattr(self, "pixel") + px_shape = val.shape[:-1] + val_str = f"{int(np.product(px_shape))}" + if val.ndim>2: + val_str += f" ({'x'.join(str(p) for p in px_shape)})" + val = val_str else: val = getattr(self, k) lines.append(f" • {k}: {val} {unit_str}") diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 9cb42dacf..58e83aa09 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -387,7 +387,7 @@ def test_describe(): ) s2 = lambda: magpy.magnet.Cylinder((0, 0, 1000), (1, 1), (2,0,0), style_label="cylinder1") s3 = magpy.magnet.Sphere((0, 0, 1000), 1, (4,0,0), style_label="sphere1") - sens1 = magpy.Sensor((1,0,2),style_label="sensor1") + sens1 = magpy.Sensor((1,0,2),style_label="sensor1", pixel=np.zeros((4,5,3))) sens2 = magpy.Sensor((3,0,2),style_label="sensor2") s3.move([[1,2,3]]) From a4511836a84bd818e1344c2a5d3e7590ee16ffdd Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 28 Mar 2022 01:13:39 +0200 Subject: [PATCH 062/207] allow different pix shapes with pixel_agg --- magpylib/_src/fields/field_wrap_BH_level2.py | 236 +++++++++++-------- magpylib/_src/input_checks.py | 18 +- 2 files changed, 146 insertions(+), 108 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 956982700..242aea7f2 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -16,7 +16,7 @@ def tile_group_property(group: list, n_pp: int, prop_name: str): - """ tile up group property""" + """tile up group property""" prop0 = getattr(group[0], prop_name) out = np.array([getattr(src, prop_name) for src in group]) out = np.tile(out, n_pp) @@ -26,8 +26,7 @@ def tile_group_property(group: list, n_pp: int, prop_name: str): def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: - """ create dictionaries for level1 input - """ + """create dictionaries for level1 input""" # pylint: disable=protected-access # pylint: disable=too-many-return-statements @@ -42,46 +41,54 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: rotobj = R.from_quat(rotv) # pos_obs - posov = np.tile(poso, (len(group),1)) + posov = np.tile(poso, (len(group), 1)) # determine which group we are dealing with and tile up dim and excitation src_type = group[0]._object_type - kwargs = {'source_type': src_type, 'position': posv, 'observer': posov, 'orientation': rotobj} + kwargs = { + "source_type": src_type, + "position": posv, + "observer": posov, + "orientation": rotobj, + } - if src_type in ('Sphere', 'Cuboid', 'Cylinder', 'CylinderSegment'): - magv = tile_group_property(group, n_pp, 'magnetization') + if src_type in ("Sphere", "Cuboid", "Cylinder", "CylinderSegment"): + magv = tile_group_property(group, n_pp, "magnetization") kwargs.update(magnetization=magv) - if src_type=="Sphere": - diav = tile_group_property(group, n_pp, 'diameter') + if src_type == "Sphere": + diav = tile_group_property(group, n_pp, "diameter") kwargs.update(diameter=diav) else: - dimv = tile_group_property(group, n_pp, 'dimension') + dimv = tile_group_property(group, n_pp, "dimension") kwargs.update(dimension=dimv) - elif src_type == 'Dipole': - momv = tile_group_property(group, n_pp, 'moment') - kwargs.update({'moment':momv}) + elif src_type == "Dipole": + momv = tile_group_property(group, n_pp, "moment") + kwargs.update({"moment": momv}) - elif src_type == 'Loop': - currv = tile_group_property(group, n_pp, 'current') - diav = tile_group_property(group, n_pp, 'diameter') - kwargs.update({'current':currv, 'diameter':diav}) + elif src_type == "Loop": + currv = tile_group_property(group, n_pp, "current") + diav = tile_group_property(group, n_pp, "diameter") + kwargs.update({"current": currv, "diameter": diav}) - elif src_type == 'Line': + elif src_type == "Line": # get_BH_line_from_vert function tiles internally ! - #currv = tile_current(group, n_pp) + # currv = tile_current(group, n_pp) currv = np.array([src.current for src in group]) vert_list = [src.vertices for src in group] - kwargs.update({'current':currv, 'vertices':vert_list}) - - elif src_type == 'CustomSource': - kwargs.update({ - 'field_B_lambda': group[0].field_B_lambda, - 'field_H_lambda': group[0].field_H_lambda}) + kwargs.update({"current": currv, "vertices": vert_list}) + + elif src_type == "CustomSource": + kwargs.update( + { + "field_B_lambda": group[0].field_B_lambda, + "field_H_lambda": group[0].field_H_lambda, + } + ) else: - raise MagpylibInternalError('Bad source_type in get_src_dict') + raise MagpylibInternalError("Bad source_type in get_src_dict") return kwargs @@ -125,15 +132,11 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # CHECK AND FORMAT INPUT --------------------------------------------------- if isinstance(sources, str): - return getBH_dict_level2( - source_type=sources, - observer=observers, - **kwargs - ) + return getBH_dict_level2(source_type=sources, observer=observers, **kwargs) # bad user inputs mixing getBH_dict kwargs with object oriented interface kwargs_check = kwargs.copy() - for popit in ['field', 'sumup', 'squeeze', 'pixel_agg']: + for popit in ["field", "sumup", "squeeze", "pixel_agg"]: kwargs_check.pop(popit, None) if kwargs_check: raise MagpylibBadUserInput( @@ -155,18 +158,22 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # allow only bare sensor, collection, pos_vec or list thereof # transform input into an ordered list of sensors (pos_vec->pixel) # check if all pixel shapes are similar. - sensors = check_format_input_observers(observers) - pix_shape = sensors[0].pixel.shape - if pix_shape == (3,): - pix_shape = (1,3) + pixel_agg = kwargs.get("pixel_agg", None) + sensors, pix_shapes = check_format_input_observers(observers, pixel_agg) + pix_nums = [0] + [ + int(np.product(ps[:-1])) for ps in pix_shapes + ] # number of pixel for each sensor + pix_tot = sum(pix_nums) + pix_inds = np.cumsum(pix_nums) # cummulative indices of pixel for each sensor + pix_all_same = len(set(pix_shapes)) == 1 # check which sensors have unit roation # so that they dont have to be rotated back later (performance issue) # this check is made now when sensor paths are not yet tiled. - unitQ = np.array([0,0,0,1.]) - unrotated_sensors = [all(all(r==unitQ) - for r in sens._orientation.as_quat()) - for sens in sensors] + unitQ = np.array([0, 0, 0, 1.0]) + unrotated_sensors = [ + all(all(r == unitQ) for r in sens._orientation.as_quat()) for sens in sensors + ] # check which sensors have a static orientation # either static sensor or translation path @@ -174,10 +181,10 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: static_sensor_rot = check_static_sensor_orient(sensors) # some important quantities ------------------------------------------------- - obj_list = set(src_list + sensors) # unique obj entries only !!! - l0 = len(sources) - l = len(src_list) - k = len(sensors) + obj_list = set(src_list + sensors) # unique obj entries only !!! + num_of_sources = len(sources) + num_of_src_list = len(src_list) + num_of_sensors = len(sensors) # tile up paths ------------------------------------------------------------- # all obj paths that are shorter than max-length are filled up with the last @@ -186,19 +193,19 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: max_path_len = max(path_lengths) # objects to tile up and reset below - mask_reset = [max_path_len!=pl for pl in path_lengths] - reset_obj = [obj for obj,mask in zip(obj_list,mask_reset) if mask] - reset_obj_m0 = [pl for pl,mask in zip(path_lengths,mask_reset) if mask] + mask_reset = [max_path_len != pl for pl in path_lengths] + reset_obj = [obj for obj, mask in zip(obj_list, mask_reset) if mask] + reset_obj_m0 = [pl for pl, mask in zip(path_lengths, mask_reset) if mask] - if max_path_len>1: - for obj,m0 in zip(reset_obj, reset_obj_m0): + if max_path_len > 1: + for obj, m0 in zip(reset_obj, reset_obj_m0): # length to be tiled - m_tile = max_path_len-m0 + m_tile = max_path_len - m0 # tile up position - tile_pos = np.tile(obj._position[-1], (m_tile,1)) + tile_pos = np.tile(obj._position[-1], (m_tile, 1)) obj._position = np.concatenate((obj._position, tile_pos)) # tile up orientation - tile_orient = np.tile(obj._orientation.as_quat()[-1], (m_tile,1)) + tile_orient = np.tile(obj._orientation.as_quat()[-1], (m_tile, 1)) # FUTURE use Rotation.concatenate() requires scipy>=1.8 and python 3.8 tile_orient = np.concatenate((obj._orientation.as_quat(), tile_orient)) obj._orientation = R.from_quat(tile_orient) @@ -206,90 +213,119 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # combine information form all sensors to generate pos_obs with------------- # shape (m * concat all sens flat pixel, 3) # allows sensors with different pixel shapes <- relevant? - poso =[[r.apply(sens.pixel.reshape(-1,3)) + p - for r,p in zip(sens._orientation, sens._position)] - for sens in sensors] - poso = np.concatenate(poso,axis=1).reshape(-1,3) + poso = [ + [ + r.apply(sens.pixel.reshape(-1, 3)) + p + for r, p in zip(sens._orientation, sens._position) + ] + for sens in sensors + ] + poso = np.concatenate(poso, axis=1).reshape(-1, 3) n_pp = len(poso) - n_pix = int(n_pp/max_path_len) + n_pix = int(n_pp / max_path_len) # group similar source types---------------------------------------------- groups = {} - for ind,src in enumerate(src_list): - if src._object_type=='CustomSource': - group_key = src.field_B_lambda if kwargs['field']=='B' else src.field_H_lambda + for ind, src in enumerate(src_list): + if src._object_type == "CustomSource": + group_key = ( + src.field_B_lambda if kwargs["field"] == "B" else src.field_H_lambda + ) else: group_key = src._object_type if group_key not in groups: - groups[group_key] = {'sources':[], 'order':[], 'source_type': src._object_type} - groups[group_key]['sources'].append(src) - groups[group_key]['order'].append(ind) + groups[group_key] = { + "sources": [], + "order": [], + "source_type": src._object_type, + } + groups[group_key]["sources"].append(src) + groups[group_key]["order"].append(ind) # evaluate each group in one vectorized step ------------------------------- - B = np.empty((l,max_path_len,n_pix,3)) # allocate B + B = np.empty((num_of_src_list, max_path_len, n_pix, 3)) # allocate B for group in groups.values(): - lg = len(group['sources']) - gr = group['sources'] + lg = len(group["sources"]) + gr = group["sources"] src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 - B_group = getBH_level1(field=kwargs['field'], **src_dict) # compute field - B_group = B_group.reshape((lg,max_path_len,n_pix,3)) # reshape (2% slower for large arrays) - for si in range(lg): # put into dedicated positions in B - B[group['order'][si]] = B_group[si] + B_group = getBH_level1(field=kwargs["field"], **src_dict) # compute field + B_group = B_group.reshape( + (lg, max_path_len, n_pix, 3) + ) # reshape (2% slower for large arrays) + for gr_ind in range(lg): # put into dedicated positions in B + B[group["order"][gr_ind]] = B_group[gr_ind] # reshape output ---------------------------------------------------------------- # rearrange B when there is at least one Collection with more than one source - if l > l0: - for si,src in enumerate(sources): - if src._object_type == 'Collection': + if num_of_src_list > num_of_sources: + for src_ind, src in enumerate(sources): + if src._object_type == "Collection": col_len = len(format_obj_input(src, allow="sources")) - B[si] = np.sum(B[si:si+col_len],axis=0) # set B[i] to sum of slice - B = np.delete(B,np.s_[si+1:si+col_len],0) # delete remaining part of slice - + # set B[i] to sum of slice + B[src_ind] = np.sum(B[src_ind : src_ind + col_len], axis=0) + B = np.delete( + B, np.s_[src_ind + 1 : src_ind + col_len], 0 + ) # delete remaining part of slice # apply sensor rotations (after summation over collections to reduce rot.apply operations) # note: replace by math.prod with python 3.8 or later - pix_tot = int(np.product(pix_shape[:-1])) # total number of pixel positions - for si,sens in enumerate(sensors): # cycle through all sensors - if not unrotated_sensors[si]: # apply operations only to rotated sensors + print( + "B.shape: ", + B.shape, + "pix_shapes: ", + pix_shapes, + "pix_inds: ", + pix_inds, + "pix_tot: ", + pix_tot, + ) + for sens_ind, sens in enumerate(sensors): # cycle through all sensors + if not unrotated_sensors[sens_ind]: # apply operations only to rotated sensors # select part where rot is applied - Bpart = B[:,:,si*pix_tot:(si+1)*pix_tot] - # change shape from (l0,m,k_pixel,3) to (P,3) for rot package - Bpart_flat = np.reshape(Bpart, (pix_tot*l0*max_path_len,3)) + Bpart = B[:, :, pix_inds[sens_ind] : pix_inds[sens_ind + 1]] + # change shape to (P,3) for rot package + Bpart_orig_shape = Bpart.shape + Bpart_flat = np.reshape(Bpart, (-1, 3)) # apply sensor rotation - if static_sensor_rot[si]: # special case: same rotation along path + if static_sensor_rot[sens_ind]: # special case: same rotation along path sens_orient = sens._orientation[0] else: # FUTURE use R.concatenate() requires scipy>=1.8 and python 3.8 # sens_orient = R.concatenate([sens._orientation]*l0) - sens_orient = R.from_quat(np.concatenate([sens._orientation.as_quat()]*l0)) + sens_orient = R.from_quat( + np.concatenate([sens._orientation.as_quat()] * num_of_sources) + ) Bpart_flat_rot = sens_orient.inv().apply(Bpart_flat) # overwrite Bpart in B - B[:,:,si*pix_tot:(si+1)*pix_tot] = np.reshape( - Bpart_flat_rot, (l0,max_path_len,pix_tot,3) + B[:, :, pix_inds[sens_ind] : pix_inds[sens_ind + 1]] = np.reshape( + Bpart_flat_rot, Bpart_orig_shape ) # rearrange sensor-pixel shape - sens_px_shape = (k,) + pix_shape - B = B.reshape((l0,max_path_len)+sens_px_shape) + if pix_all_same: + sens_px_shape = (num_of_sensors,) + pix_shapes[0] + B = B.reshape((num_of_sources, max_path_len) + sens_px_shape) + # aggregate pixel values + if pixel_agg is not None: + B = getattr(np, pixel_agg)(B, axis=tuple(range(3 - B.ndim, -1))) + if not kwargs["squeeze"]: + # add missing dimension since `pixel_agg` reduces pixel + # dimensions to zero. Only needed if `squeeze is False`` + B = np.expand_dims(B, axis=-2) + else: # pixel_agg is not None when pix_all_same, checked with + Bsplit = np.split(B, pix_inds[1:-1], axis=2) + Bmeans = [getattr(np, pixel_agg)(b, axis=-2, keepdims=True) for b in Bsplit] + B = np.concatenate(Bmeans, axis=-2) # sumup over sources - if kwargs['sumup']: + if kwargs["sumup"]: B = np.sum(B, axis=0, keepdims=True) - # aggregate pixel values - pixel_agg = kwargs['pixel_agg'] - if pixel_agg is not None: - B = getattr(np, pixel_agg)(B, axis=tuple(range(3-B.ndim,-1))) - if not kwargs['squeeze']: - # add missing dimension since `pixel_agg` reduces pixel - # dimensions to zero. Only needed if `squeeze is False`` - B = np.expand_dims(B, axis=-2) - # reduce all size-1 levels - if kwargs['squeeze']: + if kwargs["squeeze"]: B = np.squeeze(B) # reset tiled objects - for obj,m0 in zip(reset_obj, reset_obj_m0): + for obj, m0 in zip(reset_obj, reset_obj_m0): obj._position = obj._position[:m0] obj._orientation = obj._orientation[:m0] diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 23ca8ffbd..a658188f5 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -379,7 +379,7 @@ def check_format_input_backend(inp): f"Instead received {inp}.") -def check_format_input_observers(inp): +def check_format_input_observers(inp, pixel_agg=None): """ checks observer input and returns a list of sensor objects """ @@ -404,7 +404,8 @@ def check_format_input_observers(inp): try: # try if input is just a pos_vec inp = np.array(inp, dtype=float) - return [_src.obj_classes.Sensor(pixel=inp)] + pix_shapes = [(1, 3) if inp.shape == (3,) else inp.shape] + return [_src.obj_classes.Sensor(pixel=inp)], pix_shapes except (TypeError, ValueError): # if not, it must be [pos_vec, sens, coll] sensors=[] for obj in inp: @@ -423,13 +424,14 @@ def check_format_input_observers(inp): raise MagpylibBadUserInput(wrong_obj_msg(obj, allow="observers")) # all pixel shapes must be the same - pix_shapes = [s._pixel.shape for s in sensors] - if not all_same(pix_shapes): + pix_shapes = [(1, 3) if s.pixel.shape == (3,) else s.pixel.shape for s in sensors] + if pixel_agg is None and not all_same(pix_shapes): raise MagpylibBadUserInput( - 'Different observer input detected.' - ' All sensor pixel and position vector inputs must' - ' be of similar shape !') - return sensors + "Different observer input detected." + " All sensor pixel and position vector inputs must" + " be of similar shape, unless a pixel aggregator is provided" + " (e.g. `pixel_agg='mean'`)!") + return sensors, pix_shapes def check_format_input_obj( From 39a0f244b8caa9a026c6ea8d6b5a79272b199631 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 28 Mar 2022 09:23:50 +0200 Subject: [PATCH 063/207] add input check --- magpylib/_src/fields/field_wrap_BH_level2.py | 3 +- magpylib/_src/input_checks.py | 246 +++++++++++-------- 2 files changed, 143 insertions(+), 106 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 242aea7f2..d9e8dee72 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -12,6 +12,7 @@ check_excitations, check_dimensions, check_format_input_observers, + check_pixel_agg, ) @@ -158,7 +159,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # allow only bare sensor, collection, pos_vec or list thereof # transform input into an ordered list of sensors (pos_vec->pixel) # check if all pixel shapes are similar. - pixel_agg = kwargs.get("pixel_agg", None) + pixel_agg = check_pixel_agg(kwargs.get("pixel_agg", None)) sensors, pix_shapes = check_format_input_observers(observers, pixel_agg) pix_nums = [0] + [ int(np.product(ps[:-1])) for ps in pix_shapes diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index a658188f5..d378684bb 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -9,7 +9,12 @@ ) from magpylib._src.defaults.defaults_classes import default_settings from magpylib import _src -from magpylib._src.utility import format_obj_input, wrong_obj_msg, LIBRARY_SOURCES, LIBRARY_SENSORS +from magpylib._src.utility import ( + format_obj_input, + wrong_obj_msg, + LIBRARY_SOURCES, + LIBRARY_SENSORS, +) ################################################################# @@ -22,7 +27,7 @@ def all_same(lst: list) -> bool: return lst[1:] == lst[:-1] -def is_array_like(inp, msg:str): +def is_array_like(inp, msg: str): """ test if inp is array_like: type list, tuple or ndarray inp: test object msg: str, error msg @@ -30,7 +35,8 @@ def is_array_like(inp, msg:str): if not isinstance(inp, (list, tuple, np.ndarray)): raise MagpylibBadUserInput(msg) -def make_float_array(inp, msg:str): + +def make_float_array(inp, msg: str): """transform inp to array with dtype=float, throw error with bad input inp: test object msg: str, error msg @@ -42,7 +48,7 @@ def make_float_array(inp, msg:str): return inp_array -def check_array_shape(inp: np.ndarray, dims:tuple, shape_m1:int, msg:str): +def check_array_shape(inp: np.ndarray, dims: tuple, shape_m1: int, msg: str): """check if inp shape is allowed inp: test object dims: list, list of allowed dims @@ -50,9 +56,9 @@ def check_array_shape(inp: np.ndarray, dims:tuple, shape_m1:int, msg:str): msg: str, error msg """ if inp.ndim in dims: - if inp.shape[-1]==shape_m1: + if inp.shape[-1] == shape_m1: return None - if shape_m1 == 'any': + if shape_m1 == "any": return None raise MagpylibBadUserInput(msg) @@ -64,21 +70,22 @@ def check_input_zoom(inp): "Input parameter `zoom` must be a number `zoom>=0`.\n" f"Instead received {inp}." ) - if inp<0: + if inp < 0: raise MagpylibBadUserInput( "Input parameter `zoom` must be a number `zoom>=0`.\n" f"Instead received {inp}." ) + def check_input_animation(inp): """check show animation input""" ERR_MSG = ( "Input parameter `animation` must be boolean or a positive number.\n" f"Instead received {inp}." - ) + ) if not isinstance(inp, numbers.Number): raise MagpylibBadUserInput(ERR_MSG) - if inp<0: + if inp < 0: raise MagpylibBadUserInput(ERR_MSG) @@ -89,7 +96,9 @@ def check_input_animation(inp): def check_start_type(inp): """start input must be int or str""" - if not (isinstance(inp, (int, np.integer)) or (isinstance(inp, str) and inp == 'auto')): + if not ( + isinstance(inp, (int, np.integer)) or (isinstance(inp, str) and inp == "auto") + ): raise MagpylibBadUserInput( f"Input parameter `start` must be integer value or 'auto'.\n" f"Instead received {repr(inp)}." @@ -105,13 +114,12 @@ def check_degree_type(inp): ) - def check_field_input(inp, origin): """check field input""" if isinstance(inp, str): - if inp == 'B': + if inp == "B": return True - if inp == 'H': + if inp == "H": return False raise MagpylibBadUserInput( f"{origin} input can only be `field='B'` or `field='H'`.\n" @@ -132,14 +140,15 @@ def validate_field_lambda(val, bh): out = val(np.array([[1, 2, 3], [4, 5, 6]])) out_shape = np.array(out).shape - case2 = out_shape!=(2, 3) + case2 = out_shape != (2, 3) if case2: raise MagpylibBadUserInput( f"Input parameter `field_{bh}_lambda` must be a callable function" " and return a field ndarray of shape (n,3) when its `observer`" " input is of shape (n,3).\n" - f"Instead received shape {out_shape}.") + f"Instead received shape {out_shape}." + ) return val @@ -147,6 +156,7 @@ def validate_field_lambda(val, bh): ################################################################# # CHECK - FORMAT + def check_format_input_orientation(inp, init_format=False): """checks orientation input returns in formatted form - inp must be None or Rotation object @@ -163,16 +173,17 @@ def check_format_input_orientation(inp, init_format=False): if not isinstance(inp, (Rotation, type(None))): raise MagpylibBadUserInput( f"Input parameter `orientation` must be `None` or scipy `Rotation` object.\n" - f"Instead received type {type(inp)}.") + f"Instead received type {type(inp)}." + ) # handle None input and compute inpQ if inp is None: - inpQ = np.array((0,0,0,1)) - inp=Rotation.from_quat(inpQ) + inpQ = np.array((0, 0, 0, 1)) + inp = Rotation.from_quat(inpQ) else: inpQ = inp.as_quat() # return if init_format: - return np.reshape(inpQ, (-1,4)) + return np.reshape(inpQ, (-1, 4)) return inp, inpQ @@ -183,12 +194,14 @@ def check_format_input_anchor(inp): if isinstance(inp, numbers.Number) and inp == 0: return np.array((0.0, 0.0, 0.0)) - return check_format_input_vector(inp, - dims=(1,2), + return check_format_input_vector( + inp, + dims=(1, 2), shape_m1=3, - sig_name='anchor', - sig_type='`None` or `0` or array_like (list, tuple, ndarray) with shape (3,)', - allow_None=True) + sig_name="anchor", + sig_type="`None` or `0` or array_like (list, tuple, ndarray) with shape (3,)", + allow_None=True, + ) def check_format_input_axis(inp): @@ -201,25 +214,27 @@ def check_format_input_axis(inp): - return as ndarray shape (3,) """ if isinstance(inp, str): - if inp == 'x': - return np.array((1,0,0)) - if inp == 'y': - return np.array((0,1,0)) - if inp == 'z': - return np.array((0,0,1)) + if inp == "x": + return np.array((1, 0, 0)) + if inp == "y": + return np.array((0, 1, 0)) + if inp == "z": + return np.array((0, 0, 1)) raise MagpylibBadUserInput( "Input parameter `axis` must be array_like shape (3,) or one of ['x', 'y', 'z'].\n" - f"Instead received string {inp}.\n") + f"Instead received string {inp}.\n" + ) - inp = check_format_input_vector(inp, + inp = check_format_input_vector( + inp, dims=(1,), shape_m1=3, - sig_name='axis', - sig_type="array_like (list, tuple, ndarray) with shape (3,) or one of ['x', 'y', 'z']") + sig_name="axis", + sig_type="array_like (list, tuple, ndarray) with shape (3,) or one of ['x', 'y', 'z']", + ) - if np.all(inp==0): - raise MagpylibBadUserInput( - "Input parameter `axis` must not be (0,0,0).\n") + if np.all(inp == 0): + raise MagpylibBadUserInput("Input parameter `axis` must not be (0,0,0).\n") return inp @@ -236,19 +251,18 @@ def check_format_input_angle(inp): if isinstance(inp, numbers.Number): return float(inp) - return check_format_input_vector(inp, + return check_format_input_vector( + inp, dims=(1,), - shape_m1='any', - sig_name='angle', - sig_type='int, float or array_like (list, tuple, ndarray) with shape (n,)') + shape_m1="any", + sig_name="angle", + sig_type="int, float or array_like (list, tuple, ndarray) with shape (n,)", + ) def check_format_input_scalar( - inp, - sig_name, - sig_type, - allow_None=False, - forbid_negative=False): + inp, sig_name, sig_type, allow_None=False, forbid_negative=False +): """check sclar input and return in formatted form - must be scalar or None (if allowed) - must be float compatible @@ -260,7 +274,8 @@ def check_format_input_scalar( ERR_MSG = ( f"Input parameter `{sig_name}` must be {sig_type}.\n" - f"Instead received {repr(inp)}.") + f"Instead received {repr(inp)}." + ) if not isinstance(inp, numbers.Number): raise MagpylibBadUserInput(ERR_MSG) @@ -268,12 +283,13 @@ def check_format_input_scalar( inp = float(inp) if forbid_negative: - if inp<0: + if inp < 0: raise MagpylibBadUserInput(ERR_MSG) return inp -def check_format_input_vector(inp, +def check_format_input_vector( + inp, dims, shape_m1, sig_name, @@ -281,7 +297,7 @@ def check_format_input_vector(inp, reshape=False, allow_None=False, forbid_negative0=False, - ): +): """checks vector input and returns in formatted form - inp must be array_like - convert inp to ndarray with dtype float @@ -295,21 +311,28 @@ def check_format_input_vector(inp, if inp is None: return None - is_array_like(inp, + is_array_like( + inp, f"Input parameter `{sig_name}` must be {sig_type}.\n" - f"Instead received type {type(inp)}." + f"Instead received type {type(inp)}.", ) - inp = make_float_array(inp, - f"Input parameter `{sig_name}` must contain only float compatible entries.\n" + inp = make_float_array( + inp, + f"Input parameter `{sig_name}` must contain only float compatible entries.\n", ) - check_array_shape(inp, dims=dims, shape_m1=shape_m1, msg=( - f"Input parameter `{sig_name}` must be {sig_type}.\n" - f"Instead received array_like with shape {inp.shape}.") + check_array_shape( + inp, + dims=dims, + shape_m1=shape_m1, + msg=( + f"Input parameter `{sig_name}` must be {sig_type}.\n" + f"Instead received array_like with shape {inp.shape}." + ), ) if reshape: - return np.reshape(inp, (-1,3)) + return np.reshape(inp, (-1, 3)) if forbid_negative0: - if np.any(inp<=0): + if np.any(inp <= 0): raise MagpylibBadUserInput( f"Input parameter `{sig_name}` cannot have values <= 0." ) @@ -320,15 +343,17 @@ def check_format_input_vertices(inp): """checks vertices input and returns in formatted form - vector check with dim = (n,3) but n must be >=2 """ - inp = check_format_input_vector(inp, + inp = check_format_input_vector( + inp, dims=(2,), shape_m1=3, - sig_name='vertices', - sig_type='`None` or array_like (list, tuple, ndarray) with shape (n,3)', - allow_None=True) + sig_name="vertices", + sig_type="`None` or array_like (list, tuple, ndarray) with shape (n,3)", + allow_None=True, + ) if inp is not None: - if inp.shape[0]<2: + if inp.shape[0] < 2: raise MagpylibBadUserInput( "Input parameter `vertices` must have more than one vertex." ) @@ -342,23 +367,26 @@ def check_format_input_cylinder_segment(inp): - check if phi2-phi1 > 360 - return error msg """ - inp = check_format_input_vector(inp, + inp = check_format_input_vector( + inp, dims=(1,), shape_m1=5, - sig_name='CylinderSegment.dimension', + sig_name="CylinderSegment.dimension", sig_type=( - 'array_like of the form (r1, r2, h, phi1, phi2) with r1r2 - case3 = phi1>phi2 - case4 = (phi2 - phi1)>360 - case5 = (r1<0) | (r2<=0) | (h<=0) + case2 = r1 > r2 + case3 = phi1 > phi2 + case4 = (phi2 - phi1) > 360 + case5 = (r1 < 0) | (r2 <= 0) | (h <= 0) if case2 | case3 | case4 | case5: raise MagpylibBadUserInput( f"Input parameter `CylinderSegment.dimension` must be array_like of the form" @@ -372,11 +400,12 @@ def check_format_input_backend(inp): """checks show-backend input and returns Non if bad input value""" if inp is None: inp = default_settings.display.backend - if inp in ('matplotlib', 'plotly'): + if inp in ("matplotlib", "plotly"): return inp raise MagpylibBadUserInput( "Input parameter `backend` must be one of `('matplotlib', 'plotly', None)`.\n" - f"Instead received {inp}.") + f"Instead received {inp}." + ) def check_format_input_observers(inp, pixel_agg=None): @@ -402,44 +431,42 @@ def check_format_input_observers(inp, pixel_agg=None): # now inp can still be [pos_vec, sens, coll] or just a pos_vec - try: # try if input is just a pos_vec + try: # try if input is just a pos_vec inp = np.array(inp, dtype=float) pix_shapes = [(1, 3) if inp.shape == (3,) else inp.shape] return [_src.obj_classes.Sensor(pixel=inp)], pix_shapes - except (TypeError, ValueError): # if not, it must be [pos_vec, sens, coll] - sensors=[] + except (TypeError, ValueError): # if not, it must be [pos_vec, sens, coll] + sensors = [] for obj in inp: if getattr(obj, "_object_type", "") == "Sensor": sensors.append(obj) elif getattr(obj, "_object_type", "") == "Collection": - child_sensors = format_obj_input(obj, allow='sensors') + child_sensors = format_obj_input(obj, allow="sensors") if not child_sensors: raise MagpylibBadUserInput(wrong_obj_msg(obj, allow="observers")) sensors.extend(child_sensors) - else: # if its not a Sensor or a Collection it can only be a pos_vec + else: # if its not a Sensor or a Collection it can only be a pos_vec try: obj = np.array(obj, dtype=float) sensors.append(_src.obj_classes.Sensor(pixel=obj)) - except Exception: # or some unwanted crap + except Exception: # or some unwanted crap raise MagpylibBadUserInput(wrong_obj_msg(obj, allow="observers")) # all pixel shapes must be the same - pix_shapes = [(1, 3) if s.pixel.shape == (3,) else s.pixel.shape for s in sensors] + pix_shapes = [ + (1, 3) if s.pixel.shape == (3,) else s.pixel.shape for s in sensors + ] if pixel_agg is None and not all_same(pix_shapes): raise MagpylibBadUserInput( "Different observer input detected." " All sensor pixel and position vector inputs must" " be of similar shape, unless a pixel aggregator is provided" - " (e.g. `pixel_agg='mean'`)!") + " (e.g. `pixel_agg='mean'`)!" + ) return sensors, pix_shapes -def check_format_input_obj( - inp, - allow: str, - recursive = True, - typechecks = False, - ) -> list: +def check_format_input_obj(inp, allow: str, recursive=True, typechecks=False,) -> list: """ Returns a flat list of all wanted objects in input. @@ -461,7 +488,7 @@ def check_format_input_obj( if "sensors" in allow.split("+"): wanted_types += list(LIBRARY_SENSORS) if "collections" in allow.split("+"): - wanted_types += ['Collection'] + wanted_types += ["Collection"] if typechecks: all_types = list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS) + ["Collection"] @@ -477,18 +504,14 @@ def check_format_input_obj( # recursion if (obj_type == "Collection") and recursive: obj_list += check_format_input_obj( - obj, - allow=allow, - recursive=recursive, - typechecks=typechecks, + obj, allow=allow, recursive=recursive, typechecks=typechecks, ) # typechecks if typechecks: if not obj_type in all_types: raise MagpylibBadUserInput( - f"Input objects must be {allow}.\n" - f"Instead received {type(obj)}." + f"Input objects must be {allow}.\n" f"Instead received {type(obj)}." ) return obj_list @@ -498,18 +521,19 @@ def check_format_input_obj( ############################################################################################ # SHOW AND GETB CHECKS + def check_dimensions(sources): """check if all sources have dimension (or similar) initialized""" # pylint: disable=protected-access for s in sources: - obj_type = getattr(s, '_object_type', None) - if obj_type in ('Cuboid', 'Cylinder', 'CylinderSegment'): + obj_type = getattr(s, "_object_type", None) + if obj_type in ("Cuboid", "Cylinder", "CylinderSegment"): if s.dimension is None: raise MagpylibMissingInput(f"Parameter `dimension` of {s} must be set.") - elif obj_type in ('Sphere', 'Loop'): + elif obj_type in ("Sphere", "Loop"): if s.diameter is None: raise MagpylibMissingInput(f"Parameter `diameter` of {s} must be set.") - elif obj_type == 'Line': + elif obj_type == "Line": if s.vertices is None: raise MagpylibMissingInput(f"Parameter `vertices` of {s} must be set.") @@ -518,13 +542,25 @@ def check_excitations(sources): """check if all sources have exitation initialized""" # pylint: disable=protected-access for s in sources: - obj_type = getattr(s, '_object_type', None) - if obj_type in ('Cuboid', 'Cylinder', 'Sphere', 'CylinderSegment'): + obj_type = getattr(s, "_object_type", None) + if obj_type in ("Cuboid", "Cylinder", "Sphere", "CylinderSegment"): if s.magnetization is None: - raise MagpylibMissingInput(f"Parameter `magnetization` of {s} must be set.") - elif obj_type in ('Loop', 'Line'): + raise MagpylibMissingInput( + f"Parameter `magnetization` of {s} must be set." + ) + elif obj_type in ("Loop", "Line"): if s.current is None: raise MagpylibMissingInput(f"Parameter `current` of {s} must be set.") - elif obj_type == 'Dipole': + elif obj_type == "Dipole": if s.moment is None: raise MagpylibMissingInput(f"Parameter `moment` of {s} must be set.") + + +def check_pixel_agg(pixel_agg): + """check if pixel_agg input is acceptable""" + allowed_values = ("mean", "max", "min", "median") + if pixel_agg not in allowed_values: + raise MagpylibBadUserInput( + f"Pixel aggregator `pixel_agg` must be one of {allowed_values}.\n" + f"Instead received {pixel_agg}." + ) From f9c6c2588bbb25b0a028bb18e0835eb46d1325cf Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 28 Mar 2022 11:56:16 +0200 Subject: [PATCH 064/207] fix input checks --- magpylib/_src/input_checks.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index d378684bb..97b33aaa9 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -558,9 +558,10 @@ def check_excitations(sources): def check_pixel_agg(pixel_agg): """check if pixel_agg input is acceptable""" - allowed_values = ("mean", "max", "min", "median") + allowed_values = (None, "mean", "max", "min", "median") if pixel_agg not in allowed_values: raise MagpylibBadUserInput( f"Pixel aggregator `pixel_agg` must be one of {allowed_values}.\n" f"Instead received {pixel_agg}." ) + return pixel_agg From 2ae76033f7323cbef5787065aa48a00c2e5bae94 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 28 Mar 2022 12:43:07 +0200 Subject: [PATCH 065/207] fix orientation tiling&repeating --- magpylib/_src/fields/field_wrap_BH_level2.py | 28 +++++++------------- 1 file changed, 10 insertions(+), 18 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index d9e8dee72..10315f672 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -161,11 +161,10 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # check if all pixel shapes are similar. pixel_agg = check_pixel_agg(kwargs.get("pixel_agg", None)) sensors, pix_shapes = check_format_input_observers(observers, pixel_agg) - pix_nums = [0] + [ + pix_nums = [ int(np.product(ps[:-1])) for ps in pix_shapes ] # number of pixel for each sensor - pix_tot = sum(pix_nums) - pix_inds = np.cumsum(pix_nums) # cummulative indices of pixel for each sensor + pix_inds = np.cumsum([0] + pix_nums) # cummulative indices of pixel for each sensor pix_all_same = len(set(pix_shapes)) == 1 # check which sensors have unit roation @@ -267,18 +266,9 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: B = np.delete( B, np.s_[src_ind + 1 : src_ind + col_len], 0 ) # delete remaining part of slice + # apply sensor rotations (after summation over collections to reduce rot.apply operations) # note: replace by math.prod with python 3.8 or later - print( - "B.shape: ", - B.shape, - "pix_shapes: ", - pix_shapes, - "pix_inds: ", - pix_inds, - "pix_tot: ", - pix_tot, - ) for sens_ind, sens in enumerate(sensors): # cycle through all sensors if not unrotated_sensors[sens_ind]: # apply operations only to rotated sensors # select part where rot is applied @@ -290,10 +280,13 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: if static_sensor_rot[sens_ind]: # special case: same rotation along path sens_orient = sens._orientation[0] else: - # FUTURE use R.concatenate() requires scipy>=1.8 and python 3.8 - # sens_orient = R.concatenate([sens._orientation]*l0) sens_orient = R.from_quat( - np.concatenate([sens._orientation.as_quat()] * num_of_sources) + np.tile( # tile for each source from list + np.repeat( # same orientation path index for all indices + sens._orientation.as_quat(), pix_nums[sens_ind], axis=0 + ), + (num_of_sources, 1), + ) ) Bpart_flat_rot = sens_orient.inv().apply(Bpart_flat) # overwrite Bpart in B @@ -303,8 +296,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # rearrange sensor-pixel shape if pix_all_same: - sens_px_shape = (num_of_sensors,) + pix_shapes[0] - B = B.reshape((num_of_sources, max_path_len) + sens_px_shape) + B = B.reshape((num_of_sources, max_path_len, num_of_sensors, *pix_shapes[0])) # aggregate pixel values if pixel_agg is not None: B = getattr(np, pixel_agg)(B, axis=tuple(range(3 - B.ndim, -1))) From 14fe6f7e5524a1ae4d3f91e112675f6c49b8900c Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 28 Mar 2022 13:33:15 +0200 Subject: [PATCH 066/207] mini bug fix --- magpylib/_src/fields/field_wrap_BH_level2.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 10315f672..e443d33af 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -300,10 +300,6 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # aggregate pixel values if pixel_agg is not None: B = getattr(np, pixel_agg)(B, axis=tuple(range(3 - B.ndim, -1))) - if not kwargs["squeeze"]: - # add missing dimension since `pixel_agg` reduces pixel - # dimensions to zero. Only needed if `squeeze is False`` - B = np.expand_dims(B, axis=-2) else: # pixel_agg is not None when pix_all_same, checked with Bsplit = np.split(B, pix_inds[1:-1], axis=2) Bmeans = [getattr(np, pixel_agg)(b, axis=-2, keepdims=True) for b in Bsplit] @@ -316,6 +312,10 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # reduce all size-1 levels if kwargs["squeeze"]: B = np.squeeze(B) + elif pixel_agg is not None: + # add missing dimension since `pixel_agg` reduces pixel + # dimensions to zero. Only needed if `squeeze is False`` + B = np.expand_dims(B, axis=-2) # reset tiled objects for obj, m0 in zip(reset_obj, reset_obj_m0): From 3082e6b161b0d2f3e93d12ca5b7ae67e7ed49000 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 28 Mar 2022 13:33:25 +0200 Subject: [PATCH 067/207] add tests --- tests/test_getBH_level2.py | 63 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py index 649af1f7c..99145fb75 100644 --- a/tests/test_getBH_level2.py +++ b/tests/test_getBH_level2.py @@ -1,5 +1,7 @@ import numpy as np +import pytest import magpylib as magpy +from magpylib._src.exceptions import MagpylibBadUserInput def test_getB_level2_input_simple(): @@ -295,3 +297,64 @@ def test_squeeze_sumup(): B2 = magpy.getB(ss, s, squeeze=False, sumup=True) assert B1.shape == B2.shape + +def test_pixel_agg(): + """test pixel aggregator""" + src1 = magpy.magnet.Cuboid((0,0,1000),(1,1,1)).move([[1,0,0]]) + sens1 = magpy.Sensor(position=(0,0,1), pixel=np.zeros((4,5,3)), style_label='sens1 pixel(4,5)') + sens2 = sens1.copy(position=(0,0,2), style_label='sens2 pixel(4,5)') + sens3 = sens1.copy(position=(0,0,3), style_label='sens3 pixel(4,5)') + sens_col = magpy.Collection(sens1, sens2, sens3) + sources = src1, + sensors = sens_col, + + B1 = magpy.getB(src1, sens_col, squeeze=False, pixel_agg=None) + np.testing.assert_array_equal(B1.shape, (1, 2, 3, 4, 5, 3)) + + B2 = magpy.getB(src1, sens_col, squeeze=False, pixel_agg='mean') + np.testing.assert_array_equal(B2.shape, (1, 2, 3, 1, 3)) + + B3 = magpy.getB(src1, sens_col, squeeze=True, pixel_agg=None) + np.testing.assert_array_equal(B3.shape, (2, 3, 4, 5, 3)) + + B4 = magpy.getB(src1, sens_col, squeeze=True, pixel_agg='mean') + np.testing.assert_array_equal(B4.shape, (2, 3, 3)) + + +def test_pixel_agg_heterogeneous_pixel_shapes(): + """test pixel aggregator with heterogeneous pixel shapes""" + src1 = magpy.magnet.Cuboid((0,0,1000),(1,1,1)) + sens1 = magpy.Sensor(position=(0,0,1), pixel=[0,0,0], style_label='sens1, pixel.shape = (3,)') + sens2 = sens1.copy(position=(0,0,2), pixel=[1,1,1], style_label='sens2, pixel.shape = (3,)') + sens3 = sens1.copy(position=(0,0,3), pixel=[2,2,2], style_label='sens3, pixel.shape = (3,)') + sens4 = sens1.copy(style_label='sens4, pixel.shape = (3,)') + sens5 = sens2.copy(pixel=np.zeros((4,5,3))+1, style_label='sens5, pixel.shape = (3,)') + sens6 = sens3.copy(pixel=np.zeros((4,5,1,3))+2, style_label='sens6, pixel.shape = (4,5,1,3)') + sens_col1 = magpy.Collection(sens1, sens2, sens3) + sens_col2 = magpy.Collection(sens4, sens5, sens6) + sens_col1.rotate_from_angax([45], 'z', anchor = (5,0,0)) + sens_col2.rotate_from_angax([45], 'z', anchor = (5,0,0)) + + # different pixel shapes withoug pixel_agg should raise an error + with pytest.raises(MagpylibBadUserInput): + magpy.getB(src1, sens_col2, pixel_agg=None) + + # bad pixexl_agg argument + with pytest.raises(MagpylibBadUserInput): + magpy.getB(src1, sens_col2, pixel_agg='bad_aggregator') + + B1 = magpy.getB(src1, sens_col1, squeeze=False, pixel_agg='max') + np.testing.assert_array_equal(B1.shape, (1, 2, 3, 1, 3)) + + B2 = magpy.getB(src1, sens_col2, squeeze=False, pixel_agg='max') + np.testing.assert_array_equal(B2.shape, (1, 2, 3, 1, 3)) + + B3 = magpy.getB(src1, sens_col1, squeeze=True) + np.testing.assert_array_equal(B3.shape, (2, 3, 3)) + + B4 = magpy.getB(src1, sens_col2, squeeze=True, pixel_agg='mean') + np.testing.assert_array_equal(B4.shape, (2, 3, 3)) + + # B3 and B4 should deliver the same results since pixel all have the same + # positions respectively for each sensor, so mean equals single value + np.testing.assert_allclose(B3, B4) From 70901b2de875fa883c2b05457e206420c6631bc7 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 28 Mar 2022 13:58:53 +0200 Subject: [PATCH 068/207] fix input_checks syntax test --- tests/test_input_checks.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index ca26f4801..b2ed2ce39 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -258,6 +258,7 @@ def test_input_objects_magnetization_moment_bad(): for bad in bads: with np.testing.assert_raises(MagpylibBadUserInput): magpy.magnet.Cuboid(magnetization=bad) + with np.testing.assert_raises(MagpylibBadUserInput): magpy.misc.Dipole(moment=bad) @@ -750,7 +751,7 @@ def test_input_collection_remove_bad(): (x2, s1), [s2, c1], ] - for bad in bads: + for bad in bads: with np.testing.assert_raises(MagpylibBadUserInput): col.remove(bad) @@ -789,7 +790,7 @@ def test_input_basegeo_parent_setter_bad(): for bad in bads: with np.testing.assert_raises(MagpylibBadUserInput): x.parent=bad - + # when obj is good but has already a parent x = magpy.Sensor() magpy.Collection(x) From b824a05d68c7f823a8dcf18596fe14d536013674 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 28 Mar 2022 14:23:43 +0200 Subject: [PATCH 069/207] fix plotly mulitidimensional pixel display --- magpylib/_src/display/plotly/plotly_display.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py index d1fbdb80c..c94812791 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -336,7 +336,7 @@ def make_Sensor( pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum distance between any pixel of the same sensor, equal to `size_pixel`. """ - pixel = np.array(pixel) + pixel = np.array(pixel).reshape((-1,3)) default_suffix = ( f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)""" if pixel.ndim != 1 @@ -354,7 +354,7 @@ def make_Sensor( ) if autosize is not None: dim *= autosize - if np.squeeze(pixel).ndim == 1: + if pixel.shape[0] == 1: dim_ext = dim else: hull_dim = pixel.max(axis=0) - pixel.min(axis=0) @@ -366,7 +366,7 @@ def make_Sensor( x, y, z = vertices.T sensor.update(x=x, y=y, z=z) meshes_to_merge = [sensor] - if np.squeeze(pixel).ndim != 1: + if pixel.shape[0] != 1: pixel_color = style.pixel.color pixel_size = style.pixel.size combs = np.array(list(combinations(pixel, 2))) From 4b16c01b5764108fa268a5a1bbf7fb6172b09030 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Mon, 28 Mar 2022 21:49:35 +0200 Subject: [PATCH 070/207] describe, repr, tests and changelog --- CHANGELOG.md | 10 +- .../_src/obj_classes/class_BaseDisplayRepr.py | 18 +- magpylib/_src/obj_classes/class_Collection.py | 158 +++++++++++------- tests/test_obj_BaseGeo.py | 126 +++++++++++--- tests/test_obj_Collection.py | 112 +++++++++++++ 5 files changed, 339 insertions(+), 85 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af1d5f2ba..27c126826 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,7 +17,8 @@ This is a major update that includes - New `CylinderSegment` class with dimension `(r1,r2,h,phi1,phi2)` with the inner radius `r1`, the outer radius `r2` the height `h` and the cylinder section angles `phi1 < phi2`. ([#386](https://github.com/magpylib/magpylib/issues/386), [#385](https://github.com/magpylib/magpylib/issues/385), [#484](https://github.com/magpylib/magpylib/pull/484), [#480](https://github.com/magpylib/magpylib/issues/480)) - New `CustomSource` class for user defined field functions ([#349](https://github.com/magpylib/magpylib/issues/349), [#409](https://github.com/magpylib/magpylib/issues/409), [#411](https://github.com/magpylib/magpylib/pull/411)) - All Magpylib objects can now be initialized without excitation and dimension attributes. -- All classes now have the unique `parent` attribute to reference to a collection they are part of. An object can only have a single parent. +- All classes now have the `parent` attribute to reference to a collection they are part of. Any object can only have a single parent. +- All classes have the `describe` method which gives a quick object property overview. ### Field computation changes/fixes: - New computation core. Added top level subpackage `magpylib.core` where all field implementations can be accessed directly without the position/orienation interface. ([#376](https://github.com/magpylib/magpylib/issues/376)) @@ -71,15 +72,16 @@ This is a major update that includes - Removed `increment` argument from `move` and `rotate` functions ([#438](https://github.com/magpylib/magpylib/discussions/438), [#444](https://github.com/magpylib/magpylib/issues/444)) ### Modifications to the `Collection` class -- Collections can now contain source, `Sensor` and other `Collection` objects. The `getB` and `getH` functions accommodate for all cases. ([#410](https://github.com/magpylib/magpylib/issues/410), [#415](https://github.com/magpylib/magpylib/pull/415), [#297](https://github.com/magpylib/magpylib/issues/297)) -- Instead of the property `Collection.sources` there are now `Collection.children`, `Collection.sources`, `Collection.sensors` and `Collection.collections` properties. ([#446](https://github.com/magpylib/magpylib/issues/446), [#502](https://github.com/magpylib/magpylib/pull/502)) +- Collections can now contain source, `Sensor` and other `Collection` objects and can function as source and observer inputs in `getB` and `getH`. ([#410](https://github.com/magpylib/magpylib/issues/410), [#415](https://github.com/magpylib/magpylib/pull/415), [#297](https://github.com/magpylib/magpylib/issues/297)) +- Instead of the property `Collection.sources` there are now the `Collection.children`, `Collection.sources`, `Collection.sensors` and `Collection.collections` properties. ([#446](https://github.com/magpylib/magpylib/issues/446), [#502](https://github.com/magpylib/magpylib/pull/502)) - `Collection` has it's own `position`, `orientation` and `style`. ([#444](https://github.com/magpylib/magpylib/issues/444), [#461](https://github.com/magpylib/magpylib/issues/461)) +- All methods applied to a collection maintain the relative child-positions. - Added `__len__` dunder for `Collection`, so that `Collection.children` length is returned. ([#383](https://github.com/magpylib/magpylib/issues/383)) -- All methods applied to a collection maintain the relative child-position. - `-` operation was removed. - `+` operation now functions as `a + b = Collection(a, b)`. - Collection input is now only `*args` anymore. List inputs like `Collection([a,b,c])` will raise an error. - `add` and `remove` have been overhauled with additional functionality and both accept only `*args` as well. +- The `describe` method gives a great Collection tree overview. ### Other changes/fixes: - Magpylib error message improvement. Msg will now tell you what input is expected. diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index 70349dcf2..68ad2df96 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -70,21 +70,33 @@ def _get_description(self, exclude=None): lines.append(f" • {k}: {val} {unit_str}") return lines - def describe(self, *, exclude=("style",)): + + def describe(self, *, exclude=("style",), return_string=False): """Returns a view of the object properties. Parameters ---------- exclude: bool, default=("style",) - properties to be excluded in the description view. + Properties to be excluded in the description view. + + return_string: bool, default=`False` + If `False` print description with stdout, if `True` return as string. """ lines = self._get_description(exclude=exclude) - print("\n".join(lines)) + output = "\n".join(lines) + + if return_string: + return output + + print(output) + return None + def _repr_html_(self): lines = self._get_description(exclude=("style",)) return f"""
{'
'.join(lines)}
""" + def __repr__(self) -> str: name = getattr(self, "name", None) if name is None and hasattr(self, "style"): diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index c65ea8147..00d0ab0a1 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -1,5 +1,7 @@ """Collection class code""" +# pylint: disable=redefined-builtin + from collections import Counter from magpylib._src.utility import ( format_obj_input, @@ -7,7 +9,6 @@ LIBRARY_SOURCES, rec_obj_remover, ) - from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 @@ -16,71 +17,100 @@ from magpylib._src.input_checks import check_format_input_obj -def repr_obj(obj, desc="type+id+label"): - """Returns obj repr based on description paramter string""" - rp = "" - lbl = "label" in desc and getattr(getattr(obj, "style", False), "label", False) - if "type" in desc or not lbl: - rp += f"{type(obj).__name__}" - if lbl: - rp += f" {obj.style.label}" - if "id" in desc or not lbl: - id_str = f"id={id(obj)}" - rp += f" ({id_str})" if rp else id_str - return rp.strip() +def repr_obj(obj, format="type+id+label"): + """ + Returns a string that describes the object depending on the chosen tag format. + """ + # pylint: disable=protected-access + show_type = "type" in format + show_label = "label" in format + show_id = "id" in format + + tag = "" + if show_type: + tag += f"{obj._object_type}" + + if show_label: + if show_type: + tag += " " + label = getattr(getattr(obj, "style", None), "label", None) + if label is None: + label = "nolabel" if show_type else f"{obj._object_type}" + tag += label + + if show_id: + if show_type or show_label: + tag += " " + tag += f"(id={id(obj)})" + return tag def collection_tree_generator( - dir_child, - prefix="", - space=" ", - branch="│ ", - tee="├── ", - last="└── ", - desc="type+id+label", + obj, + format="type+id+label", max_elems=20, - properties=False, + prefix = "", + space = " ", + branch = "│ ", + tee = "├── ", + last = "└── ", ): - """A recursive generator, given a collection child object - will yield a visual tree structure line by line - with each line prefixed by the same characters + """ + Recursively creates a generator that will yield a visual tree structure of + a collection object and all its children. """ # pylint: disable=protected-access - # contents each get pointers that are ├── with a final └── : + + # store children and properties of this branch contents = [] - children = getattr(dir_child, "children", []) - desc_func = getattr(dir_child, "_get_description", False) - props = [] - if properties and desc_func: - desc_out = desc_func( - exclude=("children", "parent", "style", "sources", "sensors", "collections") - ) - props = [d.strip() for d in desc_out[1:]] - if len(children) > max_elems: + + children = getattr(obj, "children", []) + if len(children) > max_elems: # replace with counter if too many counts = Counter([c._object_type for c in children]) children = [f"{v}x {k}s" for k, v in counts.items()] + + props = [] + view_props = "properties" in format + if view_props: + desc = getattr(obj, "_get_description", False) + if desc: + desc_out = desc(exclude=( + "children", + "parent", + "style", + "sources", + "sensors", + "collections") + ) + props = [d.strip() for d in desc_out[1:]] + contents.extend(props) contents.extend(children) + + # generate and store "pointer" structure for this branch pointers = [tee] * (len(contents) - 1) + [last] pointers[: len(props)] = [branch if children else space] * len(props) + + # create branch entries for pointer, child in zip(pointers, contents): - child_repr = child if isinstance(child, str) else repr_obj(child, desc) + child_repr = child if isinstance(child, str) else repr_obj(child, format) yield prefix + pointer + child_repr - if getattr(child, "children", False) or ( - getattr(dir_child, "_get_description", False) and properties - ): # extend the prefix and recurse: + + # recursion + has_child = getattr(child, "children", False) + if has_child or (view_props and desc): + # space because last, └── , above so no more | extension = branch if pointer == tee else space - # i.e. space because last, └── , above so no more | + yield from collection_tree_generator( child, + format=format, + max_elems=max_elems, prefix=prefix + extension, space=space, branch=branch, tee=tee, last=last, - desc=desc, - max_elems=max_elems, - properties=properties, ) @@ -185,30 +215,42 @@ def _repr_html_(self): lines = [] lines.append(repr_obj(self)) for line in collection_tree_generator( - self, desc="type+label+id", max_elems=10, properties=False + self, + format="type+label+id", + max_elems=10, ): lines.append(line) return f"""
{'
'.join(lines)}
""" - def describe(self, *, desc="type+label+id", max_elems=10, properties=False): + def describe(self, format='type+label+id', max_elems=10, return_string=False): # pylint: disable=arguments-differ - """Returns a tree view of the nested collection elements. + """Returns or prints a tree view of the collection. Parameters ---------- - desc: bool, default="type+label+id" - Object description. - max_elems: - If number of children at any level is higher than `max_elems`, elements are replaced by - counters by object type. - properties: bool, default=False - If True, adds object properties to the view + format: bool, default='type+label+id' + Object description in tree view. Can be any combination of `'type'`, `'label'` + and `'id'` and `'properties'`. + max_elems: default=10 + If number of children at any level is higher than `max_elems`, elements are + replaced by counters. + return_string: bool, default=`False` + If `False` print description with stdout, if `True` return as string. """ - print(repr_obj(self, desc)) - for line in collection_tree_generator( - self, desc=desc, max_elems=max_elems, properties=properties - ): - print(line) + tree = collection_tree_generator( + self, + format=format, + max_elems=max_elems, + ) + output = [repr_obj(self, format)] + for t in tree: + output.append(t) + output = "\n".join(output) + + if return_string: + return output + print(output) + return None # methods ------------------------------------------------------- def add(self, *children, override_parent=False): @@ -263,7 +305,7 @@ def add(self, *children, override_parent=False): obj._parent = self else: raise MagpylibBadUserInput( - f"Cannot add {obj!r} to {self!r} because it already has a parent." + f"Cannot add {obj!r} to {self!r} because it already has a parent.\n" "Consider using `override_parent=True`." ) diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 58e83aa09..97f8f7bfd 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -1,3 +1,4 @@ +import re import numpy as np import pytest from scipy.spatial.transform import Rotation as R @@ -380,27 +381,112 @@ def test_copy_parents(): assert x1.parent.parent == c assert y.parent is None + def test_describe(): """testing descibe method""" - s1 = lambda: magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1), (0,0,0), - style_label="cuboid1", style_color='cyan' + # pylint: disable=protected-access + x1 = magpy.magnet.Cuboid(style_label='x1') + x2 = magpy.magnet.Cylinder(style_label='x2', dimension=(1,3), magnetization=(2,3,4)) + s1 = magpy.Sensor(position=[(1,2,3)]*3, pixel=[(1,2,3)]*15) + + desc = x1.describe() + assert desc is None + + test = ( + "
Cuboid(id=REGEX, label='x1')
• parent: None
• " + + "position: [0. 0. 0.] mm
• orientation: [0. 0. 0.] degrees
• " + + "dimension: None mm
• magnetization: None mT
" + ) + rep = x1._repr_html_() + rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep) + assert test == rep + + magpy.Collection(x1, x2) + test = [ + "Cuboid(id=REGEX, label='x1')", + " • parent: Collection(id=REGEX) ", # INVISIBLE SPACE + " • position: [0. 0. 0.] mm", + " • orientation: [0. 0. 0.] degrees", + " • dimension: None mm", + " • magnetization: None mT", + ] + desc = x1.describe(return_string=True) + desc = re.sub('id=*[0-9]*[0-9]', 'id=REGEX', desc) + assert test == desc.split("\n") + + test = [ + "Cylinder(id=REGEX, label='x2')", + " • parent: Collection(id=REGEX) ", # INVISIBLE SPACE + " • position: [0. 0. 0.] mm", + " • orientation: [0. 0. 0.] degrees", + " • dimension: [1. 3.] mm", + " • magnetization: [2. 3. 4.] mT", + ] + desc = x2.describe(return_string=True) + desc = re.sub('id=*[0-9]*[0-9]', 'id=REGEX', desc) + assert test == desc.split("\n") + + test = [ + "Sensor(id=REGEX)", + " • parent: None ", # INVISIBLE SPACE + " • path length: 3", + " • position (last): [1. 2. 3.] mm", + " • orientation (last): [0. 0. 0.] degrees", + " • pixel: 15 ", # INVISIBLE SPACE + ] + desc = s1.describe(return_string=True) + desc = re.sub('id=*[0-9]*[0-9]', 'id=REGEX', desc) + assert test == desc.split("\n") + + # exclude=None test + s = magpy.Sensor() + desc = s.describe(exclude=None, return_string=True) + test = ( + "Sensor(id=REGEX)\n" + + " • parent: None \n" + + " • position: [0. 0. 0.] mm\n" + + " • orientation: [0. 0. 0.] degrees\n" + + " • pixel: 1 \n" + + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," + + " color=None, description=Description(show=None, text=None), label=None, " + + "model3d=Model3d(data=[], showdefault=True), opacity=None, path=Path(frames=None," + + " line=Line(color=None, style=None, width=None), marker=Marker(color=None," + + " size=None, symbol=None), numbering=None, show=None), pixel=Pixel(color=None," + + " size=1, symbol=None), size=None) " + ) + desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) + assert desc == test + + # exclude=None test + s = magpy.Sensor() + desc = s.describe(exclude=None, return_string=True) + test = ( + "Sensor(id=REGEX)\n" + + " • parent: None \n" + + " • position: [0. 0. 0.] mm\n" + + " • orientation: [0. 0. 0.] degrees\n" + + " • pixel: 1 \n" + + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," + + " color=None, description=Description(show=None, text=None), label=None, " + + "model3d=Model3d(data=[], showdefault=True), opacity=None, path=Path(frames=None," + + " line=Line(color=None, style=None, width=None), marker=Marker(color=None," + + " size=None, symbol=None), numbering=None, show=None), pixel=Pixel(color=None," + + " size=1, symbol=None), size=None) " ) - s2 = lambda: magpy.magnet.Cylinder((0, 0, 1000), (1, 1), (2,0,0), style_label="cylinder1") - s3 = magpy.magnet.Sphere((0, 0, 1000), 1, (4,0,0), style_label="sphere1") - sens1 = magpy.Sensor((1,0,2),style_label="sensor1", pixel=np.zeros((4,5,3))) - sens2 = magpy.Sensor((3,0,2),style_label="sensor2") - s3.move([[1,2,3]]) - - src_col = magpy.Collection(*[s1() for _ in range(6)], s2(), - style_label="src_col", style_color='orange' + desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) + assert desc == test + + # lots of sensor pixel + s = magpy.Sensor(pixel=[[[(1,2,3)]*5]*5]*3) + desc = s.describe(return_string=True) + test = ( + "Sensor(id=REGEX)\n" + + " • parent: None \n" + + " • position: [0. 0. 0.] mm\n" + + " • orientation: [0. 0. 0.] degrees\n" + + " • pixel: 75 (3x5x5) " ) - sens_col = magpy.Collection(sens1, style_label="sens_col") - mixed_col = magpy.Collection(s3, sens2, style_label="mixed_col") - nested_col = magpy.Collection(src_col, sens_col, mixed_col, style_label="nested_col") - - assert s3.describe(exclude=None) is None - assert s3._repr_html_() - assert src_col._repr_html_() - assert nested_col.describe(max_elems=6) is None - assert nested_col.describe(desc='label') is None - assert nested_col.describe(properties=True, desc='label') is None + desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) + assert desc == test diff --git a/tests/test_obj_Collection.py b/tests/test_obj_Collection.py index 98bc04ce5..fa6bcecd4 100644 --- a/tests/test_obj_Collection.py +++ b/tests/test_obj_Collection.py @@ -1,5 +1,6 @@ import pickle import os +import re import numpy as np from scipy.spatial.transform import Rotation as R import pytest @@ -295,6 +296,7 @@ def test_set_children_styles(): def test_reprs(): """test repr strings""" + # pylint: disable=protected-access c = magpy.Collection() assert repr(c)[:10]=='Collection' @@ -311,3 +313,113 @@ def test_reprs(): s1 = magpy.magnet.Sphere((1,2,3), 5) c = magpy.Collection(s1,x1) assert repr(c)[:10]=='Collection' + + x1 = magpy.magnet.Cuboid(style_label='x1') + x2 = magpy.magnet.Cuboid(style_label='x2') + cc = x1 + x2 + rep = cc._repr_html_() + rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep) + test = "
Collection nolabel (id=REGEX)
├── Cuboid x1" + test += " (id=REGEX)
└── Cuboid x2 (id=REGEX)
" + assert rep == test + + +def test_collection_describe(): + """ test describe method""" + + x = magpy.magnet.Cuboid(style_label='x') + y = magpy.magnet.Cuboid(style_label='y') + z = magpy.magnet.Cuboid(style_label='z') + u = magpy.magnet.Cuboid(style_label='u') + c = x + y + z + u + + desc = c.describe(format="label, type", return_string=True).split("\n") + test = [ + "Collection nolabel", + "├── Collection nolabel", + "│ ├── Collection nolabel", + "│ │ ├── Cuboid x", + "│ │ └── Cuboid y", + "│ └── Cuboid z", + "└── Cuboid u", + ] + assert test == desc + + desc = c.describe(format="label", return_string=True).split("\n") + test = [ + "Collection", + "├── Collection", + "│ ├── Collection", + "│ │ ├── x", + "│ │ └── y", + "│ └── z", + "└── u", + ] + assert test == desc + + desc = c.describe(format="type", return_string=True).split("\n") + test = [ + "Collection", + "├── Collection", + "│ ├── Collection", + "│ │ ├── Cuboid", + "│ │ └── Cuboid", + "│ └── Cuboid", + "└── Cuboid", + ] + assert test == desc + + desc = c.describe(format="label,type,id", return_string=True).split("\n") + test = [ + "Collection nolabel (id=REGEX)", + "├── Collection nolabel (id=REGEX)", + "│ ├── Collection nolabel (id=REGEX)", + "│ │ ├── Cuboid x (id=REGEX)", + "│ │ └── Cuboid y (id=REGEX)", + "│ └── Cuboid z (id=REGEX)", + "└── Cuboid u (id=REGEX)", + ] + assert "".join(test)==re.sub('id=*[0-9]*[0-9]', 'id=REGEX', "".join(desc)) + + + #pylint: disable=unnecessary-lambda + x = lambda : magpy.magnet.Cuboid() + y = lambda : magpy.current.Loop() + z = lambda : magpy.misc.CustomSource() + + c = magpy.Collection(*[x() for _ in range(100)]) + c.add(*[y() for _ in range(50)]) + c.add(*[z() for _ in range(25)]) + + desc = c.describe(format="type+label", return_string=True).split('\n') + test = [ + "Collection nolabel", + "├── 100x Cuboids", + "├── 50x Loops", + "└── 25x CustomSources", + ] + assert test == desc + + x = magpy.magnet.Cuboid(style_label='x') + y = magpy.magnet.Cuboid(style_label='y') + cc = x + y + desc = cc.describe(format="label, properties", return_string=True).split("\n") + test = [ + "Collection", + "│ • position: [0. 0. 0.] mm", + "│ • orientation: [0. 0. 0.] degrees", + "├── x", + "│ • position: [0. 0. 0.] mm", + "│ • orientation: [0. 0. 0.] degrees", + "│ • dimension: None mm", + "│ • magnetization: None mT", + "└── y", + " • position: [0. 0. 0.] mm", + " • orientation: [0. 0. 0.] degrees", + " • dimension: None mm", + " • magnetization: None mT", + ] + assert desc == test + + desc = cc.describe() + assert desc is None From 7ddba34818063a8f4d22696fc2d9b6ee720d045b Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Wed, 30 Mar 2022 01:34:44 +0200 Subject: [PATCH 071/207] draft --- magpylib/_src/fields/field_BH_line.py | 68 ++-- magpylib/_src/fields/field_wrap_BH_level1.py | 94 ++--- magpylib/_src/fields/field_wrap_BH_level2.py | 37 +- .../_src/fields/field_wrap_BH_level2_dict.py | 4 +- tests/test_exceptions.py | 371 +++++++++++------- tests/test_field_functions.py | 4 +- 6 files changed, 320 insertions(+), 258 deletions(-) diff --git a/magpylib/_src/fields/field_BH_line.py b/magpylib/_src/fields/field_BH_line.py index ccf2288ef..f8fa277a9 100644 --- a/magpylib/_src/fields/field_BH_line.py +++ b/magpylib/_src/fields/field_BH_line.py @@ -7,11 +7,13 @@ from magpylib._src.input_checks import check_field_input -def field_BH_line_from_vert( +def current_vertices_field( current: np.ndarray, - vertex_sets: list, # list of mix3 ndarrays - pos_obs: np.ndarray, + observer: np.ndarray, field: str, + vertices: list=None, + segment_start=None, # list of mix3 ndarrays + segment_end=None, ) -> np.ndarray: """ This function accepts n (mi,3) shaped vertex-sets, creates a single long @@ -27,42 +29,44 @@ def field_BH_line_from_vert( ### Returns: - B-field (ndarray nx3): B-field vectors at pos_obs in units of mT """ + if vertices is None: + return current_line_field(current, segment_start, segment_end, observer, field=field) + else: + nv = len(vertices) # number of input vertex_sets + npp = int(observer.shape[0]/nv) # number of position vectors + nvs = [len(vset)-1 for vset in vertices] # length of vertex sets + nseg = sum(nvs) # number of segments - nv = len(vertex_sets) # number of input vertex_sets - npp = int(pos_obs.shape[0]/nv) # number of position vectors - nvs = [len(vset)-1 for vset in vertex_sets] # length of vertex sets - nseg = sum(nvs) # number of segments - - # vertex_sets -> segments - curr_tile = np.repeat(current, nvs) - pos_start = np.concatenate([vert[:-1] for vert in vertex_sets]) - pos_end = np.concatenate([vert[1:] for vert in vertex_sets]) + # vertex_sets -> segments + curr_tile = np.repeat(current, nvs) + pos_start = np.concatenate([vert[:-1] for vert in vertices]) + pos_end = np.concatenate([vert[1:] for vert in vertices]) - # create input for vectorized computation in one go - pos_obs = np.reshape(pos_obs, (nv, npp,3)) - pos_obs = np.repeat(pos_obs, nvs, axis=0) - pos_obs = np.reshape(pos_obs, (-1, 3)) + # create input for vectorized computation in one go + observer = np.reshape(observer, (nv, npp,3)) + observer = np.repeat(observer, nvs, axis=0) + observer = np.reshape(observer, (-1, 3)) - curr_tile = np.repeat(curr_tile, npp) - pos_start = np.repeat(pos_start, npp, axis=0) - pos_end = np.repeat(pos_end, npp, axis=0) + curr_tile = np.repeat(curr_tile, npp) + pos_start = np.repeat(pos_start, npp, axis=0) + pos_end = np.repeat(pos_end, npp, axis=0) - # compute field - field = current_line_field(curr_tile, pos_start, pos_end, pos_obs, field=field) - field = np.reshape(field, (nseg, npp, 3)) + # compute field + field = current_line_field(curr_tile, pos_start, pos_end, observer, field=field) + field = np.reshape(field, (nseg, npp, 3)) - # sum for each vertex set - ns_cum = [sum(nvs[:i]) for i in range(nv+1)] # cumulative index positions - field_sum = np.array([np.sum(field[ns_cum[i-1]:ns_cum[i]], axis=0) for i in range(1,nv+1)]) + # sum for each vertex set + ns_cum = [sum(nvs[:i]) for i in range(nv+1)] # cumulative index positions + field_sum = np.array([np.sum(field[ns_cum[i-1]:ns_cum[i]], axis=0) for i in range(1,nv+1)]) - return np.reshape(field_sum, (-1,3)) + return np.reshape(field_sum, (-1,3)) # ON INTERFACE def current_line_field( current: np.ndarray, - start: np.ndarray, - end: np.ndarray, + segment_start: np.ndarray, + segment_end: np.ndarray, observer: np.ndarray, field='B' ) -> np.ndarray: @@ -124,7 +128,7 @@ def current_line_field( field_all = np.zeros((ntot,3)) # Check for zero-length segments - mask0 = np.all(start==end, axis=1) + mask0 = np.all(segment_start==segment_end, axis=1) if np.all(mask0): return field_all @@ -132,12 +136,12 @@ def current_line_field( if np.any(mask0): not_mask0 = ~mask0 # avoid multiple computation of ~mask current = current[not_mask0] - start = start[not_mask0] - end = end[not_mask0] + segment_start = segment_start[not_mask0] + segment_end = segment_end[not_mask0] observer = observer[not_mask0] # rename - p1,p2,po = start, end, observer + p1,p2,po = segment_start, segment_end, observer # make dimensionless (avoid all large/small input problems) by introducing # the segment length as characteristic length scale. diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py index 3b1afdc66..1b03f5a5d 100644 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ b/magpylib/_src/fields/field_wrap_BH_level1.py @@ -5,12 +5,29 @@ from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.fields.field_BH_loop import current_loop_field -from magpylib._src.fields.field_BH_line import current_line_field, field_BH_line_from_vert +from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.exceptions import MagpylibInternalError - -def getBH_level1(**kwargs:dict) -> np.ndarray: - """ Vectorized field computation +FIELD_FUNCTIONS = { + "Cuboid": magnet_cuboid_field, + "Cylinder": magnet_cylinder_field, + "CylinderSegment": magnet_cylinder_segment_field, + "Sphere": magnet_sphere_field, + "Dipole": dipole_field, + "Loop": current_loop_field, + "Line": current_vertices_field, +} + + +def getBH_level1( + *, + source_type: str, + position: np.ndarray, + orientation: np.ndarray, + observer: np.ndarray, + **kwargs: dict, +) -> np.ndarray: + """Vectorized field computation - applies spatial transformations global CS <-> source CS - selects the correct Bfield_XXX function from input @@ -27,71 +44,26 @@ def getBH_level1(**kwargs:dict) -> np.ndarray: # pylint: disable=too-many-statements # pylint: disable=too-many-branches - # base inputs of all sources - src_type = kwargs['source_type'] - field = kwargs['field'] # 'B' or 'H' - - rot = kwargs['orientation'] # only rotation object allowed as input - pos = kwargs['position'] - poso = kwargs['observer'] - # transform obs_pos into source CS - pos_rel = poso - pos # relative position - pos_rel_rot = rot.apply(pos_rel, inverse=True) # rotate rel_pos into source CS + pos_rel_rot = orientation.apply(observer - position, inverse=True) # collect dictionary inputs and compute field - if src_type == 'Cuboid': - mag = kwargs['magnetization'] - dim = kwargs['dimension'] - B = magnet_cuboid_field(mag, dim, pos_rel_rot, field=field) - - elif src_type == 'Cylinder': - mag = kwargs['magnetization'] - dim = kwargs['dimension'] - B = magnet_cylinder_field(mag, dim, pos_rel_rot, field=field) - - elif src_type == 'CylinderSegment': - mag = kwargs['magnetization'] - dim = kwargs['dimension'] - B = magnet_cylinder_segment_field(mag, dim, pos_rel_rot, field=field) - - elif src_type == 'Sphere': - mag = kwargs['magnetization'] - dia = kwargs['diameter'] - B = magnet_sphere_field(mag, dia, pos_rel_rot, field=field) + field_func = FIELD_FUNCTIONS.get(source_type, None) - elif src_type == 'Dipole': - moment = kwargs['moment'] - B = dipole_field(moment, pos_rel_rot, field=field) - - elif src_type == 'Loop': - current = kwargs['current'] - dia = kwargs['diameter'] - B = current_loop_field(current, dia, pos_rel_rot, field=field) - - elif src_type =='Line': - current = kwargs['current'] - if 'vertices' in kwargs: - vertices = kwargs['vertices'] - B = field_BH_line_from_vert(current, vertices, pos_rel_rot, field=field) - else: - pos_start = kwargs['segment_start'] - pos_end = kwargs['segment_end'] - B = current_line_field(current, pos_start, pos_end, pos_rel_rot, field=field) - - elif src_type == 'CustomSource': - #bh_key = 'B' if bh else 'H' - if kwargs[f'field_{field}_lambda'] is not None: - B = kwargs[f'field_{field}_lambda'](pos_rel_rot) + if source_type == "CustomSource": + field = kwargs["field"] + if kwargs[f"field_{field}_lambda"] is not None: + BH = kwargs[f"field_{field}_lambda"](pos_rel_rot) else: raise MagpylibInternalError( - f'{field}-field calculation not implemented for CustomSource class' + f"{field}-field calculation not implemented for CustomSource class" ) - + elif field_func is not None: + BH = field_func(observer=pos_rel_rot, **kwargs) else: - raise MagpylibInternalError(f'Bad src input type "{src_type}" in level1') + raise MagpylibInternalError(f'Bad src input type "{source_type}" in level1') # transform field back into global CS - B = rot.apply(B) + BH = orientation.apply(BH) - return B + return BH diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index e443d33af..2aa12d8ca 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -94,7 +94,9 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: return kwargs -def getBH_level2(sources, observers, **kwargs) -> np.ndarray: +def getBH_level2( + sources, observers, *, field, sumup, squeeze, pixel_agg, **kwargs +) -> np.ndarray: """... Parameters @@ -133,15 +135,18 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # CHECK AND FORMAT INPUT --------------------------------------------------- if isinstance(sources, str): - return getBH_dict_level2(source_type=sources, observer=observers, **kwargs) + return getBH_dict_level2( + source_type=sources, + observer=observers, + field=field, + squeeze=squeeze, + **kwargs, + ) # bad user inputs mixing getBH_dict kwargs with object oriented interface - kwargs_check = kwargs.copy() - for popit in ["field", "sumup", "squeeze", "pixel_agg"]: - kwargs_check.pop(popit, None) - if kwargs_check: + if kwargs: raise MagpylibBadUserInput( - f"Keyword arguments {tuple(kwargs_check.keys())} are only allowed when the source " + f"Keyword arguments {tuple(kwargs.keys())} are only allowed when the source " "is defined by a string (e.g. sources='Cylinder')" ) @@ -159,7 +164,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # allow only bare sensor, collection, pos_vec or list thereof # transform input into an ordered list of sensors (pos_vec->pixel) # check if all pixel shapes are similar. - pixel_agg = check_pixel_agg(kwargs.get("pixel_agg", None)) + pixel_agg = check_pixel_agg(pixel_agg) sensors, pix_shapes = check_format_input_observers(observers, pixel_agg) pix_nums = [ int(np.product(ps[:-1])) for ps in pix_shapes @@ -228,9 +233,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: groups = {} for ind, src in enumerate(src_list): if src._object_type == "CustomSource": - group_key = ( - src.field_B_lambda if kwargs["field"] == "B" else src.field_H_lambda - ) + group_key = src.field_B_lambda if field == "B" else src.field_H_lambda else: group_key = src._object_type if group_key not in groups: @@ -248,7 +251,8 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: lg = len(group["sources"]) gr = group["sources"] src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 - B_group = getBH_level1(field=kwargs["field"], **src_dict) # compute field + print("in lvl2: ", field) + B_group = getBH_level1(field=field, **src_dict) # compute field B_group = B_group.reshape( (lg, max_path_len, n_pix, 3) ) # reshape (2% slower for large arrays) @@ -281,8 +285,8 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: sens_orient = sens._orientation[0] else: sens_orient = R.from_quat( - np.tile( # tile for each source from list - np.repeat( # same orientation path index for all indices + np.tile( # tile for each source from list + np.repeat( # same orientation path index for all indices sens._orientation.as_quat(), pix_nums[sens_ind], axis=0 ), (num_of_sources, 1), @@ -306,11 +310,12 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: B = np.concatenate(Bmeans, axis=-2) # sumup over sources - if kwargs["sumup"]: + if sumup: B = np.sum(B, axis=0, keepdims=True) # reduce all size-1 levels - if kwargs["squeeze"]: + print(squeeze) + if squeeze: B = np.squeeze(B) elif pixel_agg is not None: # add missing dimension since `pixel_agg` reduces pixel diff --git a/magpylib/_src/fields/field_wrap_BH_level2_dict.py b/magpylib/_src/fields/field_wrap_BH_level2_dict.py index 064f3f1fe..cc87e189c 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2_dict.py +++ b/magpylib/_src/fields/field_wrap_BH_level2_dict.py @@ -6,7 +6,7 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.utility import LIBRARY_BH_DICT_SOURCE_STRINGS -def getBH_dict_level2(**kwargs: dict) -> np.ndarray: +def getBH_dict_level2(squeeze=True, **kwargs: dict) -> np.ndarray: """ Direct interface access to vectorized computation Parameters @@ -55,8 +55,6 @@ def getBH_dict_level2(**kwargs: dict) -> np.ndarray: # if no input set rot=unit rot = kwargs.get('orientation', R.from_quat((0,0,0,1))) tile_params['orientation'] = (rot.as_quat(),2) - # if no input set squeeze=True - squeeze = kwargs.get('squeeze', True) # mandatory class specific inputs ----------- if src_type == 'Cuboid': diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index dbae4fddf..3e2d314d2 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -4,264 +4,350 @@ import magpylib as magpy from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 -from magpylib._src.fields.field_wrap_BH_level2_dict import getBH_dict_level2 -from magpylib._src.exceptions import (MagpylibInternalError, MagpylibBadUserInput,) +from magpylib._src.exceptions import ( + MagpylibInternalError, + MagpylibBadUserInput, +) from magpylib._src.utility import format_obj_input, format_src_inputs from magpylib._src.utility import test_path_format as tpf from magpylib._src.input_checks import check_format_input_observers + def getBHv_unknown_source_type(): - """ unknown source type """ - getBH_dict_level2( - source_type='badName', - magnetization=(1,0,0), - dimension=(0,2,1,0,360), - position=(0,0,-.5), - observer=(1.5,0,-.1), - field='B') + """unknown source type""" + getBH_level2( + sources="badName", + observers=(0, 0, 0), + magnetization=(1, 0, 0), + dimension=(0, 2, 1, 0, 360), + position=(0, 0, -0.5), + sumup=False, + squeeze=True, + pixel_agg=None, + field="B", + ) + def getBH_level1_internal_error(): - """ bad source_type input should not happen - """ - x = np.array([(1,2,3)]) - rot = R.from_quat((0,0,0,1)) - getBH_level1(field='B',source_type='woot', magnetization=x, dimension=x, observer=x, - position=x, orientation=rot) + """bad source_type input should not happen""" + x = np.array([(1, 2, 3)]) + rot = R.from_quat((0, 0, 0, 1)) + getBH_level1( + field="B", + source_type="woot", + magnetization=x, + dimension=x, + observer=x, + position=x, + orientation=rot, + ) def getBH_level2_bad_input1(): - """ test BadUserInput error at getBH_level2 - """ - src = magpy.magnet.Cuboid((1,1,2),(1,1,1)) + """test BadUserInput error at getBH_level2""" + src = magpy.magnet.Cuboid((1, 1, 2), (1, 1, 1)) sens = magpy.Sensor() - getBH_level2([src,sens], (0,0,0), sumup=False, squeeze=True, field='B') + getBH_level2( + [src, sens], (0, 0, 0), sumup=False, squeeze=True, pixel_agg=None, field="B" + ) def getBH_level2_bad_input2(): - """ different pixel shapes - """ - mag = (1,2,3) - dim_cuboid = (1,2,3) - pm1 = magpy.magnet.Cuboid(mag,dim_cuboid) + """different pixel shapes""" + mag = (1, 2, 3) + dim_cuboid = (1, 2, 3) + pm1 = magpy.magnet.Cuboid(mag, dim_cuboid) sens1 = magpy.Sensor() - sens2 = magpy.Sensor(pixel=[(0,0,0),(0,0,1),(0,0,2)]) - magpy.getB(pm1,[sens1,sens2]) + sens2 = magpy.Sensor(pixel=[(0, 0, 0), (0, 0, 1), (0, 0, 2)]) + magpy.getB(pm1, [sens1, sens2]) def getBH_level2_internal_error1(): - """ somehow an unrecognized objects end up in get_src_dict - """ + """somehow an unrecognized objects end up in get_src_dict""" # pylint: disable=protected-access sens = magpy.Sensor() - x = np.zeros((10,3)) - magpy._src.fields.field_wrap_BH_level2.get_src_dict([sens],10,10,x) + x = np.zeros((10, 3)) + magpy._src.fields.field_wrap_BH_level2.get_src_dict([sens], 10, 10, x) # getBHv missing inputs ------------------------------------------------------ def getBHv_missing_input1(): - """ missing bh - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(source_type='Cuboid', observer=x, magnetization=x, dimension=x) + """missing field""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Cuboid", + observers=x, + magnetization=x, + dimension=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input2(): - """ missing source_type - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, observer=x, magnetization=x, dimension=x) + """missing source_type""" + x = np.array([(1, 2, 3)]) + getBH_level2( + observers=x, + field="B", + magnetization=x, + dimension=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input3(): - """ missing observer - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, source_type='Cuboid', magnetization=x, dimension=x) + """missing observer""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Cuboid", + field="B", + magnetization=x, + dimension=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input4_cuboid(): - """ missing Cuboid mag - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, source_type='Cuboid', observer=x, dimension=x) + """missing Cuboid mag""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Cuboid", + observers=x, + field="B", + dimension=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input5_cuboid(): - """ missing Cuboid dim - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, source_type='Cuboid', observer=x, magnetization=x) + """missing Cuboid dim""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Cuboid", + observers=x, + field="B", + magnetization=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input4_cyl(): - """ missing Cylinder mag - """ - x=np.array([(1,2,3)]) - y = np.array([(1,2)]) - getBH_dict_level2(bh=True, source_type='Cylinder', observer=x, dimension=y) + """missing Cylinder mag""" + x = np.array([(1, 2, 3)]) + y = np.array([(1, 2)]) + getBH_level2( + sources="Cylinder", + observers=x, + field="B", + dimension=y, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input5_cyl(): - """ missing Cylinder dim - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, source_type='Cylinder', observer=x, magnetization=x) + """missing Cylinder dim""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Cylinder", + observers=x, + field="B", + magnetization=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input4_sphere(): - """ missing Sphere mag - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, source_type='Sphere', observer=x, dimension=1) + """missing Sphere mag""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Sphere", + observers=x, + field="B", + dimension=1, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_missing_input5_sphere(): - """ missing Sphere dim - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, source_type='Sphere', observer=x, magnetization=x) + """missing Sphere dim""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Sphere", + observers=x, + field="B", + magnetization=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) + # bad inputs ------------------------------------------------------------------- def getBHv_bad_input1(): - """ different input lengths - """ - x=np.array([(1,2,3)]) - x2=np.array([(1,2,3)]*2) - getBH_dict_level2(bh=True, source_type='Cuboid', observer=x, magnetization=x2, dimension=x) + """different input lengths""" + x = np.array([(1, 2, 3)]) + x2 = np.array([(1, 2, 3)] * 2) + getBH_level2( + sources="Cuboid", + observers=x, + field="B", + magnetization=x2, + dimension=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_bad_input2(): - """ bad source_type string - """ - x=np.array([(1,2,3)]) - getBH_dict_level2(bh=True, source_type='Cubooid', observer=x, magnetization=x, dimension=x) + """bad source_type string""" + x = np.array([(1, 2, 3)]) + getBH_level2( + sources="Cubooid", + observers=x, + field="B", + magnetization=x, + dimension=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def getBHv_bad_input3(): - """ mixed input - """ - x=np.array([(1,2,3)]) + """mixed input""" + x = np.array([(1, 2, 3)]) s = magpy.Sensor() - getBH_dict_level2(bh=True, source_type='Cuboid', observer=s, magnetization=x, dimension=x) + getBH_level2( + sources="Cuboid", + observers=s, + field="B", + magnetization=x, + dimension=x, + sumup=False, + squeeze=True, + pixel_agg=None, + ) def utility_format_obj_input(): - """ bad input object - """ - pm1 = magpy.magnet.Cuboid((1,2,3),(1,2,3)) - pm2 = magpy.magnet.Cuboid((1,2,3),(1,2,3)) - format_obj_input([pm1,pm2,333]) + """bad input object""" + pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) + pm2 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) + format_obj_input([pm1, pm2, 333]) def utility_format_src_inputs(): - """ bad src input - """ - pm1 = magpy.magnet.Cuboid((1,2,3),(1,2,3)) - pm2 = magpy.magnet.Cuboid((1,2,3),(1,2,3)) - format_src_inputs([pm1,pm2,1]) + """bad src input""" + pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) + pm2 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) + format_src_inputs([pm1, pm2, 1]) def utility_format_obs_inputs(): - """ bad src input - """ + """bad src input""" sens1 = magpy.Sensor() sens2 = magpy.Sensor() - possis = [1,2,3] - check_format_input_observers([sens1,sens2,possis,'whatever']) + possis = [1, 2, 3] + check_format_input_observers([sens1, sens2, possis, "whatever"]) def utility_test_path_format(): - """ bad path format input - """ + """bad path format input""" # pylint: disable=protected-access - pm1 = magpy.magnet.Cuboid((1,2,3),(1,2,3)) - pm1._position = [(1,2,3),(1,2,3)] + pm1 = magpy.magnet.Cuboid((1, 2, 3), (1, 2, 3)) + pm1._position = [(1, 2, 3), (1, 2, 3)] tpf(pm1) ############################################################################### # BAD INPUT SHAPE EXCEPTIONS def bad_input_shape_basegeo_pos(): - """ bad position input shape - """ - vec3 = (1,2,3) - vec4 = (1,2,3,4) + """bad position input shape""" + vec3 = (1, 2, 3) + vec4 = (1, 2, 3, 4) magpy.magnet.Cuboid(vec3, vec3, vec4) def bad_input_shape_cuboid_dim(): - """ bad cuboid dimension shape - """ - vec3 = (1,2,3) - vec4 = (1,2,3,4) + """bad cuboid dimension shape""" + vec3 = (1, 2, 3) + vec4 = (1, 2, 3, 4) magpy.magnet.Cuboid(vec3, vec4) def bad_input_shape_cuboid_mag(): - """ bad cuboid magnetization shape - """ - vec3 = (1,2,3) - vec4 = (1,2,3,4) + """bad cuboid magnetization shape""" + vec3 = (1, 2, 3) + vec4 = (1, 2, 3, 4) magpy.magnet.Cuboid(vec4, vec3) def bad_input_shape_cyl_dim(): - """ bad cylinder dimension shape - """ - vec3 = (1,2,3) - vec4 = (1,2,3,4) + """bad cylinder dimension shape""" + vec3 = (1, 2, 3) + vec4 = (1, 2, 3, 4) magpy.magnet.Cylinder(vec3, vec4) def bad_input_shape_cyl_mag(): - """ bad cylinder magnetization shape - """ - vec3 = (1,2,3) - vec4 = (1,2,3,4) + """bad cylinder magnetization shape""" + vec3 = (1, 2, 3) + vec4 = (1, 2, 3, 4) magpy.magnet.Cylinder(vec4, vec3) def bad_input_shape_sphere_mag(): - """ bad sphere magnetization shape - """ - vec4 = (1,2,3,4) + """bad sphere magnetization shape""" + vec4 = (1, 2, 3, 4) magpy.magnet.Sphere(vec4, 1) def bad_input_shape_sensor_pix_pos(): - """ bad sensor pix_pos input shape - """ - vec4 = (1,2,3,4) - vec3 = (1,2,3) + """bad sensor pix_pos input shape""" + vec4 = (1, 2, 3, 4) + vec3 = (1, 2, 3) magpy.Sensor(vec3, vec4) def bad_input_shape_dipole_mom(): - """ bad sphere magnetization shape - """ - vec4 = (1,2,3,4) + """bad sphere magnetization shape""" + vec4 = (1, 2, 3, 4) magpy.misc.Dipole(moment=vec4) ##################################################################### class TestExceptions(unittest.TestCase): - """ test class for exception testing - """ + """test class for exception testing""" def test_except_utility(self): - """ utility - """ + """utility""" self.assertRaises(MagpylibBadUserInput, utility_test_path_format) self.assertRaises(MagpylibBadUserInput, utility_format_obj_input) self.assertRaises(MagpylibBadUserInput, utility_format_src_inputs) self.assertRaises(MagpylibBadUserInput, utility_format_obs_inputs) def test_except_getBHv(self): - """ getBHv - """ - self.assertRaises(KeyError, getBHv_missing_input1) - self.assertRaises(MagpylibBadUserInput, getBHv_missing_input2) - self.assertRaises(MagpylibBadUserInput, getBHv_missing_input3) + """getBHv""" + self.assertRaises(TypeError, getBHv_missing_input1) + self.assertRaises(TypeError, getBHv_missing_input2) + self.assertRaises(TypeError, getBHv_missing_input3) self.assertRaises(MagpylibBadUserInput, getBHv_missing_input4_cuboid) self.assertRaises(MagpylibBadUserInput, getBHv_missing_input4_cyl) self.assertRaises(MagpylibBadUserInput, getBHv_missing_input4_sphere) @@ -274,20 +360,17 @@ def test_except_getBHv(self): self.assertRaises(MagpylibBadUserInput, getBHv_unknown_source_type) def test_except_getBH_lev1(self): - """ getBH_level1 exception testing - """ + """getBH_level1 exception testing""" self.assertRaises(MagpylibInternalError, getBH_level1_internal_error) def test_except_getBH_lev2(self): - """ getBH_level2 exception testing - """ + """getBH_level2 exception testing""" self.assertRaises(MagpylibBadUserInput, getBH_level2_bad_input1) self.assertRaises(MagpylibBadUserInput, getBH_level2_bad_input2) self.assertRaises(MagpylibInternalError, getBH_level2_internal_error1) def test_except_bad_input_shape_basegeo(self): - """ BaseGeo bad input shapes - """ + """BaseGeo bad input shapes""" self.assertRaises(MagpylibBadUserInput, bad_input_shape_basegeo_pos) self.assertRaises(MagpylibBadUserInput, bad_input_shape_cuboid_dim) self.assertRaises(MagpylibBadUserInput, bad_input_shape_cuboid_mag) diff --git a/tests/test_field_functions.py b/tests/test_field_functions.py index f02d06f6c..af434333e 100644 --- a/tests/test_field_functions.py +++ b/tests/test_field_functions.py @@ -5,7 +5,7 @@ from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.fields.field_BH_loop import current_loop_field -from magpylib._src.fields.field_BH_line import current_line_field, field_BH_line_from_vert +from magpylib._src.fields.field_BH_line import current_line_field, current_vertices_field def test_magnet_cuboid_Bfield(): @@ -250,7 +250,7 @@ def test_field_line_from_vert(): vert3 = np.array([(1,2,3),(-2,-3,3),(3,2,1),(3,3,3)]) pos_tiled = np.tile(p, (3,1)) - B_vert = field_BH_line_from_vert(curr, [vert1,vert2,vert3], pos_tiled, field='B') + B_vert = current_vertices_field(curr, pos_tiled, field='B', vertices=[vert1,vert2,vert3]) B = [] for i,vert in enumerate([vert1,vert2,vert3]): From 241a43561aaa857e280e6c5ec20ef1e3698d6b2f Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 09:40:48 +0200 Subject: [PATCH 072/207] remove print statement --- magpylib/_src/fields/field_wrap_BH_level2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 2aa12d8ca..b81a00d14 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -314,7 +314,6 @@ def getBH_level2( B = np.sum(B, axis=0, keepdims=True) # reduce all size-1 levels - print(squeeze) if squeeze: B = np.squeeze(B) elif pixel_agg is not None: From 54ffae4037fa2e842cb89a9fd672c4928d3b6f09 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Wed, 30 Mar 2022 09:59:43 +0200 Subject: [PATCH 073/207] collection setter override parents --- tests/test_display_plotly.py | 2 +- tests/test_obj_Collection_child_parent.py | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index 8df23289c..7320a506d 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -283,7 +283,7 @@ def test_display_warnings(): with pytest.warns(UserWarning): # max frames surpassed src.show(canvas=fig, animation=True, animation_time=2, animation_fps=1) src = Cuboid((1, 2, 3), (1, 2, 3)) - with pytest.warns(UserWarning): # no objet path detected + with pytest.warns(UserWarning): # no object path detected src.show(canvas=fig, style_path_frames=[], animation=True) diff --git a/tests/test_obj_Collection_child_parent.py b/tests/test_obj_Collection_child_parent.py index ae55435e8..8b6f001ba 100644 --- a/tests/test_obj_Collection_child_parent.py +++ b/tests/test_obj_Collection_child_parent.py @@ -51,6 +51,24 @@ def test_children_setter(): assert c[1] == x4 +def test_setter_parent_override(): + """ all setter should override parents""" + x1 = magpy.Sensor() + s1 = magpy.magnet.Cuboid() + c1 = magpy.Collection() + coll = magpy.Collection(x1, s1, c1) + + coll2 = magpy.Collection() + coll2.children = coll.children + assert coll2.children == [x1, s1, c1] + + coll3 = magpy.Collection() + coll3.sensors = [x1] + coll3.sources = [s1] + coll3.collections = [c1] + assert coll3.children == [x1, s1, c1] + + def test_sensors_setter(): """ setting new sensors and removing old parents""" x1 = magpy.Sensor() From 5ee8fb291717fc317108995841470e78a0b77fc7 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 10:02:38 +0200 Subject: [PATCH 074/207] remove print statement --- magpylib/_src/fields/field_wrap_BH_level2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index b81a00d14..856fddb72 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -251,7 +251,6 @@ def getBH_level2( lg = len(group["sources"]) gr = group["sources"] src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 - print("in lvl2: ", field) B_group = getBH_level1(field=field, **src_dict) # compute field B_group = B_group.reshape( (lg, max_path_len, n_pix, 3) From 30df48368b1f5b4866d3629c2181b93cfeaa5948 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 12:11:25 +0200 Subject: [PATCH 075/207] refactor getBHdict --- .../_src/fields/field_wrap_BH_level2_dict.py | 147 ++++++++---------- tests/test_exceptions.py | 12 +- 2 files changed, 67 insertions(+), 92 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2_dict.py b/magpylib/_src/fields/field_wrap_BH_level2_dict.py index cc87e189c..66b49189e 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2_dict.py +++ b/magpylib/_src/fields/field_wrap_BH_level2_dict.py @@ -6,7 +6,31 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.utility import LIBRARY_BH_DICT_SOURCE_STRINGS -def getBH_dict_level2(squeeze=True, **kwargs: dict) -> np.ndarray: + +PARAM_TILE_DIMS = { + "observer": 2, + "position": 2, + "orientation": 2, + "magnetization": 2, + "current": 1, + "moment": 2, + "dimension": 2, + "diameter": 1, + "segment_start": 2, + "segment_end": 2, +} + + +def getBH_dict_level2( + source_type, + observer, + *, + field: str, + position=(0, 0, 0), + orientation=R.identity(), + squeeze=True, + **kwargs: dict, +) -> np.ndarray: """ Direct interface access to vectorized computation Parameters @@ -35,106 +59,57 @@ def getBH_dict_level2(squeeze=True, **kwargs: dict) -> np.ndarray: # getBH_level1(). # To allow different input dimensions, the tdim argument is also given # which tells the program which dimension it should tile up. - tile_params = {} - # mandatory general inputs ------------------ - try: - src_type = kwargs['source_type'] - if src_type not in LIBRARY_BH_DICT_SOURCE_STRINGS: - raise MagpylibBadUserInput( - f"Input parameter `sources` must be one of {LIBRARY_BH_DICT_SOURCE_STRINGS}" - " when using the direct interface.") - - poso = np.array(kwargs['observer'], dtype=float) - tile_params['observer'] = (poso,2) # <-- (input,tdim) - - # optional general inputs ------------------- - # if no input set pos=0 - pos = np.array(kwargs.get('position', (0,0,0)), dtype=float) - tile_params['position'] = (pos,2) - # if no input set rot=unit - rot = kwargs.get('orientation', R.from_quat((0,0,0,1))) - tile_params['orientation'] = (rot.as_quat(),2) - - # mandatory class specific inputs ----------- - if src_type == 'Cuboid': - mag = np.array(kwargs['magnetization'], dtype=float) - tile_params['magnetization'] = (mag,2) - dim = np.array(kwargs['dimension'], dtype=float) - tile_params['dimension'] = (dim,2) - - elif src_type == 'Cylinder': - mag = np.array(kwargs['magnetization'], dtype=float) - tile_params['magnetization'] = (mag,2) - dim = np.array(kwargs['dimension'], dtype=float) - tile_params['dimension'] = (dim,2) - - elif src_type == 'CylinderSegment': - mag = np.array(kwargs['magnetization'], dtype=float) - tile_params['magnetization'] = (mag,2) - dim = np.array(kwargs['dimension'], dtype=float) - tile_params['dimension'] = (dim,2) - - elif src_type == 'Sphere': - mag = np.array(kwargs['magnetization'], dtype=float) - tile_params['magnetization'] = (mag,2) - dia = np.array(kwargs['diameter'], dtype=float) - tile_params['diameter'] = (dia,1) - - elif src_type == 'Dipole': - moment = np.array(kwargs['moment'], dtype=float) - tile_params['moment'] = (moment,2) - - elif src_type == 'Loop': - current = np.array(kwargs['current'], dtype=float) - tile_params['current'] = (current,1) - dia = np.array(kwargs['diameter'], dtype=float) - tile_params['diameter'] = (dia,1) - - elif src_type == 'Line': - current = np.array(kwargs['current'], dtype=float) - tile_params['current'] = (current,1) - pos_start = np.array(kwargs['segment_start'], dtype=float) - tile_params['segment_start'] = (pos_start,2) - pos_end = np.array(kwargs['segment_end'], dtype=float) - tile_params['segment_end'] = (pos_end,2) - - except KeyError as kerr: - raise MagpylibBadUserInput( - f"Missing input keys: {str(kerr)}" - ) from kerr - except TypeError as terr: + if source_type not in LIBRARY_BH_DICT_SOURCE_STRINGS: raise MagpylibBadUserInput( - "Bad user input type. When sources argument is a string," - " all other inputs must be scalar or array_like." - ) from terr + f"Input parameter `sources` must be one of {LIBRARY_BH_DICT_SOURCE_STRINGS}" + " when using the direct interface." + ) + + kwargs["observer"] = observer + kwargs["position"] = position - # auto tile 1D parameters --------------------------------------- + # change orientation to Rotation numpy array for tiling + kwargs["orientation"] = orientation.as_quat() - # evaluation vector length - ns = [len(val) for val,tdim in tile_params.values() if val.ndim == tdim] - if len(set(ns)) > 1: + # evaluation vector lengths + vec_lengths = [] + for key, val in kwargs.items(): + try: + val = np.array(val, dtype=float) + except TypeError as err: + raise MagpylibBadUserInput( + f"{key} input must be array-like.\n" + f"Instead received {val}" + ) from err + tdim = PARAM_TILE_DIMS.get(key, 1) + if val.ndim == tdim: + vec_lengths.append(len(val)) + kwargs[key] = val + + if len(set(vec_lengths)) > 1: raise MagpylibBadUserInput( - "Input array lengths must be 1 or of a similarlength.\n" - f"Instead received {str(set(ns))}" + "Input array lengths must be 1 or of a similar length.\n" + f"Instead received {set(vec_lengths)}" ) - n = max(ns, default=1) + vec_len = max(vec_lengths, default=1) # tile 1D inputs and replace original values in kwargs - for key,(val,tdim) in tile_params.items(): - if val.ndim Date: Wed, 30 Mar 2022 12:26:13 +0200 Subject: [PATCH 076/207] remove obsolete comment --- magpylib/_src/fields/field_wrap_BH_level2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 856fddb72..109790990 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -271,7 +271,6 @@ def getBH_level2( ) # delete remaining part of slice # apply sensor rotations (after summation over collections to reduce rot.apply operations) - # note: replace by math.prod with python 3.8 or later for sens_ind, sens in enumerate(sensors): # cycle through all sensors if not unrotated_sensors[sens_ind]: # apply operations only to rotated sensors # select part where rot is applied From 5c023fa16869222cf8f4260cc750f1127a2d2be7 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 13:28:40 +0200 Subject: [PATCH 077/207] pylint --- magpylib/_src/fields/field_BH_line.py | 58 +++++++++++++-------------- 1 file changed, 29 insertions(+), 29 deletions(-) diff --git a/magpylib/_src/fields/field_BH_line.py b/magpylib/_src/fields/field_BH_line.py index f8fa277a9..48eb45954 100644 --- a/magpylib/_src/fields/field_BH_line.py +++ b/magpylib/_src/fields/field_BH_line.py @@ -31,35 +31,35 @@ def current_vertices_field( """ if vertices is None: return current_line_field(current, segment_start, segment_end, observer, field=field) - else: - nv = len(vertices) # number of input vertex_sets - npp = int(observer.shape[0]/nv) # number of position vectors - nvs = [len(vset)-1 for vset in vertices] # length of vertex sets - nseg = sum(nvs) # number of segments - - # vertex_sets -> segments - curr_tile = np.repeat(current, nvs) - pos_start = np.concatenate([vert[:-1] for vert in vertices]) - pos_end = np.concatenate([vert[1:] for vert in vertices]) - - # create input for vectorized computation in one go - observer = np.reshape(observer, (nv, npp,3)) - observer = np.repeat(observer, nvs, axis=0) - observer = np.reshape(observer, (-1, 3)) - - curr_tile = np.repeat(curr_tile, npp) - pos_start = np.repeat(pos_start, npp, axis=0) - pos_end = np.repeat(pos_end, npp, axis=0) - - # compute field - field = current_line_field(curr_tile, pos_start, pos_end, observer, field=field) - field = np.reshape(field, (nseg, npp, 3)) - - # sum for each vertex set - ns_cum = [sum(nvs[:i]) for i in range(nv+1)] # cumulative index positions - field_sum = np.array([np.sum(field[ns_cum[i-1]:ns_cum[i]], axis=0) for i in range(1,nv+1)]) - - return np.reshape(field_sum, (-1,3)) + + nv = len(vertices) # number of input vertex_sets + npp = int(observer.shape[0]/nv) # number of position vectors + nvs = [len(vset)-1 for vset in vertices] # length of vertex sets + nseg = sum(nvs) # number of segments + + # vertex_sets -> segments + curr_tile = np.repeat(current, nvs) + pos_start = np.concatenate([vert[:-1] for vert in vertices]) + pos_end = np.concatenate([vert[1:] for vert in vertices]) + + # create input for vectorized computation in one go + observer = np.reshape(observer, (nv, npp,3)) + observer = np.repeat(observer, nvs, axis=0) + observer = np.reshape(observer, (-1, 3)) + + curr_tile = np.repeat(curr_tile, npp) + pos_start = np.repeat(pos_start, npp, axis=0) + pos_end = np.repeat(pos_end, npp, axis=0) + + # compute field + field = current_line_field(curr_tile, pos_start, pos_end, observer, field=field) + field = np.reshape(field, (nseg, npp, 3)) + + # sum for each vertex set + ns_cum = [sum(nvs[:i]) for i in range(nv+1)] # cumulative index positions + field_sum = np.array([np.sum(field[ns_cum[i-1]:ns_cum[i]], axis=0) for i in range(1,nv+1)]) + + return np.reshape(field_sum, (-1,3)) # ON INTERFACE From 204938655f91424883e8586771a1fa4267af11eb Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 14:04:37 +0200 Subject: [PATCH 078/207] core function argument consistency * 'field' is now a positional arg only * field and observer always in pos 1 and 2 --- magpylib/_src/fields/field_BH_cuboid.py | 20 +++---- magpylib/_src/fields/field_BH_cylinder.py | 11 +++- .../_src/fields/field_BH_cylinder_segment.py | 18 +++---- magpylib/_src/fields/field_BH_dipole.py | 14 ++--- magpylib/_src/fields/field_BH_line.py | 54 +++++++++---------- magpylib/_src/fields/field_BH_loop.py | 19 ++++--- magpylib/_src/fields/field_BH_sphere.py | 18 +++---- magpylib/_src/fields/field_wrap_BH_level1.py | 16 +++--- tests/test_field_cylinder.py | 8 +-- tests/test_field_functions.py | 44 +++++++-------- tests/test_input_checks.py | 6 +-- tests/test_obj_Cuboid.py | 4 +- tests/test_obj_Sphere.py | 2 +- 13 files changed, 120 insertions(+), 114 deletions(-) diff --git a/magpylib/_src/fields/field_BH_cuboid.py b/magpylib/_src/fields/field_BH_cuboid.py index 93f41a749..097313a66 100644 --- a/magpylib/_src/fields/field_BH_cuboid.py +++ b/magpylib/_src/fields/field_BH_cuboid.py @@ -7,10 +7,10 @@ from magpylib._src.input_checks import check_field_input def magnet_cuboid_field( - magnetization: np.ndarray, - dimension: np.ndarray, + field: str, observer: np.ndarray, - field='B') -> np.ndarray: + magnetization: np.ndarray, + dimension: np.ndarray) -> np.ndarray: """Magnetic field of a homogeneously magnetized cuboid. The cuboid sides are parallel to the coordinate axes. The geometric center of the @@ -18,19 +18,19 @@ def magnet_cuboid_field( Parameters ---------- + field: str, default=`'B'` + If `field='B'` return B-field in units of [mT], if `field='H'` return H-field + in units of [kA/m]. + + observer: ndarray, shape (n,3) + Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. + magnetization: ndarray, shape (n,3) Homogeneous magnetization vector in units of [mT]. dimension: ndarray, shape (n,3) Cuboid side lengths in units of [mm]. - observer: ndarray, shape (n,3) - Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. - - field: str, default=`'B'` - If `field='B'` return B-field in units of [mT], if `field='H'` return H-field - in units of [kA/m]. - Returns ------- B-field or H-field: ndarray, shape (n,3) diff --git a/magpylib/_src/fields/field_BH_cylinder.py b/magpylib/_src/fields/field_BH_cylinder.py index 81b94d927..07cd79cb0 100644 --- a/magpylib/_src/fields/field_BH_cylinder.py +++ b/magpylib/_src/fields/field_BH_cylinder.py @@ -203,10 +203,10 @@ def fieldH_cylinder_diametral( # ON INTERFACE def magnet_cylinder_field( + field: str, + observer: np.ndarray, magnetization: np.ndarray, dimension: np.ndarray, - observer: np.ndarray, - field='B', ) -> np.ndarray: """Magnetic field of a homogeneously magnetized cylinder. @@ -215,6 +215,13 @@ def magnet_cylinder_field( Parameters ---------- + field: str, default=`'B'` + If `field='B'` return B-field in units of [mT], if `field='H'` return H-field + in units of [kA/m]. + + observer: ndarray, shape (n,3) + Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. + magnetization: ndarray, shape (n,3) Homogeneous magnetization vector in units of [mT]. diff --git a/magpylib/_src/fields/field_BH_cylinder_segment.py b/magpylib/_src/fields/field_BH_cylinder_segment.py index 4ef2a7b3a..23d17b726 100644 --- a/magpylib/_src/fields/field_BH_cylinder_segment.py +++ b/magpylib/_src/fields/field_BH_cylinder_segment.py @@ -1350,10 +1350,10 @@ def magnet_cylinder_segment_core( def magnet_cylinder_segment_field( + field: str, + observer: np.ndarray, magnetization: np.ndarray, dimension: np.ndarray, - observer: np.ndarray, - field='B', ) -> np.ndarray: """Magnetic field of a homogeneously magnetized cylinder segment. @@ -1362,6 +1362,13 @@ def magnet_cylinder_segment_field( Parameters ---------- + field: str, default=`'B'` + If `field='B'` return B-field in units of [mT], if `field='H'` return H-field + in units of [kA/m]. + + observer: ndarray, shape (n,3) + Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. + magnetization: ndarray, shape (n,3) Homogeneous magnetization vector in units of [mT]. @@ -1369,13 +1376,6 @@ def magnet_cylinder_segment_field( Cylinder segment dimensions (r1,r2,h,phi1,phi2) with inner radius r1, outer radius r2, height h in units of [mm] and the two segment angles phi1 and phi2 in units of [deg]. - observer: ndarray, shape (n,3) - Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. - - field: str, default=`'B'` - If `field='B'` return B-field in units of [mT], if `field='H'` return H-field - in units of [kA/m]. - Returns ------- B-field or H-field: ndarray, shape (n,3) diff --git a/magpylib/_src/fields/field_BH_dipole.py b/magpylib/_src/fields/field_BH_dipole.py index 1c37973b3..6c0f469f3 100644 --- a/magpylib/_src/fields/field_BH_dipole.py +++ b/magpylib/_src/fields/field_BH_dipole.py @@ -7,9 +7,9 @@ def dipole_field( - moment: np.ndarray, + field: str, observer: np.ndarray, - field='B' + moment: np.ndarray, ) -> np.ndarray: """Magnetic field of a dipole moment. @@ -17,16 +17,16 @@ def dipole_field( Parameters ---------- - moment: ndarray, shape (n,3) - Dipole moment vector in units of [mT*mm^3]. + field: str, default=`'B'` + If `field='B'` return B-field in units of [mT], if `field='H'` return H-field observer: ndarray, shape (n,3) Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. - - field: str, default=`'B'` - If `field='B'` return B-field in units of [mT], if `field='H'` return H-field in units of [kA/m]. + moment: ndarray, shape (n,3) + Dipole moment vector in units of [mT*mm^3]. + Returns ------- B-field or H-field: ndarray, shape (n,3) diff --git a/magpylib/_src/fields/field_BH_line.py b/magpylib/_src/fields/field_BH_line.py index ccf2288ef..b3e43f01d 100644 --- a/magpylib/_src/fields/field_BH_line.py +++ b/magpylib/_src/fields/field_BH_line.py @@ -8,10 +8,10 @@ def field_BH_line_from_vert( - current: np.ndarray, - vertex_sets: list, # list of mix3 ndarrays - pos_obs: np.ndarray, field: str, + observer: np.ndarray, + current: np.ndarray, + vertices: list, # list of mix3 ndarrays ) -> np.ndarray: """ This function accepts n (mi,3) shaped vertex-sets, creates a single long @@ -28,27 +28,27 @@ def field_BH_line_from_vert( - B-field (ndarray nx3): B-field vectors at pos_obs in units of mT """ - nv = len(vertex_sets) # number of input vertex_sets - npp = int(pos_obs.shape[0]/nv) # number of position vectors - nvs = [len(vset)-1 for vset in vertex_sets] # length of vertex sets + nv = len(vertices) # number of input vertex_sets + npp = int(observer.shape[0]/nv) # number of position vectors + nvs = [len(vset)-1 for vset in vertices] # length of vertex sets nseg = sum(nvs) # number of segments # vertex_sets -> segments curr_tile = np.repeat(current, nvs) - pos_start = np.concatenate([vert[:-1] for vert in vertex_sets]) - pos_end = np.concatenate([vert[1:] for vert in vertex_sets]) + pos_start = np.concatenate([vert[:-1] for vert in vertices]) + pos_end = np.concatenate([vert[1:] for vert in vertices]) # create input for vectorized computation in one go - pos_obs = np.reshape(pos_obs, (nv, npp,3)) - pos_obs = np.repeat(pos_obs, nvs, axis=0) - pos_obs = np.reshape(pos_obs, (-1, 3)) + observer = np.reshape(observer, (nv, npp,3)) + observer = np.repeat(observer, nvs, axis=0) + observer = np.reshape(observer, (-1, 3)) curr_tile = np.repeat(curr_tile, npp) pos_start = np.repeat(pos_start, npp, axis=0) pos_end = np.repeat(pos_end, npp, axis=0) # compute field - field = current_line_field(curr_tile, pos_start, pos_end, pos_obs, field=field) + field = current_line_field(field, observer, curr_tile, pos_start, pos_end) field = np.reshape(field, (nseg, npp, 3)) # sum for each vertex set @@ -60,11 +60,11 @@ def field_BH_line_from_vert( # ON INTERFACE def current_line_field( - current: np.ndarray, - start: np.ndarray, - end: np.ndarray, + field: str, observer: np.ndarray, - field='B' + current: np.ndarray, + segment_start: np.ndarray, + segment_end: np.ndarray, ) -> np.ndarray: """Magnetic field of line current segments. @@ -73,6 +73,13 @@ def current_line_field( Parameters ---------- + field: str, default=`'B'` + If `field='B'` return B-field in units of [mT], if `field='H'` return H-field + in units of [kA/m]. + + observer: ndarray, shape (n,3) + Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. + current: ndarray, shape (n,) Electrical current in units of [A]. @@ -82,13 +89,6 @@ def current_line_field( end: ndarray, shape (n,3) Line end positions (x,y,z) in Cartesian coordinates in units of [mm]. - observer: ndarray, shape (n,3) - Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. - - field: str, default=`'B'` - If `field='B'` return B-field in units of [mT], if `field='H'` return H-field - in units of [kA/m]. - Returns ------- B-field or H-field: ndarray, shape (n,3) @@ -124,7 +124,7 @@ def current_line_field( field_all = np.zeros((ntot,3)) # Check for zero-length segments - mask0 = np.all(start==end, axis=1) + mask0 = np.all(segment_start==segment_end, axis=1) if np.all(mask0): return field_all @@ -132,12 +132,12 @@ def current_line_field( if np.any(mask0): not_mask0 = ~mask0 # avoid multiple computation of ~mask current = current[not_mask0] - start = start[not_mask0] - end = end[not_mask0] + segment_start = segment_start[not_mask0] + segment_end = segment_end[not_mask0] observer = observer[not_mask0] # rename - p1,p2,po = start, end, observer + p1,p2,po = segment_start, segment_end, observer # make dimensionless (avoid all large/small input problems) by introducing # the segment length as characteristic length scale. diff --git a/magpylib/_src/fields/field_BH_loop.py b/magpylib/_src/fields/field_BH_loop.py index 79b5ded53..2debac525 100644 --- a/magpylib/_src/fields/field_BH_loop.py +++ b/magpylib/_src/fields/field_BH_loop.py @@ -12,10 +12,10 @@ # ON INTERFACE def current_loop_field( + field: str, + observer: np.ndarray, current: np.ndarray, diameter: np.ndarray, - observer: np.ndarray, - field='B' ) -> np.ndarray: """Magnetic field of a circular (line) current loop. @@ -23,19 +23,18 @@ def current_loop_field( Parameters ---------- - current: ndarray, shape (n,) - Electrical current in units of [A]. - - diameter: ndarray, shape (n,) - Diameter of loop in units of [mm]. + field: str, default=`'B'` + If `field='B'` return B-field in units of [mT], if `field='H'` return H-field + in units of [kA/m]. observer: ndarray, shape (n,3) Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. - field: str, default=`'B'` - If `field='B'` return B-field in units of [mT], if `field='H'` return H-field - in units of [kA/m]. + current: ndarray, shape (n,) + Electrical current in units of [A]. + diameter: ndarray, shape (n,) + Diameter of loop in units of [mm]. Returns ------- B-field or H-field: ndarray, shape (n,3) diff --git a/magpylib/_src/fields/field_BH_sphere.py b/magpylib/_src/fields/field_BH_sphere.py index 9ee7cf0fa..fa21013d4 100644 --- a/magpylib/_src/fields/field_BH_sphere.py +++ b/magpylib/_src/fields/field_BH_sphere.py @@ -8,10 +8,10 @@ def magnet_sphere_field( + field: str, + observer: np.ndarray, magnetization: np.ndarray, diameter: np.ndarray, - observer: np.ndarray, - field='B' )->np.ndarray: """Magnetic field of a homogeneously magnetized sphere. @@ -19,19 +19,19 @@ def magnet_sphere_field( Parameters ---------- + field: str, default=`'B'` + If `field='B'` return B-field in units of [mT], if `field='H'` return H-field + in units of [kA/m]. + + observer: ndarray, shape (n,3) + Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. + magnetization: ndarray, shape (n,3) Homogeneous magnetization vector in units of [mT]. diameter: ndarray, shape (n,3) Sphere diameter in units of [mm]. - observer: ndarray, shape (n,3) - Observer positions (x,y,z) in Cartesian coordinates in units of [mm]. - - field: str, default=`'B'` - If `field='B'` return B-field in units of [mT], if `field='H'` return H-field - in units of [kA/m]. - Returns ------- B-field or H-field: ndarray, shape (n,3) diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py index 3b1afdc66..3a68876de 100644 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ b/magpylib/_src/fields/field_wrap_BH_level1.py @@ -43,41 +43,41 @@ def getBH_level1(**kwargs:dict) -> np.ndarray: if src_type == 'Cuboid': mag = kwargs['magnetization'] dim = kwargs['dimension'] - B = magnet_cuboid_field(mag, dim, pos_rel_rot, field=field) + B = magnet_cuboid_field(field, pos_rel_rot, mag, dim) elif src_type == 'Cylinder': mag = kwargs['magnetization'] dim = kwargs['dimension'] - B = magnet_cylinder_field(mag, dim, pos_rel_rot, field=field) + B = magnet_cylinder_field(field, pos_rel_rot, mag, dim) elif src_type == 'CylinderSegment': mag = kwargs['magnetization'] dim = kwargs['dimension'] - B = magnet_cylinder_segment_field(mag, dim, pos_rel_rot, field=field) + B = magnet_cylinder_segment_field(field, pos_rel_rot, mag, dim) elif src_type == 'Sphere': mag = kwargs['magnetization'] dia = kwargs['diameter'] - B = magnet_sphere_field(mag, dia, pos_rel_rot, field=field) + B = magnet_sphere_field(field, pos_rel_rot, mag, dia) elif src_type == 'Dipole': moment = kwargs['moment'] - B = dipole_field(moment, pos_rel_rot, field=field) + B = dipole_field(field, pos_rel_rot, moment) elif src_type == 'Loop': current = kwargs['current'] dia = kwargs['diameter'] - B = current_loop_field(current, dia, pos_rel_rot, field=field) + B = current_loop_field(field, pos_rel_rot, current, dia) elif src_type =='Line': current = kwargs['current'] if 'vertices' in kwargs: vertices = kwargs['vertices'] - B = field_BH_line_from_vert(current, vertices, pos_rel_rot, field=field) + B = field_BH_line_from_vert(field, pos_rel_rot, current, vertices) else: pos_start = kwargs['segment_start'] pos_end = kwargs['segment_end'] - B = current_line_field(current, pos_start, pos_end, pos_rel_rot, field=field) + B = current_line_field(field, pos_rel_rot, current, pos_start, pos_end) elif src_type == 'CustomSource': #bh_key = 'B' if bh else 'H' diff --git a/tests/test_field_cylinder.py b/tests/test_field_cylinder.py index c850f839a..9f5220102 100644 --- a/tests/test_field_cylinder.py +++ b/tests/test_field_cylinder.py @@ -220,7 +220,7 @@ def test_cylinder_field1(): eins = np.ones(N) d, h, _ = dim.T dim5 = np.array([nulll, d / 2, h, nulll, eins * 360]).T - B1 = magnet_cylinder_segment_field(magg, dim5, poso) + B1 = magnet_cylinder_segment_field('B', poso, magg, dim5) assert np.allclose(B1, B0) @@ -450,10 +450,10 @@ def test_cylinder_diametral_small_r(): test if the gneral case fluctuations are small """ B = magpy.core.magnet_cylinder_field( - np.array([(1,1,0)]*1000), - np.array([(2,2)]*1000), + 'B', np.array([(x,0,3) for x in np.logspace(-1.4,-1.2,1000)]), - field='B') + np.array([(1,1,0)]*1000), + np.array([(2,2)]*1000)) dB = np.log(abs(B[1:]-B[:-1])) ddB = abs(dB[1:]-dB[:-1]) diff --git a/tests/test_field_functions.py b/tests/test_field_functions.py index f02d06f6c..78340db52 100644 --- a/tests/test_field_functions.py +++ b/tests/test_field_functions.py @@ -14,7 +14,7 @@ def test_magnet_cuboid_Bfield(): mag = np.array([(0,0,0), (1,2,3), (1,2,3), (1,2,3), (1,2,3), (2,2,2), (2,2,2), (1,1,1), (1,1,1)]) dim = np.array([(1,2,3), (-1,-2,2), (1,2,2), (0,2,2), (1,2,3), (2,2,2), (2,2,2), (2,2,2), (2,2,2)]) pos = np.array([(1,2,3), (1,-1,0), (1,-1,0), (1,-1,0), (1,2,3), (1,1+1e-14,0), (1,1,1), (1,-1,2), (1+1e-14,-1,2)]) - B = magnet_cuboid_field(mag, dim, pos) + B = magnet_cuboid_field('B', pos, mag, dim) Btest = [ [ 0. , 0. , 0. ], @@ -37,7 +37,7 @@ def test_magnet_cuboid_field_mag0(): mag = np.zeros((n,3)) dim = np.random.rand(n,3) pos = np.random.rand(n,3) - B = magnet_cuboid_field(mag, dim, pos) + B = magnet_cuboid_field('B', pos, mag, dim) assert_allclose(mag,B) @@ -51,7 +51,7 @@ def test_field_BH_cylinder_tile_mag0(): phi2=phi1+phi2 dim = np.array([r1,r2,h,phi1,phi2]).T pos = np.random.rand(n,3) - B = magnet_cylinder_segment_field(mag, dim, pos) + B = magnet_cylinder_segment_field('B', pos, mag, dim) assert_allclose(mag, B) @@ -71,7 +71,7 @@ def test_field_sphere_vs_v2(): dim = np.array([1.23]*7) mag = np.array([(33,66,99)]*7) poso = np.array([(0,0,0),(.2,.2,.2),(.4,.4,.4),(-1,-1,-2),(.1,.1,.1),(1,2,-3),(-3,2,1)]) - B = magnet_sphere_field(mag, dim, poso ) + B = magnet_sphere_field('B', poso, mag, dim) np.testing.assert_allclose(result_v2, B, rtol=1e-6) @@ -83,7 +83,7 @@ def test_magnet_sphere_field_mag0(): mag = np.zeros((n,3)) dim = np.random.rand(n) pos = np.random.rand(n,3) - B = magnet_sphere_field(mag, dim, pos) + B = magnet_sphere_field('B', pos, mag, dim) assert_allclose(mag,B) @@ -92,7 +92,7 @@ def test_field_dipole1(): """ poso = np.array([(1,2,3),(-1,2,3)]) mom = np.array([(2,3,4),(0,-3,-2)]) - B = dipole_field(mom, poso, field='B')*np.pi + B = dipole_field('B', poso, mom)*np.pi Btest = np.array([ (0.01090862,0.02658977,0.04227091), (0.0122722,-0.01022683,-0.02727156), @@ -106,7 +106,7 @@ def test_field_dipole2(): """ moment = np.array([(100,200,300)]*2 + [(0,0,0)]*2) observer = np.array([(0,0,0),(1,2,3)]*2) - B = dipole_field(moment, observer) + B = dipole_field('B', observer, moment) assert all(np.isinf(B[0])) assert_allclose(B[1:], @@ -145,12 +145,12 @@ def test_field_loop(): current = np.array([1,1] + [123]*8) dim = np.array([2,2] + [2]*8) - B = current_loop_field(current, dim, pos_test) + B = current_loop_field('B', pos_test, current, dim) assert_allclose(B, Btest, rtol=1e-6) Htest = Btest*10/4/np.pi - H = current_loop_field(current, dim, pos_test, field='H') + H = current_loop_field('H', pos_test, current, dim) assert_allclose(H, Htest, rtol=1e-6) @@ -160,12 +160,12 @@ def test_field_loop2(): curr = np.array([1]) dim = np.array([2]) poso = np.array([[0,0,0]]) - B = current_loop_field(curr, dim, poso) + B = current_loop_field('B', poso, curr, dim) curr = np.array([1]*2) dim = np.array([2]*2) poso = np.array([[0,0,0]]*2) - B2 = current_loop_field(curr, dim, poso) + B2 = current_loop_field('B', poso, curr, dim) assert_allclose(B, (B2[0],)) assert_allclose(B, (B2[1],)) @@ -178,7 +178,7 @@ def test_field_loop_specials(): dia = np.array([2,2,0,0,2,2]) obs = np.array([(0,0,0), (1,0,0), (0,0,0), (1,0,0), (1,0,0), (0,0,0)]) - B = current_loop_field(cur, dia, obs) + B = current_loop_field('B', obs, cur, dia) Btest = [[0,0,0.62831853], [0,0,0], [0,0,0], [0,0,0], [0,0,0], [0,0,1.25663706]] assert_allclose(B, Btest) @@ -193,18 +193,18 @@ def test_field_line(): pe1 = np.array([(2,2,2)]) # only normal - B1 = current_line_field(c1, ps1, pe1, po1) + B1 = current_line_field('B', po1, c1, ps1, pe1) x1 = np.array([[ 0.02672612, -0.05345225, 0.02672612]]) assert_allclose(x1, B1, rtol=1e-6) # only on_line po1b = np.array([(1,1,1)]) - B2 = current_line_field(c1, ps1, pe1, po1b) + B2 = current_line_field('B', po1b, c1, ps1, pe1) x2 = np.zeros((1,3)) assert_allclose(x2, B2, rtol=1e-6) # only zero-segment - B3 = current_line_field(c1, ps1, ps1, po1) + B3 = current_line_field('B', po1, c1, ps1, ps1) x3 = np.zeros((1,3)) assert_allclose(x3, B3, rtol=1e-6) @@ -213,19 +213,19 @@ def test_field_line(): ps2 = np.array([(0,0,0)]*2) pe2 = np.array([(0,0,0),(2,2,2)]) po2 = np.array([(1,2,3),(1,1,1)]) - B4 = current_line_field(c2, ps2, pe2, po2) + B4 = current_line_field('B', po2, c2, ps2, pe2) x4 = np.zeros((2,3)) assert_allclose(x4, B4, rtol=1e-6) # normal + zero_segment po2b = np.array([(1,2,3),(1,2,3)]) - B5 = current_line_field(c2, ps2, pe2, po2b) + B5 = current_line_field('B', po2b, c2, ps2, pe2) x5 = np.array([[0,0,0],[ 0.02672612, -0.05345225, 0.02672612]]) assert_allclose(x5, B5, rtol=1e-6) # normal + on_line pe2b = np.array([(2,2,2)]*2) - B6 = current_line_field(c2, ps2, pe2b, po2) + B6 = current_line_field('B', po2, c2, ps2, pe2b) x6 = np.array([[0.02672612, -0.05345225, 0.02672612],[0,0,0]]) assert_allclose(x6, B6, rtol=1e-6) @@ -234,7 +234,7 @@ def test_field_line(): ps4 = np.array([(0,0,0)]*3) pe4 = np.array([(0,0,0),(2,2,2),(2,2,2)]) po4 = np.array([(1,2,3),(1,2,3),(1,1,1)]) - B7 = current_line_field(c4, ps4, pe4, po4) + B7 = current_line_field('B', po4, c4, ps4, pe4) x7 = np.array([[0,0,0], [0.02672612, -0.05345225, 0.02672612], [0,0,0]]) assert_allclose(x7, B7, rtol=1e-6) @@ -250,7 +250,7 @@ def test_field_line_from_vert(): vert3 = np.array([(1,2,3),(-2,-3,3),(3,2,1),(3,3,3)]) pos_tiled = np.tile(p, (3,1)) - B_vert = field_BH_line_from_vert(curr, [vert1,vert2,vert3], pos_tiled, field='B') + B_vert = field_BH_line_from_vert('B', pos_tiled, curr, [vert1,vert2,vert3]) B = [] for i,vert in enumerate([vert1,vert2,vert3]): @@ -259,7 +259,7 @@ def test_field_line_from_vert(): p2 = vert[1:] po = np.array([pos]*(len(vert)-1)) cu = np.array([curr[i]]*(len(vert)-1)) - B += [np.sum(current_line_field(cu, p1, p2, po), axis=0)] + B += [np.sum(current_line_field('B', po, cu, p1, p2), axis=0)] B = np.array(B) assert_allclose(B_vert, B) @@ -272,7 +272,7 @@ def test_field_line_v4(): start = np.array([(-1,0,0)]*7) end = np.array([(1,0,0), (-1,0,0), (1,0,0), (-1,0,0)] + [(1,0,0)]*3) obs = np.array([(0,0,1),(0,0,0), (0,0,0), (0,0,0), (0,0,1e-16), (2,0,1), (-2,0,1)]) - B = current_line_field(cur, start, end, obs) + B = current_line_field('B', obs, cur, start, end) Btest = np.array( [[0, -0.14142136, 0], [0, 0. , 0], diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index b2ed2ce39..dfb7b3263 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -814,7 +814,7 @@ def test_input_getBH_field_good(): for good in goods: moms = np.array([[1,2,3]]) obs = np.array([[1,2,3]]) - B = magpy.core.dipole_field(moms, obs, field=good) + B = magpy.core.dipole_field(good, obs, moms) assert isinstance(B, np.ndarray) @@ -838,7 +838,7 @@ def test_input_getBH_field_bad(): np.testing.assert_raises( MagpylibBadUserInput, magpy.core.dipole_field, - moms, + bad, obs, - field=bad, + moms, ) diff --git a/tests/test_obj_Cuboid.py b/tests/test_obj_Cuboid.py index 9d8b0c807..263535d29 100644 --- a/tests/test_obj_Cuboid.py +++ b/tests/test_obj_Cuboid.py @@ -98,8 +98,8 @@ def test_cuboid_object_vs_lib(): mag = np.array([(10,20,30)]) dim = np.array([( a, a, a)]) pos = np.array([(2*a,2*a,2*a)]) - B0 = magpy.core.magnet_cuboid_field(mag, dim, pos) - H0 = magpy.core.magnet_cuboid_field(mag, dim, pos, field='H') + B0 = magpy.core.magnet_cuboid_field('B', pos, mag, dim) + H0 = magpy.core.magnet_cuboid_field('H', pos, mag, dim) src = magpy.magnet.Cuboid(mag[0], dim[0]) B1 = src.getB(pos) diff --git a/tests/test_obj_Sphere.py b/tests/test_obj_Sphere.py index 82dd6f5fe..cd60f35e3 100644 --- a/tests/test_obj_Sphere.py +++ b/tests/test_obj_Sphere.py @@ -94,7 +94,7 @@ def test_sphere_object_vs_lib(): mag = np.array([(10,20,30)]) dia = np.array([1]) pos = np.array([(2,2,2)]) - B1 = magpy.core.magnet_sphere_field(mag, dia, pos)[0] + B1 = magpy.core.magnet_sphere_field('B', pos, mag, dia)[0] src = magpy.magnet.Sphere(mag[0], dia[0]) B2 = src.getB(pos) From f2aec9f369f197422e65bfeae10a71148836bd92 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Wed, 30 Mar 2022 14:56:30 +0200 Subject: [PATCH 079/207] remove return self of reset() --- magpylib/_src/defaults/defaults_classes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/defaults/defaults_classes.py b/magpylib/_src/defaults/defaults_classes.py index 1a6ed6a43..0cf8cfd0b 100644 --- a/magpylib/_src/defaults/defaults_classes.py +++ b/magpylib/_src/defaults/defaults_classes.py @@ -31,7 +31,7 @@ def __init__( def reset(self): """Resets all nested properties to their hard coded default values""" self.update(get_defaults_dict(), _match_properties=False) - return self + # no return self ! @property def display(self): From 1d26fde24fac1eff669badbb4e2e34cfb4a38777 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Wed, 30 Mar 2022 14:57:01 +0200 Subject: [PATCH 080/207] allow collection/add/remove flat list input --- magpylib/_src/input_checks.py | 5 ++-- magpylib/_src/obj_classes/class_Collection.py | 17 +++++++++---- tests/test_input_checks.py | 24 +++++++++---------- 3 files changed, 28 insertions(+), 18 deletions(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 23ca8ffbd..57dae9623 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -443,7 +443,8 @@ def check_format_input_obj( Parameters ---------- - input: can be sources, sensor or collection objects + input: can be + - objects allow: str Specify which object types are wanted, separate by +, @@ -485,7 +486,7 @@ def check_format_input_obj( if typechecks: if not obj_type in all_types: raise MagpylibBadUserInput( - f"Input objects must be {allow}.\n" + f"Input objects must be {allow} or a flat list thereof.\n" f"Instead received {type(obj)}." ) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 00d0ab0a1..03eb2b953 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -142,7 +142,7 @@ def children(self, children): for child in self._children: child._parent = None self._children = [] - self.add(*children) + self.add(*children, override_parent=True) @property def sources(self): @@ -161,7 +161,7 @@ def sources(self, sources): new_children.append(child) self._children = new_children src_list = format_obj_input(sources, allow="sources") - self.add(*src_list) + self.add(*src_list, override_parent=True) @property def sensors(self): @@ -180,7 +180,7 @@ def sensors(self, sensors): new_children.append(child) self._children = new_children sens_list = format_obj_input(sensors, allow="sensors") - self.add(*sens_list) + self.add(*sens_list, override_parent=True) @property def collections(self): @@ -199,7 +199,7 @@ def collections(self, collections): new_children.append(child) self._children = new_children coll_list = format_obj_input(collections, allow="collections") - self.add(*coll_list) + self.add(*coll_list, override_parent=True) # dunders def __iter__(self): @@ -288,6 +288,11 @@ def add(self, *children, override_parent=False): └── x2 """ # pylint: disable=protected-access + + # allow flat lists as input + if len(children)==1 and isinstance(children[0], (list, tuple)): + children = children[0] + # check and format input obj_list = check_format_input_obj( children, @@ -367,6 +372,10 @@ def remove(self, *children, recursive=True, errors="raise"): """ # pylint: disable=protected-access + # allow flat lists as input + if len(children)==1 and isinstance(children[0], (list, tuple)): + children = children[0] + # check and format input remove_objects = check_format_input_obj( children, diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index b2ed2ce39..edab887dd 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -656,6 +656,8 @@ def test_input_collection_good(): [x()], [s()], [c()], [x(), s(), c()], [x(), x(), s(), s(), c(), c()], + [[x(), s(), c()]], + [(x(), s(), c())], ] for good in goods: @@ -671,10 +673,8 @@ def test_input_collection_bad(): c = lambda : magpy.Collection() bads = [ - 'some_string', None, [], True, 1, np.array((1,2,3)), - [x(), s(), c()], + 'some_string', None, True, 1, np.array((1,2,3)), [x(), [s(), c()]], - (x(), s(), c()), ] for bad in bads: np.testing.assert_raises(MagpylibBadUserInput, magpy.Collection, bad) @@ -691,6 +691,8 @@ def test_input_collection_add_good(): [x()], [s()], [c()], [x(), s(), c()], [x(), x(), s(), s(), c(), c()], + [[x(), s(), c()]], + [(x(), s(), c())], ] for good in goods: @@ -707,10 +709,8 @@ def test_input_collection_add_bad(): c = lambda : magpy.Collection() bads = [ - 'some_string', None, [], True, 1, np.array((1,2,3)), - [x(), s(), c()], + 'some_string', None, True, 1, np.array((1,2,3)), [x(), [s(), c()]], - (x(), s(), c()), ] for bad in bads: col = magpy.Collection() @@ -726,13 +726,15 @@ def test_input_collection_remove_good(): goods = [ #unpacked [x], [s], [c], [x, s, c], + [[x,s]], + [(x,s)], ] for good in goods: col = magpy.Collection(*good) - assert len(col.children) == len(good) + assert col.children == (list(good[0]) if isinstance(good[0], (tuple, list)) else good) col.remove(*good) - assert len(col.children) == 0 + assert not col.children def test_input_collection_remove_bad(): @@ -746,10 +748,8 @@ def test_input_collection_remove_bad(): col = magpy.Collection(x1, x2, s1, s2, c1) bads = [ - 'some_string', None, [], True, 1, np.array((1,2,3)), - [x1], - (x2, s1), - [s2, c1], + 'some_string', None, True, 1, np.array((1,2,3)), + [x1, [x2]] ] for bad in bads: with np.testing.assert_raises(MagpylibBadUserInput): From 876556d63d198332c0b7183cd24b561b6a5210a2 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Wed, 30 Mar 2022 15:06:57 +0200 Subject: [PATCH 081/207] take reset change back --- magpylib/_src/defaults/defaults_classes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/defaults/defaults_classes.py b/magpylib/_src/defaults/defaults_classes.py index 0cf8cfd0b..1a6ed6a43 100644 --- a/magpylib/_src/defaults/defaults_classes.py +++ b/magpylib/_src/defaults/defaults_classes.py @@ -31,7 +31,7 @@ def __init__( def reset(self): """Resets all nested properties to their hard coded default values""" self.update(get_defaults_dict(), _match_properties=False) - # no return self ! + return self @property def display(self): From 4991cff105d6a077942ec9ed01e865c8135d644c Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Wed, 30 Mar 2022 15:21:50 +0200 Subject: [PATCH 082/207] complete docs --- CHANGELOG.md | 12 +- docs/_pages/page_01_introduction.md | 222 +++++++++-------- docs/_pages/page_03_installation.md | 59 +---- docs/_pages/page_07_physics_computation.md | 82 +++---- docs/examples/01_fundamentals.md | 1 + docs/examples/02_graphic_output.md | 1 - docs/examples/03_advanced.md | 4 +- docs/examples/examples_01_complex_forms.md | 12 +- docs/examples/examples_02_paths.md | 10 +- docs/examples/examples_03_collections.md | 185 +++++++++----- docs/examples/examples_04_custom_source.md | 6 +- docs/examples/examples_05_backend_canvas.md | 115 +++++++++ docs/examples/examples_12_styles.md | 10 +- docs/examples/examples_13_3d_models.md | 102 +++++++- docs/examples/examples_14_adding_cad_model.md | 107 --------- docs/examples/examples_21_compound.md | 218 +++++++++++++++++ docs/examples/examples_21_compound_class.md | 226 ------------------ ....md => examples_22_field_interpolation.md} | 0 docs/examples/examples_30_field_of_a_coil.md | 6 +- docs/index.md | 6 +- 20 files changed, 748 insertions(+), 636 deletions(-) create mode 100644 docs/examples/examples_05_backend_canvas.md delete mode 100644 docs/examples/examples_14_adding_cad_model.md create mode 100644 docs/examples/examples_21_compound.md delete mode 100644 docs/examples/examples_21_compound_class.md rename docs/examples/{examples_22_fem_interpolation.md => examples_22_field_interpolation.md} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27c126826..63b7cd6d4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,8 @@ This is a major update that includes ### New documentation: - Completely new structure and layout. ([#399](https://github.com/magpylib/magpylib/issues/399), [#294](https://github.com/magpylib/magpylib/issues/294)) - Binder links and live code. ([#389](https://github.com/magpylib/magpylib/issues/389)) +- Example galleries with practical user examples +- Guidelines for advanced subclassing of `Collection` to form complex dynamic compound objects that seamlessly integrate into the MAgpylib interface. ### Geometry interface modification - Added all scipy Rotation forms as rotation object methods. ([#427](https://github.com/magpylib/magpylib/pull/427)) @@ -73,14 +75,14 @@ This is a major update that includes ### Modifications to the `Collection` class - Collections can now contain source, `Sensor` and other `Collection` objects and can function as source and observer inputs in `getB` and `getH`. ([#410](https://github.com/magpylib/magpylib/issues/410), [#415](https://github.com/magpylib/magpylib/pull/415), [#297](https://github.com/magpylib/magpylib/issues/297)) -- Instead of the property `Collection.sources` there are now the `Collection.children`, `Collection.sources`, `Collection.sensors` and `Collection.collections` properties. ([#446](https://github.com/magpylib/magpylib/issues/446), [#502](https://github.com/magpylib/magpylib/pull/502)) +- Instead of the property `Collection.sources` there are now the `Collection.children`, `Collection.sources`, `Collection.sensors` and `Collection.collections` properties. Setting these collection properties will automatically override parents. ([#446](https://github.com/magpylib/magpylib/issues/446), [#502](https://github.com/magpylib/magpylib/pull/502)) - `Collection` has it's own `position`, `orientation` and `style`. ([#444](https://github.com/magpylib/magpylib/issues/444), [#461](https://github.com/magpylib/magpylib/issues/461)) -- All methods applied to a collection maintain the relative child-positions. +- All methods applied to a collection maintain relative child-positions in the local reference frame. - Added `__len__` dunder for `Collection`, so that `Collection.children` length is returned. ([#383](https://github.com/magpylib/magpylib/issues/383)) - `-` operation was removed. -- `+` operation now functions as `a + b = Collection(a, b)`. -- Collection input is now only `*args` anymore. List inputs like `Collection([a,b,c])` will raise an error. -- `add` and `remove` have been overhauled with additional functionality and both accept only `*args` as well. +- `+` operation now functions as `a + b = Collection(a, b)`. Warning: `a + b + c` now creates a nested collection ! +- Allowed `Collection`, `add` and `remove` input is now only `*args` or a single flat list or tuple of Magpylib objects. +- `add` and `remove` have some additional functionality related to child-parent relations. - The `describe` method gives a great Collection tree overview. ### Other changes/fixes: diff --git a/docs/_pages/page_01_introduction.md b/docs/_pages/page_01_introduction.md index e2df39474..f9f99216b 100644 --- a/docs/_pages/page_01_introduction.md +++ b/docs/_pages/page_01_introduction.md @@ -15,7 +15,7 @@ kernelspec: # Introduction -This section gives an introduction to the Magpylib API. Many practical examples how to use Magpylib can be found in the example galleries. Detailed package, class, method and function documentations are found in the {ref}`modindex`. +This section gives an introduction to the Magpylib API. More detailed information and practical examples how to use Magpylib can be found in the example galleries. The package documentation is found in the {ref}`modindex`. ## Contents @@ -36,9 +36,8 @@ This section gives an introduction to the Magpylib API. Many practical examples Magpylib provides fast and accurate magnetostatic field computation based on **analytical solutions** to permanent magnet and current problems. The field computation is coupled to a **position and orientation interface** that makes it easy to work with relative object positioning. The idea behind the main object oriented interface is: 1. Sensors, magnets, currents, etc. are created as Python objects with defined position and orientation in a global coordinate system. -2. After initialization, the Magpylib objects can easily be manipulated, grouped and moved around. -3. All objects can be displayed graphically using Matplotlib or Plotly 3D plotting. -4. Finally, the magnetic field generated by the source objects is computed at given observers. +2. After initialization, the Magpylib objects can easily be manipulated, grouped, moved around and displayed graphically using Matplotlib or Plotly 3D plotting. +3. When all objects are set up, the magnetic field generated by the source objects is computed at the observer objects. The following example code outlines this functionality: @@ -52,19 +51,19 @@ cube = magpy.magnet.Cuboid(magnetization=(0,0,100), dimension=(1,1,1)) loop = magpy.current.Loop(current=5, diameter=3, position=(0,0,-3)) sensor = magpy.Sensor(position=(0,0,2), style_size=1.8) -# 2. move objects around, define object paths +# 2. move objects and display graphically +sensor.rotate_from_rotvec((0,0,225), degrees=True) cube.position = np.linspace((-3,0,0), (3,0,0), 50) -cube.rotate_from_angax(angle=np.linspace(0,180,50), axis='z', start=0) loop.move(np.linspace((0,0,0), (0,0,6), 50), start=0) -sensor.rotate_from_rotvec((0,0,225), degrees=True) -# 3. display system graphically magpy.show(loop, cube, sensor, backend='plotly', animation=2, style_path_show=False) -# 4. compute field at sensor (and plot with Matplotlib) +# 3. compute field at sensor (and plot with Matplotlib) B = sensor.getB(cube, loop, sumup=True) -plt.plot(B) +plt.plot(B, label=['Bx', 'By', 'Bz']) +plt.legend() +plt.grid(color='.8') plt.show() ``` @@ -74,45 +73,45 @@ For users who would like to avoid the object oriented interface, the field imple ## When can you use Magpylib ? -The analytical solutions are exact when there is no material response and natural boundary conditions can be assumed. In general, Magpylib is at its best when dealing with air-coils (no eddy currents) and high grade permanent magnet assemblies (Ferrite, NdFeB, SmCo or similar materials). +The analytical solutions are exact when there is no material response and natural boundary conditions can be assumed. In general, Magpylib is at its best when dealing with air-coils (no eddy currents) and high grade permanent magnets (Ferrite, NdFeB, SmCo or similar materials). -When magnet permeabilities are below $\mu_r < 1.1$ the error typically undercuts 1-5 % (long magnet shapes are better, large distance from magnet is better). Demagnetization factors are not automatically included at this point. The line current solutions give the exact same field as outside of a wire that carries a homogenous current. For more details check out the {ref}`physComp` section. +When **magnet** permeabilities are below $\mu_r < 1.1$ the error typically undercuts 1-5 % (long magnet shapes are better, large distance from magnet is better). Demagnetization factors are not automatically included at this point. The line **current** solutions give the exact same field as outside of a wire that carries a homogenous current. For more details check out the {ref}`physComp` section. -Magpylib only provides solutions for simple geometric forms (cuboids, cylinders, lines, ...). How complex shapes can be constructed from these simple base shapes is described in {ref}`examples-complex-forms`. +Magpylib only provides solutions for simple geometric forms (cuboids, cylinders, lines, ...). How **complex shapes** can be constructed from these simple base shapes is described in {ref}`examples-complex-forms`. (intro-magpylib-objects)= ## The Magpylib objects -The most convenient way for working with Magpylib is through the **object oriented interface**. Magpylib objects represent magnetic field sources, sensors and collections with various defining attributes and methods. The following classes are implemented: +The most convenient way of working with Magpylib is through the **object oriented interface**. Magpylib objects represent magnetic field sources, sensors and collections with various defining attributes and methods. By default all objects are initialized with `position=(0,0,0)` and `orientation=None`. The following classes are implemented: **Magnets** -All magnet objects have the `magnetization` attribute which must be of the format $(m_x, m_y, m_z)$ and denotes the homogeneous magnetization vector in units of \[mT\]. It is often referred to as the remanence ($B_r=\mu_0 M$) in material data sheets. All magnets can be used as Magpylib `sources` input. +All magnet objects have the `magnetization` attribute which must be of the format $(m_x, m_y, m_z)$ and denotes the homogeneous magnetization vector in the local object coordinates in units of \[mT\]. It is often referred to as the remanence ($B_r=\mu_0 M$) in material data sheets. All magnets can be used as Magpylib `sources` input. -- **`Cuboid`**`(magnetization, dimension, position, orientation, style)` represents a magnet with cuboid shape. `dimension` has the format $(a,b,c)$ and denotes the sides of the cuboid in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the cuboid lies in the origin of the global coordinates, and the sides are parallel to the coordinate axes. +- **`Cuboid`**`(magnetization, dimension, position, orientation)` represents a magnet with cuboid shape. `dimension` has the format $(a,b,c)$ and denotes the sides of the cuboid in units of \[mm\]. By default the center of the cuboid lies in the origin of the global coordinates, and the sides are parallel to the coordinate axes. -- **`Cylinder`**`(magnetization, dimension, position, orientation, style)` represents a magnet with cylindrical shape. `dimension` has the format $(d,h)$ and denotes diameter and height of the cylinder in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis. +- **`Cylinder`**`(magnetization, dimension, position, orientation)` represents a magnet with cylindrical shape. `dimension` has the format $(d,h)$ and denotes diameter and height of the cylinder in units of \[mm\]. By default the center of the cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis. -- **`CylinderSegment`**`(magnetization, dimension, position, orientation, style)` represents a magnet with the shape of a cylindrical ring section. `dimension` has the format $(r_1,r_2,h,\varphi_1,\varphi_2)$ and denotes inner radius, outer radius and height in units of \[mm\] and the two section angles $\varphi_1<\varphi_2$ in \[deg\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the full cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis. +- **`CylinderSegment`**`(magnetization, dimension, position, orientation)` represents a magnet with the shape of a cylindrical ring section. `dimension` has the format $(r_1,r_2,h,\varphi_1,\varphi_2)$ and denotes inner radius, outer radius and height in units of \[mm\] and the two section angles $\varphi_1<\varphi_2$ in \[deg\]. By default the center of the full cylinder lies in the origin of the global coordinates, and the cylinder axis coincides with the z-axis. -- **`Sphere`**`(magnetization, diameter, position, orientation, style)` represents a magnet of spherical shape. `diameter` is the sphere diameter $d$ in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the center of the sphere lies in the origin of the global coordinates. +- **`Sphere`**`(magnetization, diameter, position, orientation)` represents a magnet of spherical shape. `diameter` is the sphere diameter $d$ in units of \[mm\]. By default the center of the sphere lies in the origin of the global coordinates. **Currents** All current objects have the `current` attribute which must be a scalar $i_0$ and denotes the electrical current in units of \[A\]. All currents can be used as Magpylib `sources` input. -- **`Loop`**`(current, diameter, position, orientation, style)` represents a circular current loop where `diameter` is the loop diameter $d$ in units of \[mm\]. By default (`position=(0,0,0)`, `orientation=None`) the loop lies in the xy-plane with its center in the origin of the global coordinates. +- **`Loop`**`(current, diameter, position, orientation)` represents a circular current loop where `diameter` is the loop diameter $d$ in units of \[mm\]. By default the loop lies in the xy-plane with it's center in the origin of the global coordinates. -- **`Line`**`(current, vertices, position, orientation, style)` represents electrical current segments that flow in a straight line from vertex to vertex. By default (`position=(0,0,0)`, `orientation=None`) the locally defined vertices have the same position in the global coordinates. +- **`Line`**`(current, vertices, position, orientation)` represents electrical current segments that flow in a straight line from vertex to vertex. By default the verticx positions coincide in the local object coordinates and the global coordinates. **Other** -- **`Dipole`**`(moment, position, orientation, style)` represents a magnetic dipole moment with moment $(m_x,m_y,m_z)$ given in \[mT mm³]. For homogeneous magnets the relation moment=magnetization$\times$volume holds. Can be used as Magpylib `sources` input. +- **`Dipole`**`(moment, position, orientation)` represents a magnetic dipole moment with moment $(m_x,m_y,m_z)$ given in \[mT mm³]. For homogeneous magnets the relation moment=magnetization$\times$volume holds. Can be used as Magpylib `sources` input. -- **`CustomSource`**`(field_B_lambda, field_H_lambda, position, orientation, style)` can be used to create user defined custom sources. Can be used as Magpylib `sources` input. +- **`CustomSource`**`(field_B_lambda, field_H_lambda, position, orientation)` is used to create user defined custom sources with their own field functions. Can be used as Magpylib `sources` input. -- **`Sensor`**`(position, pixel, orientation)` represents a 3D magnetic field sensor. The field is evaluated at the given pixel positions, by default `pixel=(0,0,0)`. Can be used as Magpylib `observers` input. +- **`Sensor`**`(position, pixel, orientation)` represents a 3D magnetic field sensor. The field is evaluated at the given pixel positions. By default (`pixel=(0,0,0)`) the pixel position coincide in the local object coordinates and the global coordinates. Can be used as Magpylib `observers` input. - **`Collection`**`(*children, position, orientation)` is a group of source and sensor objects (children) that is used for common manipulation. Depending on the children, a collection can be used as Magpylib `sources` and/or `observers` input. @@ -144,7 +143,7 @@ for obj in [magnet1, magnet2, magnet3, magnet4, current1, current2, dipole, cust ## Position and orientation -All Magpylib objects have the `position` and `orientation` attributes that refer to position and orientation in the global Cartesian coordinate system. The `position` attribute is a numpy ndarray, shape (3,) or (m,3) and denotes the coordinates $(x,y,z)$ in units of [mm]. By default every object is created at `position=(0,0,0)`. The `orientation` attribute is a scipy [Rotation object](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html) and denotes the object rotation (relative to its initial state), e.g. in terms of Euler angles $(\phi, \psi, \theta)$. By default the orientation of a Magpylib object is the unit rotation, `orientation=None`. +All Magpylib objects have the `position` and `orientation` attributes that refer to position and orientation in the global Cartesian coordinate system. The `position` attribute is a numpy ndarray, shape (3,) or (m,3) and denotes the coordinates $(x,y,z)$ in units of [mm]. By default every object is created at `position=(0,0,0)`. The `orientation` attribute is a scipy [Rotation object](https://docs.scipy.org/doc/scipy/reference/generated/scipy.spatial.transform.Rotation.html) and denotes the object rotation relative to its initial state. By default the orientation of a Magpylib object is the unit rotation, `orientation=None`. ```python import magpylib as magpy @@ -178,7 +177,7 @@ print(sensor.position) # out: [2. 3. 4.] print(sensor.orientation.as_euler('xyz', degrees=True)) # out: [ 0. 0. 90.] ``` -The attributes `position` and `orientation` can be either of **"scalar"** nature, i.e. a single position or a single rotation like in the examples above, or **"vectors"** when they are arrays of such scalars. The two attributes together define an object **"path"**. Paths should always be used when modelling object motion as the magnetic field is computed on the whole path. +The attributes `position` and `orientation` can be either of **"scalar"** nature, i.e. a single position or a single rotation like in the examples above, or **"vectors"** when they are arrays of such scalars. The two attributes together define an object **"path"**. Paths should always be used when modelling object motion as the magnetic field is computed on the whole path with increased performance. With vector inputs, the `move` and `rotate` methods provide *append* and *merge* functionality. The following example shows how a path `path1` is assigned to a magnet object, how `path2` is appended with `move` and how `path3` is merged on top starting at path index 25. @@ -211,9 +210,9 @@ Notice that when one of the `position` and `orientation` attributes are modified ## Graphic output -When all Magpylib objects and their paths have been created, **`show`** provides a convenient way to graphically display the geometric arrangement using the Matplotlib (default) and Plotly packages. The `backend` keyword argument is used in `show` to select the graphic backend. +Once all Magpylib objects and their paths have been created, **`show`** provides a convenient way to graphically display the geometric arrangement using the Matplotlib (default) and Plotly packages. When `show` is called, it generates a new figure which is then automatically displayed. -When `show` is called, it generates a new figure which is then automatically displayed. To bring the output to a given, user-defined figure, the `canvas` kwarg is used. +The desired graphic backend is selected with the `backend` keyword argument. To bring the output to a given, user-defined figure, the `canvas` kwarg is used. This is demonstrated in {ref}`examples-backends-canvas`. The following example shows the graphical representation of various Magpylib objects and their paths using the default Matplotlib graphic backend. @@ -224,7 +223,7 @@ from magpylib.magnet import Cuboid, Cylinder, CylinderSegment, Sphere from magpylib.current import Loop, Line from magpylib.misc import Dipole -sources = [ +objects = [ Cuboid( magnetization=(0,100,0), dimension=(1,1,1), @@ -259,36 +258,39 @@ sources = [ moment=(0,0,100), position=(5,0,0), ), + magpy.Sensor( + pixel=[(0,0,z) for z in (-.5,0,.5)], + position=(7,0,0), + ), ] -sensor = magpy.Sensor( - pixel=[(0,0,z) for z in (-.5,0,.5)], - position=(7,0,0), -) -sources[5].move(np.linspace((0,0,0), (0,0,7), 20)) -sources[0].rotate_from_angax(np.linspace(0, -90, 20), 'y', anchor=0) +objects[5].move(np.linspace((0,0,0), (0,0,7), 20)) +objects[0].rotate_from_angax(np.linspace(0, -90, 20), 'y', anchor=0) -magpy.show(sources, sensor) +magpy.show(objects) ``` Notice that, objects and their paths are automatically assigned different colors, the magnetization vector, current directions and dipole objects are indicated by arrows and sensors are shown as tri-colored coordinate cross with pixel as markers. -How objects are represented graphically (color, line thickness, ect.) is defined by the **style**. The default style, which can be seen above, is stored in the `magpy.defaults.display.style` object. In addition, each object can have its own style attribute that takes precedence over the default setting. Some practical ways to set styles are shown in the next example: +How objects are represented graphically (color, line thickness, ect.) is defined by the **style**. The default style, which can be seen above, is accessed and manipulated through `magpy.defaults.display.style`. In addition, each object can have an individual style, which takes precedence over the default setting. A local style override is also possible by passing style arguments directly to `show`. + +Some practical ways to set styles are shown in the next example: ```{code-cell} ipython3 import magpylib as magpy from magpylib.magnet import Cuboid +cube1 = Cuboid(magnetization=(0,0,1), dimension=(2,4,4)) +cube2 = cube1.copy(position=(3,0,0)) +cube3 = cube1.copy(position=(6,0,0)) + # change the default magpy.defaults.display.style.base.color = 'crimson' -cube1 = Cuboid(magnetization=(0,0,1), dimension=(2,4,4)) -# set individual style through property -cube2 = cube1.copy(position=(3,0,0)) +# set individual style through properties cube2.style.color = 'orangered' -# set individual style through update with dictionary -cube3 = cube1.copy(position=(6,0,0)) +# set individual style using update with style dictionary cube3.style.update({'color': 'gold'}) # set individual style at initialization with underscore magic @@ -298,7 +300,7 @@ cube4 = cube1.copy(position=(9,0,0), style_color='linen') magpy.show(cube1, cube2, cube3, cube4, style_magnetization_show=False) ``` -A local style override is possible by passing style arguments directly to `show`. The hierarchy that decides about the final graphic object representation, a list of all styles and all other styling options for tuning the `show`-output are described in {ref}`examples-graphic-styles` and {ref}`examples-animation`. +The hierarchy that decides about the final graphic object representation, a list of all style parameters and other options for tuning the `show`-output are described in {ref}`examples-graphic-styles` and {ref}`examples-animation`. (intro-field-computation)= @@ -309,22 +311,20 @@ Magnetic field computation in Magpylib is achieved through: - **`getB`**`(sources, observers)` computes the B-field seen by `observers` generated by `sources` in units of \[mT\] - **`getH`**`(sources, observers)` computes the H-field seen by `observers` generated by `sources` in units of \[kA/m\] -The argument `sources` can be any Magpylib **source object** or a list thereof. The argument `observers` can be an array_like of position vectors with shape $(n_1,n_2,n_3,...,3)$, any Magpylib **observer object** or a list thereof. `getB` and `getH` will compute the field for all input combinations of sources, observers and paths. +The argument `sources` can be any Magpylib **source object** or a flat list thereof. The argument `observers` can be an array_like of position vectors with shape $(n_1,n_2,n_3,...,3)$, any Magpylib **observer object** or a flat list thereof. `getB` and `getH` return the field for all combinations of sources, observers and paths. -```{note} The output of the most general field computation `getB(sources, observers)` is an ndarray of shape `(l, m, k, n1, n2, n3, ..., 3)` where `l` is the number of input sources, `m` the (maximal) object path length, `k` the number of sensors, `n1,n2,n3,...` the sensor pixel shape or the shape of the observer position vector input and `3` the three magnetic field components $(B_x, B_y, B_z)$. -``` **Example 1:** As expressed by the old v2 slogan *"The magnetic field is only three lines of code away"*, this example demonstrates the most fundamental field computation: ```{code-cell} ipython3 import magpylib as magpy -src = magpy.current.Loop(current=1, diameter=2) -B = magpy.getB(src, (1,2,3)) +loop = magpy.current.Loop(current=1, diameter=2) +B = magpy.getB(loop, (1,2,3)) print(B) ``` -**Example 2:** When dealing with multiple observer positions, `getB` and `getH` will return the field in the shape of the observer input. In the following example, B- and H-field of a cuboid magnet are computed on a position grid, and then displayed using Matplotlib: +**Example 2:** When handed with multiple observer positions, `getB` and `getH` will return the field in the shape of the observer input. In the following example, B- and H-field of a cuboid magnet are computed on a position grid, and then displayed using Matplotlib: ```{code-cell} ipython3 import numpy as np @@ -338,9 +338,9 @@ ts = np.linspace(-3, 3, 30) grid = np.array([[(x,0,z) for x in ts] for z in ts]) # compute B- and H-fields of a cuboid magnet on the grid -src = magpy.magnet.Cuboid(magnetization=(500,0,500), dimension=(2,2,2)) -B = src.getB(grid) -H = src.getH(grid) +cube = magpy.magnet.Cuboid(magnetization=(500,0,500), dimension=(2,2,2)) +B = cube.getB(grid) +H = cube.getH(grid) # display field with Pyplot ax1.streamplot(grid[:,:,0], grid[:,:,2], B[:,:,0], B[:,:,2], density=2, @@ -364,16 +364,18 @@ import numpy as np import plotly.graph_objects as go import magpylib as magpy +# reset defaults set in previous example +magpy.defaults.reset() + # setup plotly figure and subplots fig = go.Figure().set_subplots(rows=1, cols=2, specs=[[{"type": "scene"}, {"type": "xy"}]]) # define sensor and source -sensor = magpy.Sensor(pixel=[(0,0,-.1), (0,0,.1)], style_size=1.5) +sensor = magpy.Sensor(pixel=[(0,0,-.2), (0,0,.2)], style_size=1.5) magnet = magpy.magnet.Cylinder(magnetization=(100,0,0), dimension=(1,2)) # define paths sensor.position = np.linspace((0,0,-3), (0,0,3), 40) - magnet.position = (4,0,0) magnet.rotate_from_angax(angle=np.linspace(0, 300, 40)[1:], axis='z', anchor=0) @@ -391,7 +393,7 @@ for i,plab in enumerate(['pixel1', 'pixel2']): fig.show() ``` -**Example 4:** The last example demonstrates the most general form of a `getB` computation with multiple source and sensor inputs. Specifically, 3 sources, one with path length 11, and two sensors, each with pixel shape (4,5). Note that, when input objects have different path lengths, all shorter paths are padded (= objects remain "static") beyond their end. +**Example 4:** The last example demonstrates the most general form of a `getB` computation with multiple source and sensor inputs. Specifically, 3 sources, one with path length 11, and two sensors, each with pixel shape (4,5). Note that, when input objects have different path lengths, objects with shorter paths are treated as static beyond their path end. ```{code-cell} ipython3 import magpylib as magpy @@ -416,15 +418,15 @@ In terms of **performance** it must be noted that Magpylib automatically vectori ## Direct interface and core -The **direct interface** allows users to bypass the object oriented functionality of Magpylib. The magnetic field is computed for a set of arbitrary input instances by providing the top level functions `getB` and `getH` with +The **direct interface** allows users to bypass the object oriented functionality of Magpylib. The magnetic field is computed for a set of $n$ arbitrary input instances by providing the top level functions `getB` and `getH` with -1. a string denoting the source type for the `sources` argument, -2. an array_like of shape (3,) or (n,3) giving the positions for the `observers` argument, -3. a dictionary with array_likes of shape (x,) or (n,x) for all other inputs. +1. `sources`: a string denoting the source type +2. `observers`: array_like of shape (3,) or (n,3) giving the positions +3. `kwargs`: a dictionary with array_likes of shape (x,) or (n,x) for all other inputs -All "scalar" inputs of shape (x,) are automatically tiled up to shape (n,x), and for every of the n given instances the field is computed and returned with shape (n,3). The allowed source types are similar to the Magpylib source class names (see {ref}`intro-magpylib-objects`), and the required dictionary inputs are the respective class inputs. +All "scalar" inputs of shape (x,) are automatically tiled up to shape (n,x), and for every of the $n$ given instances the field is computed and returned with shape (n,3). The allowed source types are similar to the Magpylib source class names (see {ref}`intro-magpylib-objects`), and the required dictionary inputs are the respective class inputs. -In the following example we compute the cuboid field for 5 different position and dimension input instances and "constant" magnetization: +In the following example we compute the cuboid field for 5 input instances, each with different position and orientation and similar magnetization: ```{code-cell} ipython3 import magpylib as magpy @@ -459,65 +461,87 @@ print(B) ## Collections -The top level class `Collection` allows users to group sources by reference for common manipulation. Objects that are part of a collection are called **children** of that collection. All operations acting on a collection are individually applied to its children. +The top level class `Collection` allows users to group objects by reference for common manipulation. Objects that are part of a collection become **children** of that collection, and the collection itself becomes their **parent**. An object can only have a single parent. The child-parent relation is demonstrated with the `describe` method in the following example: -```python +```{code-cell} ipython3 import magpylib as magpy -sphere = magpy.magnet.Sphere(magnetization=(1,2,3), diameter=1, position=(2,0,0)) -loop = magpy.current.Loop(current=1, diameter=1, position=(-2,0,0)) -coll = magpy.Collection(src1, src2) - -print(sphere.position) # out: [ 2. 0. 0.] -print(loop.position) # out: [-2. 0. 0.] -print(coll.position) # out: [ 0. 0. 0.] +sens = magpy.Sensor(style_label='sens') +loop = magpy.current.Loop(style_label='loop') +line = magpy.current.Line(style_label='line') +cube = magpy.magnet.Cuboid(style_label='cube') -coll.move(((0,0,2))) +coll1 = magpy.Collection(sens, loop, line, style_label='coll1') +coll2 = cube + coll1 -print(sphere.position) # out: [ 2. 0. 2.] -print(loop.position) # out: [-2. 0. 2.] -print(coll.position) # out: [ 0. 0. 2.] +coll2.describe(format='type,label') ``` -Collections function primarily like groups. Magpylib objects can be part of multiple collections. After being added to a collection, it is still possible to manipulate the individual objects by reference, and also by collection index: +A detailed review of collection properties and construction is provided in the example gallery {ref}`examples-collections-construction`. It is specifically noteworthy in the above example, that any two Magpylib objects can simply be added up to form a collection. -```python -sphere.move((2,0,0)) -coll[1].move((-2,0,0)) - -print(sphere.position) # out: [ 4. 0. 2.] -print(loop.position) # out: [-4. 0. 2.] -print(coll.position) # out: [ 0. 0. 2.] -``` +A collection object has its own `position` and `orientation` attributes and spans a local reference frame for all its children. An operation applied to a collection moves the frame, and is individually applied to all children such that their relative position in the local reference frame is maintained. This means that the collection functions only as a container for manipulation, but child position and orientation are always updated in the global coordinate system. After being added to a collection, it is still possible to manipulate the individual children, which will also move them to a new relative position in the collection frame. -A detailed review of collection properties and construction is provided in the example gallery {ref}`examples-collections-construction`. - -Magpylib collections follow a *compound object philosophy*, which means that a collection behaves itself like a single object. Notice in the examples above, that `coll` has its own `position` and `orientation` attributes. Geometric operations acting on a collection object are individually applied to all child objects - but in such a way that the geometric compound structure is maintained. For example, applying `rotate` with `anchor=None` rotates all children about `collection.position` instead of the individual child positions. This is demonstrated in the following example: +This enables user-friendly manipulation of groups, sub-groups and individual objects, which is demonstrated in the following example: ```{code-cell} ipython3 import numpy as np import magpylib as magpy +from magpylib.current import Loop -cube1 = magpy.magnet.Cuboid((0,0,1), (1,1,1), (2,0,0)) -cube2 = cube1.copy(position=(-2,0,0)) -sensor = magpy.Sensor() -coll = cube1 + cube2 + sensor +# construct two coil collections from windings +coil1 = magpy.Collection(style_label='coil1') +for z in np.linspace(-.5, .5, 5): + coil1.add(Loop(current=1, diameter=20, position=(0,0,z))) +coil1.position = (0,0,-5) +coil2 = coil1.copy(position=(0,0,5)) + +# helmholtz consists of two coils +helmholtz = coil1 + coil2 + +# move the helmholz +helmholtz.position = np.linspace((0,0,0), (10,0,0), 30) +helmholtz.rotate_from_angax(np.linspace(0,180,30), 'x', start=0) + +# move the coils +coil1.move(np.linspace((0,0,0), ( 5,0,0), 30)) +coil2.move(np.linspace((0,0,0), (-5,0,0), 30)) + +# move the windings +for coil in [coil1, coil2]: + for i,wind in enumerate(coil): + wind.move(np.linspace((0,0,0), (0,0,2-i), 20)) -coll.move(np.linspace((0,0,0), (0,2,0), 30)) -coll.rotate_from_angax(np.linspace(0, 180, 30), 'y') +magpy.show(*helmholtz, backend='plotly', animation=4, style_path_show=False) +``` + +Notice, that collections have their own `style` attributes, their paths are displayed in `show`, and all children are automatically assigned their parent color. + +For magnetic field computation a collection with source children behaves like a single source object, and a collection with sensor children behaves like a flat list of it's sensors when provided as `sources` and `observers` input respectively. This is demonstrated in the following continuation of the previous helmholtz example: -coll.show(animation=True, style_path_show=False, backend='plotly') +```{code-cell} ipython3 +import matplotlib.pyplot as plt + +B = helmholtz.getB((10,0,0)) +plt.plot(B, label=['Bx', 'By', 'Bz']) + +plt.gca().set( + title='B-field [mT] at position (10,0,0)', + xlabel='helmholtz path position index' +) +plt.gca().grid(color='.9') +plt.gca().legend() +plt.show() ``` -It should be noted that collections have their own `style` attributes. Their paths are displayed in `show` and all children are automatically given their parent color. An advanced tutorial how to create dynamic compound objects is given in {ref}`examples-collections-compound`. +One central motivation behind the `Collection` class is enabling users to build **compound objects**, which refer to custom classes that inherit `Collection`. They can represent complex magnet structures like magnetic encoders, motor parts, halbach arrays, and other arrangments, and will naturally integrate into the Magpylib interface. An advanced tutorial how to sub-class `Collection` with dynamic properties and custom 3D models is given in {ref}`examples-compounds`. (intro-customization)= ## Customization -**User-defined 3D models** (traces) that will be displayed by `show`, can be stored in `style.model3d.data`. A trace itself is a dictionary that contains all information necessary for plotting, and can be added with the method `style.model3d.data.add_trace`. In the example gallery {ref}`examples-3d-models` it is explained how to create custom traces with standard plotting backends such as `scatter3d` or `mesh3d` in Plotly, or `plot`, `plot_surface` and `plot_trisurf` in Matplotlib. Some pre-defined models are also provided for easy parts visualization. +**User-defined 3D models** (traces) for any object that will be displayed by `show`, can be stored in `style.model3d.data`. A trace itself is a dictionary that contains all information necessary for plotting, and can be added with the method `style.model3d.data.add_trace`. In the example gallery {ref}`examples-3d-models` it is explained how to create custom traces with standard plotting backends such as `scatter3d` or `mesh3d` in Plotly, or `plot`, `plot_surface` and `plot_trisurf` in Matplotlib. Some pre-defined models are also provided for easy parts visualization. -**User-defined source classes** are easily realized through the `CustomSource` class. Such a custom source object can be provided with user-defined field computation functions, that are stored in the attributes `field_B_lambda` and `field_H_lambda`, and will be used when `getB` and `getH` are called. The provided functions must accept position arrays with shape (n,3), and return the field with a similar shape. Details on working with custom sources are given in {ref}`examples-custom-source-objects`. +**User-defined source objects** are easily realized with the `CustomSource` class. Such a custom source object is provided with user-defined field computation functions, that are stored in the attributes `field_B_lambda` and `field_H_lambda`, and will be used when `getB` and `getH` are called. The provided functions must accept position arrays with shape (n,3), and return the field with a similar shape. Details on working with custom sources are given in {ref}`examples-custom-source-objects`. While each of these features can be used individually, the combination of the two (own source class with own 3D representation) enables a high level of customization in Magpylib. Such user-defined objects will feel like native Magpylib objects and can be used in combination with all other features, which is demonstrated in the following example: @@ -527,14 +551,14 @@ import plotly.graph_objects as go import magpylib as magpy # define B-field function for custom source -def custom_field(pos): - """ easter field""" +def easter_field(pos): + """ points in z-direction and decays with 1/r^3""" dist = np.linalg.norm(pos, axis=1) return np.c_[np.zeros((len(pos),2)), 1/dist**3] # create custom source egg = magpy.misc.CustomSource( - field_B_lambda=custom_field, + field_B_lambda=easter_field, style=dict(color='orange', model3d_showdefault=False, label='The Egg'), ) diff --git a/docs/_pages/page_03_installation.md b/docs/_pages/page_03_installation.md index ad2cd30a3..ca60179bc 100644 --- a/docs/_pages/page_03_installation.md +++ b/docs/_pages/page_03_installation.md @@ -2,65 +2,28 @@ # Installation -## Dependencies - -Magpylib works with Python 3.7 or later ! The following packages will be automatically installed, or updated. See [Git Hub](https://github.com/magpylib/magpylib) for respective versions. Packages will never be downgraded. - -- numpy -- matplotlib -- scipy (.spatial.transform, .special) - -(plotly-installation)= - -## Using a package manager - -Magpylib works with PyPI and conda-forge repositories. - -Install with [pip](https://pypi.org/project/pip/), +For fast installation with a **package manager**, Magpylib is uploaded to the PyPI and conda-forge repositories. Install with [pip](https://pypi.org/project/pip/), ```console pip install magpylib ``` -or with [conda](https://docs.conda.io/en/latest/) +or with [conda](https://docs.conda.io/en/latest/). ```console conda install magpylib ``` -## Using Anaconda - -Or if you have little experience with Python we recommend using [Anaconda](https://www.anaconda.com). - -1. Download & install Anaconda3 - -2. Start Anaconda Navigator - -3. On the interface, go to `Environments` and choose the environment you wish to install magpylib in. For this example, we will use the base environment: - - > ```{image} ../_static/images/install_guide/anaconda0.png - > ``` - -4. Click the arrow, and open the conda terminal - - > ```{image} ../_static/images/install_guide/anaconda1.png - > ``` - -5. Input the following to install from conda-forge: - - > ```console - > conda install -c conda-forge magpylib - > ``` - -6. Don't forget to select the proper environment in your IDE. - - > ```{image} ../_static/images/install_guide/anaconda2.png - > ``` - -## Download Sites - -Currently magpylib is hosted at: +Magpylib is hosted for **download** at the following locations: - [Conda Cloud](https://anaconda.org/conda-forge/magpylib) - [Python Package Index](https://pypi.org/project/magpylib/) - [GitHub repository](https://github.com/magpylib/magpylib) + +The Magpylib **dependencies** are as follows (check on [GitHub](https://github.com/magpylib/magpylib) for the latest version requirements): + +- Python 3.7 or later +- numpy +- scipy (.spatial.transform, .special) +- matplotlib +- plotly diff --git a/docs/_pages/page_07_physics_computation.md b/docs/_pages/page_07_physics_computation.md index 934d4481b..c222741cd 100644 --- a/docs/_pages/page_07_physics_computation.md +++ b/docs/_pages/page_07_physics_computation.md @@ -8,10 +8,10 @@ Magnetic field computations in Magpylib are based on known analytical solutions (formulas) to permanent magnet and current problems. For Magpylib we have used the following references: -- Field of cuboid magnets: \[1999Yang, 2005Engel-Herbert, 2013Camacho\] -- Field of cylindrical magnets: \[1994Furlani, 2009Derby, 2021Slanovc\] -- Field of facet bodies: \[2009Janssen, 2013Rubeck\] -- Field of circular line current: \[1950Smythe, 2001Simpson, 2017Ortner\] +- Field of cuboid magnets: \[Yang1999, Engel-Herbert2005, Camacho2013, Cichon2019\] +- Field of cylindrical magnets: \[Furlani1994, Derby2009, Caciagli2018, Slanovc2021\] +- Field of facet bodies: \[Janssen2009, Rubeck2013\] +- Field of circular line current: \[Smythe1950, Simpson2001, Ortner2017\] - all others derived by hand A short reflection on how these formulas can be achieved: In magnetostatics (no currents) the magnetic field becomes conservative (Maxwell: $\nabla \times {\bf H} = 0$) and can thus be expressed through the magnetic scalar potential $\Phi_m$: @@ -36,7 +36,7 @@ $$ {\bf B}({\bf r}) = \frac{\mu_0}{4\pi}\int_{V'} {\bf J}({\bf r}')\times \frac{{\bf r}-{\bf r}'}{|{\bf r}-{\bf r}'|^3} dV' $$ -In some special cases (simple shapes, homogeneous magnetizations and current distributions) the above integrals can be worked out directly to give analytical formulas (or simple, fast converging series). The derivations can be found in the respective references. A noteworthy comparison between the Coulombian approach and the Amperian current model is given in \[2009Ravaud\]. +In some special cases (simple shapes, homogeneous magnetizations and current distributions) the above integrals can be worked out directly to give analytical formulas (or simple, fast converging series). The derivations can be found in the respective references. A noteworthy comparison between the Coulombian approach and the Amperian current model is given in \[Ravaud2009\]. ## Accuracy of the Solutions and Demagnetization @@ -48,25 +48,15 @@ The magnetic field of a wire carrying a homogeneous current density is similar ( The analytical solutions are exact when bodies have a homogeneous magnetization. However, real materials always have a material response which results in an inhomogeneous magnetization even when the initial magnetization is perfectly homogeneous. There is a lot of literature on such [demagnetization effects](https://en.wikipedia.org/wiki/Demagnetizing_field). -Modern high grade permanent magnets (NdFeB, SmCo, Ferrite) have a very weak material responses (local slope of the magnetization curve, remanent permeability) of the order of $\mu_r \approx 1.05$. In this case the analytical solutions provide an excellent approximation with less than 1% error even at close distance from the magnet surface. A detailed error analysis and discussion is presented in the appendix of \[2020Malago\]. +Modern high grade permanent magnets (NdFeB, SmCo, Ferrite) have a very weak material responses (local slope of the magnetization curve, remanent permeability) of the order of $\mu_r \approx 1.05$. In this case the analytical solutions provide an excellent approximation with less than 1% error even at close distance from the magnet surface. A detailed error analysis and discussion is presented in the appendix of \[Malago2020\]. Error estimation as a result of the material response is evaluated in more detail in the appendix of [Malagò2020](https://www.mdpi.com/1424-8220/20/23/6873). -Note on demagnetization factors !!! +Demagnetization factors can be used to compensate a large part of the demagnetization effect. Analytical expressions for the demagnetization factors of cuboids can be found at [magpar.net](http://www.magpar.net/static/magpar/doc/html/demagcalc.html). **Soft-Magnetic Materials** -Soft-magnetic materials like iron or steel with large permeabilities $\mu_r \sim 1000$ can in principle not be modeled with Magpylib. However, when the body is static, when there is no strong local interaction with an adjacent magnet and when the body is mostly conformal one can approximate the field using the Magpylib solutions and some empirical magnetization that depends on the shape of the body, the material response and the strength of the magnetizing field. - -An example would be the magnetization of a soft-magnetic metal piece in the earth magnetic field. However, even in such a case it is probably more efficient to use a simple dipole approximation. - -**Convergence of the diametral Cylinder solution** - -The diametral Cylinder solution is based on a converging series and is set to 50 iterations by default, which should be sufficient in most cases. If you want to be more precise, you can change the convergence behavior by setting a new default value `x` with - -```python -magpylib.defaults.itercylinder = x -``` +Soft-magnetic materials like iron or steel with large permeabilities $\mu_r \sim 1000$ and low remanence fields are dominated by the material response. There are no useful analytical solutions. However, recent developments showed that the Magnetostatic Method of Moments can be a powerful tool in combination with Magpylib to compute such a material response. An integration into Magpylib is planned for the future. (docu-units-scaling)= @@ -80,10 +70,13 @@ Magpylib uses the following physical units: - \[deg\]: for angle inputs by default. - \[A\]: for current inputs. -However, the analytical solutions scale in such a way that the magnetic field is the same when the system scales in size. This means that a 1-meter sized magnet in a distance of 1-meter produces the same field as a 1-millimeter sized magnet in a distance of 1-millimeter. The choice of position/length input dimension is therefore not relevant - the Magpylib choice of \[mm\] is a result of history and practical considerations when working with position and orientation systems). +However, the analytical solutions scale in such a way that the magnetic field is the same when the system scales in size. This means that a 1-meter sized magnet in a distance of 1-meter produces the same field as a 1-millimeter sized magnet in a distance of 1-millimeter. The choice of position/length input dimension is therefore not relevant - the Magpylib choice of \[mm\] is a result of history and practical considerations when working with position and orientation systems. In addition, `getB` returns the unit of the input magnetization. The Magpylib choice of \[mT\] (theoretical physicists will point out that it is µ0\*M) is historical and convenient. When the magnetization is given in \[mT\], then `getH` returns \[kA/m\] which is simply related by factor of $\frac{10}{4\pi}$. Of course, `getB` also adds the magnet magnetization when computing the field inside the magnet, while `getH` does not. -## Computation + + +(docu-performance)= +## Computation and Performance Magpylib code is fully [vectorized](https://en.wikipedia.org/wiki/Array_programming), written almost completely in numpy native. Magpylib automatically vectorizes the computation with complex inputs (many sources, many observers, paths) and never falls back on using loops. @@ -91,38 +84,29 @@ Magpylib code is fully [vectorized](https://en.wikipedia.org/wiki/Array_programm Maximal performance is achieved when `.getB(sources, observers)` is called only a single time in your program. Try not to use loops. ``` -Of course the objective oriented interface (sensors and sources) comes with an overhead. If you want to achieve maximal performance this overhead can be avoided through direct access to the vectorized field functions with the top level function `magpylib.getB_dict` **FIX THIS** - - - -(docu-performance)= - -## Performance - -The analytical solutions provide extreme performance. Single field evaluations take of the order of `100 µs`. For large input arrays (e.g. many observer positions or many similar magnets) the computation time drops below `1 µs` on single state-of-the-art x86 mobile cores (tested on `Intel Core i5-8365U @ 1.60GHz`), depending on the source type. - - -(docu-close-to-surface)= +The objective oriented interface comes with an overhead. If you want to achieve maximal performance this overhead can be avoided with {ref}`intro-direct-interface`. -## Close to surfaces, edges and corners +The analytical solutions provide extreme performance. Single field evaluations take of the order of `100 µs`. For large input arrays (e.g. many observer positions or many similar magnets) the computation time drops below `1 µs` per evaluation point on single state-of-the-art x86 mobile cores (tested on `Intel Core i5-8365U @ 1.60GHz`), depending on the source type. -Evaluation of analytical solutions are often limited by numerical precision when approaching singularities or indeterminate forms on magnet surfaces, edges or corners. 64-bit precision limits evaluation to 16 significant digits, but unfortunately many solutions include higher powers of the distances so that the precision limit is quickly approached. +## Numerical stability -As a result, Magpylib automatically sets solution that lie closer than **1e-8** to problematic surfaces, edges or corners to **0**. The user can adjust this default value simply with the command `magpylib.defaults.edgesize=x`. +Many expressions provided in the literature have very questionable numerical stability. Most of these issues are fixed in Magpylib, but one should be aware that the results might not have more than 8-10 correct significant figures. A detailed treatsie on this topic is work in progress. **References** -- \[1999Yang\] Z. J. Yang et al., "Potential and force between a magnet and a bulk Y1Ba2Cu3O7-d superconductor studied by a mechanical pendulum", Superconductor Science and Technology 3(12):591, 1999 -- \[2005 Engel-Herbert\] R. Engel-Herbert et al., Journal of Applied Physics 97(7):074504 - 074504-4 (2005) -- \[2013 Camacho\] J.M. Camacho and V. Sosa, "Alternative method to calculate the magnetic field of permanent magnets with azimuthal symmetry", Revista Mexicana de Fisica E 59 8–17, 2013 -- \[1994Furlani\] E. P. Furlani, S. Reanik and W. Janson, "A Three-Dimensional Field Solution for Bipolar Cylinders", IEEE Transaction on Magnetics, VOL. 30, NO. 5, 1994 -- \[2009Derby\] N. Derby, "Cylindrical Magnets and Ideal Solenoids", arXiv:0909.3880v1, 2009 -- \[1950Smythe\] W.B. Smythe, "Static and dynamic electricity" McGraw-Hill New York, 1950, vol. 3. -- \[2001Simpson\] J. Simplson et al., "Simple analytic expressions for the magnetic field of a circular current loop," 2001. -- \[2017Ortner\] M. Ortner et al., "Feedback of Eddy Currents in Layered Materials for Magnetic Speed Sensing", IEEE Transactions on Magnetics ( Volume: 53, Issue: 8, Aug. 2017) -- \[2009Janssen\] J.L.G. Janssen, J.J.H. Paulides and E.A. Lomonova, "3D ANALYTICAL FIELD CALCULATION USING TRIANGULAR MAGNET SEGMENTS APPLIED TO A SKEWED LINEAR PERMANENT MAGNET ACTUATOR", ISEF 2009 - XIV International Symposium on Electromagnetic Fields in Mechatronics, Electrical and Electronic Engineering Arras, France, September 10-12, 2009 -- \[2013Rubeck\] C. Rubeck et al., "Analytical Calculation of Magnet Systems: Magnetic Field Created by Charged Triangles and Polyhedra", IEEE Transactions on Magnetics, VOL. 49, NO. 1, 2013 -- \[1999Jackson\] J. D. Jackson, "Classical Electrodynamics", 1999 Wiley, New York -- \[2009Ravaud\] R. Ravaud and G. Lamarquand, "Comparison of the coulombian and amperian current models for calculating the magnetic field produced by radially magnetized arc-shaped permanent magnets", HAL Id: hal-00412346 -- \[2020Malago\] P. Malagò et al., Magnetic Position System Design Method Applied to Three-Axis Joystick Motion Tracking. Sensors, 2020, 20. Jg., Nr. 23, S. 6873. -- \[2021Slanovc\] F. Slanovc et al., "Full Analytical Solution for the Magnetic field of Uniformly Magnetized Cylinder Tiles", in preparation +- \[Yang1999\] Z. J. Yang et al., "Potential and force between a magnet and a bulk Y1Ba2Cu3O7-d superconductor studied by a mechanical pendulum", Superconductor Science and Technology 3(12):591, 1999 +- \[Engel-Herbert2005\] R. Engel-Herbert et al., Journal of Applied Physics 97(7):074504 - 074504-4 (2005) +- \[Camacho2013\] J.M. Camacho and V. Sosa, "Alternative method to calculate the magnetic field of permanent magnets with azimuthal symmetry", Revista Mexicana de Fisica E 59 8–17, 2013 +- \[Cichon2019\] D. Cichon, R. Psiuk and H. Brauer, "A Hall-Sensor-Based Localization Method With Six Degrees of Freedom Using Unscented Kalman Filter", IEEE Sensors Journal, Vol. 19, No. 7, April 1, 2019. +- \[Furlani1994\] E. P. Furlani, S. Reanik and W. Janson, "A Three-Dimensional Field Solution for Bipolar Cylinders", IEEE Transaction on Magnetics, VOL. 30, NO. 5, 1994 +- \[Derby2009\] N. Derby, "Cylindrical Magnets and Ideal Solenoids", arXiv:0909.3880v1, 2009 +- \[Caciagli2018\] A. Caciagli, R. J. Baars, A. P. Philipse and B. W. M. Kuipers, "Exact expression for the magnetic field of a finite cylinder with arbitrary uniform magnetization", Journal of Magnetism and Magnetic Materials 456 (2018) 423–432. +- \[Slanovc2022\] F. Slanovc, M. Ortner, M. Moridi, C. Abert and D. Suess, "Full analytical solution for the magnetic field of uniformly magnetized cylinder tiles", submitted to Journal of Magnetism and Magnetic Materials. +- \[Smythe1950\] W.B. Smythe, "Static and dynamic electricity" McGraw-Hill New York, 1950, vol. 3. +- \[Simpson2001\] J. Simplson et al., "Simple analytic expressions for the magnetic field of a circular current loop," 2001. +- \[Ortner2017\] M. Ortner et al., "Feedback of Eddy Currents in Layered Materials for Magnetic Speed Sensing", IEEE Transactions on Magnetics ( Volume: 53, Issue: 8, Aug. 2017) +- \[Janssen2009\] J.L.G. Janssen, J.J.H. Paulides and E.A. Lomonova, "3D ANALYTICAL FIELD CALCULATION USING TRIANGULAR MAGNET SEGMENTS APPLIED TO A SKEWED LINEAR PERMANENT MAGNET ACTUATOR", ISEF 2009 - XIV International Symposium on Electromagnetic Fields in Mechatronics, Electrical and Electronic Engineering Arras, France, September 10-12, 2009 +- \[Rubeck2013\] C. Rubeck et al., "Analytical Calculation of Magnet Systems: Magnetic Field Created by Charged Triangles and Polyhedra", IEEE Transactions on Magnetics, VOL. 49, NO. 1, 2013 +- \[Jackson1999\] J. D. Jackson, "Classical Electrodynamics", 1999 Wiley, New York +- \[Ravaud2009\] R. Ravaud and G. Lamarquand, "Comparison of the coulombian and amperian current models for calculating the magnetic field produced by radially magnetized arc-shaped permanent magnets", HAL Id: hal-00412346 +- \[Malago2020\] P. Malagò et al., Magnetic Position System Design Method Applied to Three-Axis Joystick Motion Tracking. Sensors, 2020, 20. Jg., Nr. 23, S. 6873. diff --git a/docs/examples/01_fundamentals.md b/docs/examples/01_fundamentals.md index 90e9dd194..be376f176 100644 --- a/docs/examples/01_fundamentals.md +++ b/docs/examples/01_fundamentals.md @@ -21,6 +21,7 @@ Fundamentals :maxdepth: 2 examples_02_paths.md +examples_05_backend_canvas.md examples_03_collections.md examples_01_complex_forms.md examples_04_custom_source.md diff --git a/docs/examples/02_graphic_output.md b/docs/examples/02_graphic_output.md index e0f074c3b..949b6f185 100644 --- a/docs/examples/02_graphic_output.md +++ b/docs/examples/02_graphic_output.md @@ -25,6 +25,5 @@ Graphic Output examples_12_styles.md examples_13_3d_models.md -examples_14_adding_cad_model.md examples_15_animation.md ``` \ No newline at end of file diff --git a/docs/examples/03_advanced.md b/docs/examples/03_advanced.md index 0a0b642c7..41f1ba1f1 100644 --- a/docs/examples/03_advanced.md +++ b/docs/examples/03_advanced.md @@ -20,6 +20,6 @@ Advanced :caption: Advanced Features :maxdepth: 2 -examples_21_compound_class.md -examples_22_fem_interpolation.md +examples_21_compound.md +examples_22_field_interpolation.md ``` \ No newline at end of file diff --git a/docs/examples/examples_01_complex_forms.md b/docs/examples/examples_01_complex_forms.md index 2692ced12..304696e0e 100644 --- a/docs/examples/examples_01_complex_forms.md +++ b/docs/examples/examples_01_complex_forms.md @@ -15,7 +15,7 @@ kernelspec: # Complex forms -The [**superposition principle**](https://en.wikipedia.org/wiki/Superposition_principle) states that the net response caused by two or more stimuli is the sum of the responses that would have been caused by each stimulus individually. This principle holds in Magnetostatics when there is no material response, and simply means that the total field created by multiple magnets is the sum of the individual fields. +The [**superposition principle**](https://en.wikipedia.org/wiki/Superposition_principle) states that the net response caused by two or more stimuli is the sum of the responses caused by each stimulus individually. This principle holds in Magnetostatics when there is no material response, and simply means that the total field created by multiple magnets is the sum of the individual fields. It is critical to understand that the superposition principle holds for the magnetization itself. When two magnets overlap geometrically, the magnetization in the overlap region is given by the vector sum of the two individual magnetizations. @@ -54,7 +54,7 @@ ring = CylinderSegment(magnetization=(0,0,100), dimension=(2,3,1,0,360)) magpy.show(ring, sensor, canvas=ax2, style_magnetization_show=False) # compare field at sensor -ax3.plot(sensor.getB(coll).T[2], label='Bz from Cuboid') +ax3.plot(sensor.getB(coll).T[2], label='Bz from Cuboids') ax3.plot(sensor.getB(ring).T[2], ls='--', label='Bz from CylinderSegment') ax3.grid(color='.9') ax3.legend() @@ -84,10 +84,4 @@ ring1 = inner + outer %time print('getB from Cylinder cut-out', ring1.getB((1,2,3))) ``` -Note that, it is faster to compute the `Cylinder` field two times than computing the complex `CylinderSegment` field one time. - -+++ - -```note -Cut-out operations cannot be displayed at the moment -``` +Note that, it is faster to compute the `Cylinder` field two times than computing the complex `CylinderSegment` field one time. Unfortunately, cut-out operations cannot be displayed graphically at the moment, but {ref}`examples-own-3d-models` offer a solution here. diff --git a/docs/examples/examples_02_paths.md b/docs/examples/examples_02_paths.md index 5cec2d157..b448ea7bd 100644 --- a/docs/examples/examples_02_paths.md +++ b/docs/examples/examples_02_paths.md @@ -69,7 +69,7 @@ print(sensor.position) # to whole path starti sensor.move([(0,0,10), (0,0,20)], start=1) # vector input and start=1 merges print(sensor.position) # the input with the existing path -# out: [[ 1. 1. 1.] [ 3. 3. 13.] [ 4. 4. 24.]] # starting at index 1. +# out: [[ 1. 1. 1.] [ 3. 3. 13.] [ 4. 4. 24.]] # starting at index 1. ``` (examples-relative-paths)= @@ -82,13 +82,13 @@ print(sensor.position) # the input with the e import numpy as np from magpylib.magnet import Sphere -path1 = np.linspace((0,0,0), (10,0,0), 10)[1:] -path2 = np.linspace((0,0,0), (0,0,10), 10)[1:] +x_path = np.linspace((0,0,0), (10,0,0), 10)[1:] +z_path = np.linspace((0,0,0), (0,0,10), 10)[1:] sphere = Sphere(magnetization=(0,0,1), diameter=3) for _ in range(3): - sphere.move(path1).move(path2) + sphere.move(x_path).move(z_path) sphere.show() ``` @@ -133,7 +133,7 @@ print(sensor.orientation.as_quat()) ## Edge-padding and end-slicing -Magpylib will always make sure that object paths are in the right format, i.e. `position` and `orientation` attributes are of the same length. In addition, when objects with different path lengths are combined, e.g. when computing the field, the shorter paths are adjusted in length to make the computation sensible. Internally, Magpylib follows a philosophy of edge-padding and end-slicing when adjusting paths. +Magpylib will always make sure that object paths are in the right format, i.e. `position` and `orientation` attributes are of the same length. In addition, when objects with different path lengths are combined, e.g. when computing the field, the shorter paths are treated as static beyond their end to make the computation sensible. Internally, Magpylib follows a philosophy of edge-padding and end-slicing when adjusting paths. The idea behind **edge-padding** is, that whenever path entries beyond the existing path length are needed the edge-entries of the existing path are returned. This means that the object is considered to be "static" beyond its existing path. diff --git a/docs/examples/examples_03_collections.md b/docs/examples/examples_03_collections.md index 8baf35fd5..b5618e248 100644 --- a/docs/examples/examples_03_collections.md +++ b/docs/examples/examples_03_collections.md @@ -11,120 +11,173 @@ kernelspec: name: python3 --- -# Collections - (examples-collections-construction)= -## Collection construction +# Collections -The `Collection` class is a versatile way of grouping and manipulating Magpylib objects. When objects are added to a Collection they are added by reference (**not copied**) to the attributes `children` (list of all objects), `sources` (list of the sources) and `sensors` (list of the sensors). These attributes are ordered lists. New additions are always added at the end. +## Constructing collections + +The `Collection` class is a versatile way of grouping and manipulating Magpylib objects. When objects are added to a Collection they are added by reference (not copied) to the **attributes** `children` (list of all objects), `sources` (list of the sources), `sensors` (list of the sensors) and `collections`. ```{code-cell} ipython3 import magpylib as magpy -loop = magpy.current.Loop() -sensor = magpy.Sensor() +x1 = magpy.Sensor(style_label='x1') +s1 = magpy.magnet.Cuboid(style_label='s1') +c1 = magpy.Collection(style_label='c1') -coll = magpy.Collection(loop, sensor) +coll = magpy.Collection(x1, s1, c1, style_label='coll') -print(f"children: {coll.children}") -print(f"sources: {coll.sources}") -print(f"sensors: {coll.sensors}") +print(f"children: {coll.children}") +print(f"sources: {coll.sources}") +print(f"sensors: {coll.sensors}") +print(f"collections: {coll.collections}") ``` -To manipulate existing collections, one can use the `add` and `remove` methods: +These attributes are ordered lists. New additions are always added at the end. Add objects to an existing collection using these parameters, or the **`add`** method. ```{code-cell} ipython3 -coll.add(magpy.magnet.Cuboid()) -coll.remove(sensor) -print(f"children: {coll.children}") -print(f"sources: {coll.sources}") -print(f"sensors: {coll.sensors}") +# automatically adjusts object label +x2 = x1.copy() +s2 = s1.copy() +c2 = c1.copy() + +# add objects with add method +coll.add(x2, s2) + +# add objects with parameters +coll.collections += [c2] + +print(f"children: {coll.children}") +print(f"sources: {coll.sources}") +print(f"sensors: {coll.sensors}") +print(f"collections: {coll.collections}") ``` -The operators `+` and `-` provide a similar functionality, +The **`describe`** method is a very convenient way to view a Collection structure, especially when the collection is nested, i.e. when containing other collections: ```{code-cell} ipython3 -coll = coll - loop -coll = coll + magpy.magnet.Cylinder() +# add more objects +c1.add(x2.copy()) +c2.add(s2.copy()) -print(f"children: {coll.children}") -print(f"sources: {coll.sources}") -print(f"sensors: {coll.sensors}") +coll.describe(format='label') ``` -However, it must be noted that `+` and `-` result in copies of the collection, while `add` and `remove` do not. - -The `+` operator is defined for all Magpylib objects. Adding objects returns a collection. +For convenience, any two Magpylib object can be added up with `+` to form a collection: ```{code-cell} ipython3 -what_is_it = loop + sensor -print(what_is_it) +import magpylib as magpy + +x1 = magpy.Sensor(style_label='x1') +s1 = magpy.magnet.Cuboid(style_label='s1') + +coll = x1 + s1 + +coll.describe(format='label') ``` -Collections have `__getitem__` through the attribute `children` defined. This allows using collections as iterators, +## Child-parent relations + +Objects that are part of a collection become children of that collection, and the collection itself becomes their parent. Every Magpylib object has the `parent` attribute, which is `None` by default. ```{code-cell} ipython3 -for child in coll: - print(child) +import magpylib as magpy + +x1 = magpy.Sensor() +c1 = magpy.Collection(x1) + +print(f"x1.parent: {x1.parent}") +print(f"c1.parent: {c1.parent}") +print(f"c1.children: {c1.children}") ``` -and makes it possible to directly reference to a child object: +Rather than adding objects to a collection, as described above, one can also set the `parent` parameter. A Magpylib object can only have a single parent, i.e. it can only be part of a single collection. As a result, changing the parent will automatically remove the object from it's previous collection. ```{code-cell} ipython3 -print(coll[0]) -``` +import magpylib as magpy -Finally, it is worth mentioning that collections do not allow duplicate sources. They will automatically be removed. However, sources can be part of multiple collections. +x1 = magpy.Sensor(style_label='x1') +c1 = magpy.Collection(style_label='c1') +c2 = magpy.Collection(c1, style_label='c2') -(examples-collections-compound)= +print("Two empty, nested collections") +c2.describe(format='label') -## Compounds +print("\nSet x1 parent to c1") +x1.parent = c1 +c2.describe(format='label') + +print("\nChange x1 parent to c2") +x1.parent = c2 +c2.describe(format='label') +``` -Collections follow the *compound philosophy*. The idea is that a compound object, made up of multiple individual sources and sensors can be treated like single object itself. -For this purpose, the collection has itself `position` and `orientation` attributes that span a local coordinate reference frame. Whenever a child is added to the collection it has a `position` and `orientation` in the local frame. All operations acting on the collection will then only move the local frame around, and not change child positions within it. Operations acting directly on the child itself will move the child also in the local frame. +## Working with collections + +Collections have `__getitem__` through the attribute `children` defined which enables using collections directly as iterators, ```{code-cell} ipython3 -import numpy as np import magpylib as magpy -# construct two coil compounds -coil1 = magpy.Collection() -for z in np.linspace(-.5, .5, 5): - winding = magpy.current.Loop(current=1, diameter=20, position=(0,0,z)) - coil1.add(winding) -coil1.position = (0,0,-5) -coil2 = coil1.copy(position = (0,0,5)) - -# construct a helmholz compound -helmholtz = coil1 + coil2 +x1 = magpy.Sensor() +x2 = magpy.Sensor() -# move the helmholz compound -helmholtz.position = np.linspace((0,0,0), (10,0,0), 30) -helmholtz.rotate_from_angax(np.linspace(0,180,30), 'x', start=0) +coll = x1 + x2 -# move the winding objects -for coil in [coil1, coil2]: - for i,w in enumerate(coil): - w.move(np.linspace((0,0,0), (0,0,2-i), 20)) +for child in coll: + print(child) +``` -# move the modified coil compounds -coil1.move(np.linspace((0,0,0), ( 5,0,0), 30)) -coil2.move(np.linspace((0,0,0), (-5,0,0), 30)) +and makes it possible to directly reference to a child object by index: -helmholtz.show(backend='plotly', animation=4, style_path_show=False) +```{code-cell} ipython3 +print(coll[0]) ``` -The compound philosophy is also followed when computing the magnetic field. Collections behave like single source inputs in the functions `getB` and `getH`. The field that is returned is simply the the sum of the fields of all child sources. In the following example the field generated by the helmholtz compound above, and by the individual coils is computed at position $(0,0,0)$: +How to work with collections in a practical way is demonstrated in the introduction section {ref}`intro-collections`. + +How to make complex compound objects is documented in {ref}`examples-compounds`. + +(examples-collections-efficient)= + +## Efficient 3D models + +The Matplotlib and Plotly libraries were not designed for complex 3D graphic outputs. As a result, it becomes often inconvenient and slow when attempting to display many 3D objects. One solution to this problem when dealing with large collections, is to represent the latter by a single encompassing body, and to deactivate the individual 3D models of all children. This is demonstrated in the following example. ```{code-cell} ipython3 -helmholtz.reset_path() +import magpylib as magpy -print(magpy.getB(helmholtz, (0,0,0))) -print(magpy.getB(coil1, (0,0,0))) -print(magpy.getB(coil2, (0,0,0))) +# create collection +coll = magpy.Collection() +for index in range(10): + cuboid = magpy.magnet.Cuboid( + magnetization=(0, 0, 1000 * (index%2-.5)), + dimension=(10,10,10), + position=(index*10,0,0), + ) + coll.add(cuboid) + +# add 3D-trace +plotly_trace = magpy.graphics.model3d.make_Cuboid( + backend='matplotlib', + dimension=(104, 12, 12), + position=(45, 0, 0), + alpha=0.5, +) +coll.style.model3d.add_trace(plotly_trace) + +coll.style.label='Collection with visible children' +coll.show() + +# hide the children default 3D representation +coll.set_children_styles(model3d_showdefault=False) +coll.style.label = 'Collection with hidden children' +coll.show() ``` -Notice that, `helmholtz.reset_path()` sets the `helmholtz` object to position $(0,0,0)$, however, the final asymmetric structure (coils shifted left and right) remains, so that there is a finite x-component. +```{note} +The `Collection` position is set to (0,0,0) at creation time. Any added extra 3D-model will be bound to the local coordinate system of to the `Collection` and `rotated`/`moved` together with its parent object. +``` diff --git a/docs/examples/examples_04_custom_source.md b/docs/examples/examples_04_custom_source.md index b24033297..dd6c9c5a2 100644 --- a/docs/examples/examples_04_custom_source.md +++ b/docs/examples/examples_04_custom_source.md @@ -14,9 +14,9 @@ kernelspec: (examples-custom-source-objects)= # Custom source objects -The custom source class `CustomSource` allows users to integrate their own custom-objects into the Magpylib interface. The `field_B_lambda` and `field_H_lambda` arguments can be provided with function that are called with `getB` and `getH`. +The class `CustomSource` allows users to integrate their own custom-objects into the Magpylib interface. The `field_B_lambda` and `field_H_lambda` arguments can be provided with function that are called with `getB` and `getH`. -These custom field functions are treated like core functions. they must accept position inputs (array_like, shape (n,3)) and return the respective field with a similar shape. A fundamental example how to create a custom source object is: +These custom field functions are treated like core functions. They must accept position inputs (array_like, shape (n,3)) and return the respective field with a similar shape. A fundamental example how to create a custom source object is: ```{code-cell} ipython3 import numpy as np @@ -26,7 +26,7 @@ import magpylib as magpy def custom_field(position): """ user defined custom field position input: array_like, shape (n,3) - returns: ndarray, shape (n,3) + returns: B-field, ndarray, shape (n,3) """ return np.array(position)*2 diff --git a/docs/examples/examples_05_backend_canvas.md b/docs/examples/examples_05_backend_canvas.md new file mode 100644 index 000000000..5b9a805d9 --- /dev/null +++ b/docs/examples/examples_05_backend_canvas.md @@ -0,0 +1,115 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.13.6 +kernelspec: + display_name: Python 3 + language: python + name: python3 +--- + +(examples-backends-canvas)= + +# Backend and canvas + +## Graphic backend + +Magpylib supports Matplotlib and Plotly as possible graphic backends. +If a backend is not specified, the library default stored in `magpy.defaults.display.backend` will be used. +The value can bei either `'matplotlib'` or `'plotly'`. + +To select a graphic backend one can +1. Change the library default with `magpy.defaults.display.backend = 'plotly'`. +2. Set the `backend` kwarg in the `show` function, `show(..., backend='matplotlib')`. + +```{note} +There is a high level of **feature parity** between the two backends but there are also some key differences, e.g. when displaying magnetization of an object. In addition, some common Matplotlib syntax (e.g. color `'r'`, linestyle `':'`) is automatically translated to Plotly and vice versa. +``` + +The following example shows first Matplotlib and then Plotly output: + +```{code-cell} ipython3 +import numpy as np +import magpylib as magpy + +# define sources and paths +loop = magpy.current.Loop(current=1, diameter=1) +loop.position = np.linspace((0,0,-3), (0,0,3), 40) + +cylinder = magpy.magnet.Cylinder(magnetization=(0,-100,0), dimension=(1,2), position=(0,-3,0)) +cylinder.rotate_from_angax(np.linspace(0, 300, 40)[1:], 'z', anchor=0) + +# display the system with both backends +magpy.show(loop, cylinder) +magpy.show(loop, cylinder, backend='plotly') +``` + +## Output in custom figure + +When calling `show`, a Matplotlib or Plotly figure is automatically generated and displayed. It is also possible to display the `show` output on a given user-defined canvas (Plotly `Figure` object or Matplotlib `Axis3d` object) with the `canvas` kwarg. + +In the following example we show how to combine a 2D field plot with the 3D `show` output in **Matplotlib**: + +```{code-cell} ipython3 +import numpy as np +import matplotlib.pyplot as plt +import magpylib as magpy + +# setup matplotlib figure and subplots +fig = plt.figure(figsize=(10, 4)) +ax1 = fig.add_subplot(121,) # 2D-axis +ax2 = fig.add_subplot(122, projection="3d") # 3D-axis + +# define sources and paths +loop = magpy.current.Loop(current=1, diameter=1) +loop.position = np.linspace((0,0,-3), (0,0,3), 40) + +cylinder = magpy.magnet.Cylinder(magnetization=(0,-100,0), dimension=(1,2), position=(0,-3,0)) +cylinder.rotate_from_angax(np.linspace(0, 300, 40)[1:], 'z', anchor=0) + +# compute field and plot in 2D-axis +B = magpy.getB([loop, cylinder], (0,0,0), sumup=True) +ax1.plot(B) + +# display show() output in 3D-axis +magpy.show(loop, cylinder, canvas=ax2) + +# generate figure +plt.tight_layout() +plt.show() +``` + +A similar example with **Plotly**: + +```{code-cell} ipython3 +import numpy as np +import plotly.graph_objects as go +import magpylib as magpy + +# setup plotly figure and subplots +fig = go.Figure().set_subplots(rows=1, cols=2, specs=[[{"type": "xy"}, {"type": "scene"}]]) + +# define sources and paths +loop = magpy.current.Loop(current=1, diameter=1) +loop.position = np.linspace((0,0,-3), (0,0,3), 40) + +cylinder = magpy.magnet.Cylinder(magnetization=(0,-100,0), dimension=(1,2), position=(0,-3,0)) +cylinder.rotate_from_angax(np.linspace(0, 300, 40)[1:], 'z', anchor=0) + +# compute field and plot in 2D-axis +B = magpy.getB([loop, cylinder], (0,0,0), sumup=True) +for i,lab in enumerate(['Bx', 'By', 'Bz']): + fig.add_trace(go.Scatter(x=np.linspace(0,1,40), y=B[:,i], name=lab)) + +# display show() output in 3D-axis +temp_fig = go.Figure() +magpy.show(loop, cylinder, canvas=temp_fig, backend='plotly') +fig.add_traces(temp_fig.data, rows=1, cols=2) +fig.layout.scene.update(temp_fig.layout.scene) + +# generate figure +fig.show() +``` diff --git a/docs/examples/examples_12_styles.md b/docs/examples/examples_12_styles.md index cd95d77c3..9fb8fa407 100644 --- a/docs/examples/examples_12_styles.md +++ b/docs/examples/examples_12_styles.md @@ -19,8 +19,8 @@ The graphic styles define how Magpylib objects are displayed visually when calli There are multiple hierarchy levels that decide about the final graphical representation of the objects: 1. When no input is given, the **default style** will be applied. -2. Object **individual styles** will take precedence over the default values. -3. Collections will override the color property of all children. +2. Collections will override the color property of all children with their own color. +3. Object **individual styles** will take precedence over these values. 4. Setting a **local style** in `show()` will take precedence over all other settings. ## Setting the default style @@ -113,11 +113,11 @@ magpy.defaults.display.style.magnet.update( Any Magpylib object can have its own individual style that will take precedence over the default values when `show` is called. When setting individual styles, the object family specifier such as `magnet` or `current` which is required for the defaults settings, but is implicitly defined by the object type, can be omitted. ```{warning} -Users should be aware that specifying style attributes massively increases object initializing time (from <50 to 100-500 $\mu$s). +Users should be aware that specifying individual style attributes massively increases object initializing time (from <50 to 100-500 $\mu$s). While this may not be noticeable for a small number of objects, it is best to avoid setting styles until it is plotting time. ``` -In the following example the individual style of `cube` is set at initialization, the style of `cylinder` is the default one, and the individual style of `sphere` is set using the style properties. +In the following example the individual style of `cube` is set at initialization, the style of `cylinder` is the default one, and the individual style of `sphere` is set using the object style properties. ```{code-cell} ipython3 import magpylib as magpy @@ -147,7 +147,7 @@ magpy.show(cube, cylinder, sphere, backend="plotly") ## Setting style via collections -When displaying collections, the collection object `color` property will be automatically assigned to all its children and override default and individual styles. An example that demonstrates this is {ref}`examples-union-operation`. In addition, it is possible to modify the style of all children with the `set_children_styles` method. Non-matching properties are simply ignored. +When displaying collections, the collection object `color` property will be automatically assigned to all its children and override the default style. An example that demonstrates this is {ref}`examples-union-operation`. In addition, it is possible to modify the individual style properties of all children with the `set_children_styles` method. Non-matching properties are simply ignored. In the following example we show how the french magnetization style is applied to all children in a collection, diff --git a/docs/examples/examples_13_3d_models.md b/docs/examples/examples_13_3d_models.md index da3309389..f111317eb 100644 --- a/docs/examples/examples_13_3d_models.md +++ b/docs/examples/examples_13_3d_models.md @@ -13,12 +13,12 @@ kernelspec: (examples-3d-models)= -# 3D models +# 3D models and CAD (examples-own-3d-models)= ## Custom 3D models -Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace(trace)` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. +Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. The input `trace` is a dictionary which includes all necessary information for plotting or a `magpylib.graphics.Trace3d` object. A `trace` dictionary has the following keys: @@ -27,7 +27,7 @@ The input `trace` is a dictionary which includes all necessary information for p 3. `'args'`: default `None`, positional arguments handed to constructor 4. `'kwargs'`: default `None`, keyword arguments handed to constructor 5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the Plotly backend and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend. -6. `'show'`: default `True`: choose if this trace should be displayed or not +6. `'show'`: default `True`, toggle if this trace should be displayed 7. `'scale'`: default 1, object geometric scaling factor 8. `'updatefunc'`: default `None`, updates the trace parameters when `show` is called. Used to generate dynamic traces. @@ -234,6 +234,98 @@ obj5.style.model3d.add_trace(trace_arrow) magpy.show(obj0, obj1, obj2, obj3, obj4, obj5, backend='plotly') ``` -## CAD models +(examples-adding-CAD-model)= + +## Adding a CAD model + +As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. + +```{note} +The code below requires installation of the `numpy-stl` package. +``` + +```{code-cell} ipython3 +import os +import tempfile +import requests +import numpy as np +from stl import mesh # requires installation of numpy-stl +import magpylib as magpy + + +def get_stl_color(x): + """ transform stl_mesh attr to plotly color""" + sb = f"{x:015b}"[::-1] + r = int(255 / 31 * int(sb[:5], base=2)) + g = int(255 / 31 * int(sb[5:10], base=2)) + b = int(255 / 31 * int(sb[10:15], base=2)) + return f"rgb({r},{g},{b})" + + +def trace_from_stl(stl_file, backend='matplotlib'): + """ + Generates a Magpylib 3D model trace dictionary from an *.stl file. + backend: 'matplotlib' or 'plotly' + """ + # load stl file + stl_mesh = mesh.Mesh.from_file(stl_file) + + # extract vertices and triangulation + p, q, r = stl_mesh.vectors.shape + vertices, ixr = np.unique(stl_mesh.vectors.reshape(p * q, r), return_inverse=True, axis=0) + i = np.take(ixr, [3 * k for k in range(p)]) + j = np.take(ixr, [3 * k + 1 for k in range(p)]) + k = np.take(ixr, [3 * k + 2 for k in range(p)]) + x, y, z = vertices.T + + # generate and return Magpylib traces + if backend == 'matplotlib': + triangles = np.array([i, j, k]).T + trace = { + 'backend': 'matplotlib', + 'constructor': 'plot_trisurf', + 'args': (x, y, z), + 'kwargs': {'triangles': triangles}, + } + elif backend == 'plotly': + colors = stl_mesh.attr.flatten() + facecolor = np.array([get_stl_color(c) for c in colors]).T + trace = { + 'backend': 'plotly', + 'constructor': 'Mesh3d', + 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), + } + else: + raise ValueError("Backend must be one of ['matplotlib', 'plotly'].") + + return trace + + +# load stl file from online resource +url = "https://raw.githubusercontent.com/magpylib/magpylib-files/main/PG-SSO-3-2.stl" +file = url.split("/")[-1] +with tempfile.TemporaryDirectory() as temp: + fn = os.path.join(temp, file) + with open(fn, "wb") as f: + response = requests.get(url) + f.write(response.content) + + # create traces for both backends + trace_mpl = trace_from_stl(fn, backend='matplotlib') + trace_ply = trace_from_stl(fn, backend='plotly') + +# create sensor and add CAD model +sensor = magpy.Sensor(style_label='PG-SSO-3 package') +sensor.style.model3d.add_trace(trace_mpl) +sensor.style.model3d.add_trace(trace_ply) + +# create magnet and sensor path +magnet = magpy.magnet.Cylinder(magnetization=(0,0,100), dimension=(15,20)) +sensor.position = np.linspace((-15,0,8), (-15,0,-4), 21) +sensor.rotate_from_angax(np.linspace(0, 200, 21), 'z', anchor=0, start=0) + +# display with both backends +magpy.show(sensor, magnet, style_path_frames=5, style_magnetization_show=False) +magpy.show(sensor, magnet, style_path_frames=5, backend="plotly") +``` -A detailed example how to add complex CAD-file is given in {ref}`examples-adding-CAD-model`. diff --git a/docs/examples/examples_14_adding_cad_model.md b/docs/examples/examples_14_adding_cad_model.md deleted file mode 100644 index 63a50ca7c..000000000 --- a/docs/examples/examples_14_adding_cad_model.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.13 - jupytext_version: 1.13.7 -kernelspec: - display_name: Python 3 (ipykernel) - language: python - name: python3 ---- - -(examples-adding-CAD-model)= - -# Adding a CAD model - -As shown in {ref}`examples-own-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. - -```{note} -The code below requires installation of the `numpy-stl` package. -``` - -```{code-cell} ipython3 -import os -import tempfile -import requests -import numpy as np -from stl import mesh # requires installation of numpy-stl -import magpylib as magpy - - -def get_stl_color(x): - """ transform stl_mesh attr to plotly color""" - sb = f"{x:015b}"[::-1] - r = int(255 / 31 * int(sb[:5], base=2)) - g = int(255 / 31 * int(sb[5:10], base=2)) - b = int(255 / 31 * int(sb[10:15], base=2)) - return f"rgb({r},{g},{b})" - - -def trace_from_stl(stl_file, backend='matplotlib'): - """ - Generates a Magpylib 3D model trace dictionary from an *.stl file. - backend: 'matplotlib' or 'plotly' - """ - # load stl file - stl_mesh = mesh.Mesh.from_file(stl_file) - - # extract vertices and triangulation - p, q, r = stl_mesh.vectors.shape - vertices, ixr = np.unique(stl_mesh.vectors.reshape(p * q, r), return_inverse=True, axis=0) - i = np.take(ixr, [3 * k for k in range(p)]) - j = np.take(ixr, [3 * k + 1 for k in range(p)]) - k = np.take(ixr, [3 * k + 2 for k in range(p)]) - x, y, z = vertices.T - - # generate and return Magpylib traces - if backend == 'matplotlib': - triangles = np.array([i, j, k]).T - trace = { - 'backend': 'matplotlib', - 'constructor': 'plot_trisurf', - 'args': (x, y, z), - 'kwargs': {'triangles': triangles}, - } - elif backend == 'plotly': - colors = stl_mesh.attr.flatten() - facecolor = np.array([get_stl_color(c) for c in colors]).T - trace = { - 'backend': 'plotly', - 'constructor': 'Mesh3d', - 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), - } - else: - raise ValueError("Backend must be one of ['matplotlib', 'plotly'].") - - return trace - - -# load stl file from online resource -url = "https://raw.githubusercontent.com/magpylib/magpylib-files/main/PG-SSO-3-2.stl" -file = url.split("/")[-1] -with tempfile.TemporaryDirectory() as temp: - fn = os.path.join(temp, file) - with open(fn, "wb") as f: - response = requests.get(url) - f.write(response.content) - - # create traces for both backends - trace_mpl = trace_from_stl(fn, backend='matplotlib') - trace_ply = trace_from_stl(fn, backend='plotly') - -# create sensor and add CAD model -sensor = magpy.Sensor(style_label='PG-SSO-3 package') -sensor.style.model3d.add_trace(trace_mpl) -sensor.style.model3d.add_trace(trace_ply) - -# create magnet and sensor path -magnet = magpy.magnet.Cylinder(magnetization=(0,0,100), dimension=(15,20)) -sensor.position = np.linspace((-15,0,8), (-15,0,-4), 21) -sensor.rotate_from_angax(np.linspace(0, 200, 21), 'z', anchor=0, start=0) - -# display with both backends -magpy.show(sensor, magnet, style_path_frames=5, style_magnetization_show=False) -magpy.show(sensor, magnet, style_path_frames=5, backend="plotly") -``` diff --git a/docs/examples/examples_21_compound.md b/docs/examples/examples_21_compound.md new file mode 100644 index 000000000..011884c34 --- /dev/null +++ b/docs/examples/examples_21_compound.md @@ -0,0 +1,218 @@ +--- +jupytext: + text_representation: + extension: .md + format_name: myst + format_version: 0.13 + jupytext_version: 1.13.7 +kernelspec: + display_name: Python 3 (ipykernel) + language: python + name: python3 +--- + +(examples-compounds)= + +# Compounds + +The `Collection` class is a powerful tool for grouping and tracking object assemblies. +However, in many cases it is convenient to have assembly variables themselves (e.g. geometric arrangement) as class properties of new custom classes, which is achieved by sub-classing `Collection`. We refer to such super-classes as **compounds** and show how to seamlessly integrate them into Magpylib. + +## Subclassing collections + +In the following example we design a compound class `MagnetRing` which represents a ring of cuboid magnets with the parameter `cubes` that should refer to the number of magnets on the ring. The ring will automatically adjust its size when `cubes` is modified. In the spirit of {ref}`examples-collections-efficient` we also add an encompassing 3D model. + +```{code-cell} ipython3 +import magpylib as magpy + +class MagnetRing(magpy.Collection): + """ A ring of cuboid magnets + + Parameters + ---------- + cubes: int, default=6 + Number of cubes on ring. + """ + + def __init__(self, cubes=6, **style_kwargs): + super().__init__(**style_kwargs) # hand over style args + self._update(cubes) + + @property + def cubes(self): + """ Number of cubes""" + return self._cubes + + @cubes.setter + def cubes(self, inp): + """ set cubes""" + self._update(inp) + + def _update(self, cubes): + """updates the MagnetRing instance""" + self._cubes = cubes + ring_radius = cubes/3 + + # construct in temporary Collection for path transfer + temp_coll = magpy.Collection() + for i in range(cubes): + child = magpy.magnet.Cuboid( + magnetization=(1000,0,0), + dimension=(1,1,1), + position=(ring_radius,0,0) + ) + child.rotate_from_angax(360/cubes*i, 'z', anchor=0) + temp_coll.add(child) + + # transfer path and children + temp_coll.position = self.position + temp_coll.orientation = self.orientation + self.children = temp_coll.children + + # add parameter-dependent 3d trace + self.style.model3d.data = [] + self.style.model3d.add_trace(self._custom_trace3d('plotly')) + self.style.model3d.add_trace(self._custom_trace3d('matplotlib')) + + return self + + def _custom_trace3d(self, backend): + """ creates a parameter-dependent 3d model""" + r1 = self.cubes/3 - .6 + r2 = self.cubes/3 + 0.6 + trace = magpy.graphics.model3d.make_CylinderSegment( + backend=backend, + dimension=(r1, r2, 1.1, 0, 360), + vert=150, + **{('opacity' if backend=='plotly' else 'alpha') :0.5} + ) + return trace +``` + +The new `MagnetRing` objects will seamlessly integrate into Magpylib and make use of the position and orientation interface, field computation and graphic display. + +```{code-cell} ipython3 +# add a sensor +sensor = magpy.Sensor(position=(0, 0, 0)) + +# create a MagnetRing object +ring = MagnetRing() + +# move ring around +ring.position = (0,0,10) +ring.rotate_from_angax(angle=45, axis=(1,-1,0)) + +# compute field +print(f"B-field at sensor → {ring.getB(sensor).round(2)}") + +# display graphically +magpy.show(ring, sensor, backend='plotly') +``` + +The ring parameter `cubes` can be modified dynamically: + +```{code-cell} ipython3 +ring.cubes=15 + +print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(2)}") +magpy.show(ring, sensor, backend='plotly') +``` + +## Postponed trace construction + +Custom traces can be computationally costly to construct. In the above example, the trace is constructed in `_update`, every time the parameter `cubes` is modified. This can lead to an unwanted computational overhead, especially as the construction is only necessary for graphical representation. + +To make our compounds ready for heavy computation, it is possible to provide a callable as a trace, which will only be constructed when `show` is called. The following modification of the above example demonstrates this: + +```{code-cell} ipython3 +from functools import partial +import magpylib as magpy +import numpy as np + +class MagnetRingAdv(magpy.Collection): + """ A ring of cuboid magnets + + Parameters + ---------- + cubes: int, default=6 + Number of cubes on ring. + """ + + def __init__(self, cubes=6, **style_kwargs): + super().__init__(**style_kwargs) # hand over style args + self._update(cubes) + + # hand trace over as callable + self.style.model3d.add_trace(partial(self._custom_trace3d, 'plotly')) + self.style.model3d.add_trace(partial(self._custom_trace3d, 'matplotlib')) + + @property + def cubes(self): + """ Number of cubes""" + return self._cubes + + @cubes.setter + def cubes(self, inp): + """ set cubes""" + self._update(inp) + + def _update(self, cubes): + """updates the MagnetRing instance""" + self._cubes = cubes + ring_radius = cubes/3 + + # construct in temporary Collection for path transfer + temp_coll = magpy.Collection() + for i in range(cubes): + child = magpy.magnet.Cuboid( + magnetization=(1000,0,0), + dimension=(1,1,1), + position=(ring_radius,0,0) + ) + child.rotate_from_angax(360/cubes*i, 'z', anchor=0) + temp_coll.add(child) + + # transfer path and children + temp_coll.position = self.position + temp_coll.orientation = self.orientation + self.children = temp_coll.children + + return self + + def _custom_trace3d(self, backend): + """ creates a parameter-dependent 3d model""" + r1 = self.cubes/3 - .6 + r2 = self.cubes/3 + 0.6 + trace = magpy.graphics.model3d.make_CylinderSegment( + backend=backend, + dimension=(r1, r2, 1.1, 0, 360), + vert=150, + **{('opacity' if backend=='plotly' else 'alpha') :0.5} + ) + return trace +``` + +All we have done is, to remove the trace construction from the `_update` method, and instead provide `_custom_trace3d` as callable in `__init__` with the help of `partial`. + +```{code-cell} ipython3 +ring0 = MagnetRing() +%time for _ in range(100): ring0.cubes=10 + +ring1 = MagnetRingAdv() +%time for _ in range(100): ring1.cubes=10 +``` + +This example is not very impressive because the provided trace is not very heavy. Finally, we play around with our new compound: + +```{code-cell} ipython3 +rings = [] +for i,cub in zip([2,7,12,17,22], [20,16,12,8,4]): + ring = MagnetRingAdv(cubes=cub, style_label=f'MagnetRingAdv (x{cub})') + ring.rotate_from_angax(angle=np.linspace(0,45,10), axis=(1,-1,0)) + ring.move(np.linspace((0,0,0), (-i,-i,i), i)) + rings.append(ring) + +magpy.show(rings, animation=2, backend='plotly', style_path_show=False) +``` + + diff --git a/docs/examples/examples_21_compound_class.md b/docs/examples/examples_21_compound_class.md deleted file mode 100644 index d40878b00..000000000 --- a/docs/examples/examples_21_compound_class.md +++ /dev/null @@ -1,226 +0,0 @@ ---- -jupytext: - text_representation: - extension: .md - format_name: myst - format_version: 0.13 - jupytext_version: 1.13.7 -kernelspec: - display_name: Python 3 (ipykernel) - language: python - name: python3 ---- - -(examples-own-dynamic-3d-model)= - -# Advanced compounds - -This tutorial brings the *compound philosophy* of collections to the next level by subclassing the `Collection` class and adding a dynamic 3D representation. - -## Efficient 3D models - -The Matplotlib and Plotly libraries were not designed for complex 3D graphic outputs. As a result, it becomes often inconvenient and slow when attempting to display many 3D objects. One solution to this problem when dealing with large collections, is to represent the latter by a single encompassing body, and to deactivate the individual 3D models of all children. This is demonstrated in the following example. - -```{code-cell} ipython3 -import magpylib as magpy - -# create collection -coll = magpy.Collection() -for index in range(10): - cuboid = magpy.magnet.Cuboid( - magnetization=(0, 0, 1000 * (index%2-.5)), - dimension=(10,10,10), - position=(index*10,0,0), - ) - coll.add(cuboid) - -# add 3D-trace -plotly_trace = magpy.graphics.model3d.make_Cuboid( - backend='matplotlib', - dimension=(104, 12, 12), - position=(45, 0, 0), - alpha=0.5, -) -coll.style.model3d.add_trace(plotly_trace) - -coll.style.label='Collection with visible children' -coll.show() - -# hide the children default 3D representation -coll.set_children_styles(model3d_showdefault=False) -coll.style.label = 'Collection with hidden children' -coll.show() -``` - -```{note} -The `Collection` position is set to (0,0,0) at creation time. Any added extra 3D-model will be bound to the local coordinate system of to the `Collection` and `rotated`/`moved` together with its parent object. -``` - -## Subclassing collections - -By subclassing the Magpylib `Collection`, we can define special _compound_ objects that have their own new properties, methods and 3d trace. In the following example we build a _magnetic ring_ object which is simply a ring of cuboid magnets. It has the `cubes` property which refers to the number of cuboids in the ring and can be dynamically updated. The `MagnetRing` object itself behaves like a native Magpylib source. - -```{code-cell} ipython3 -import magpylib as magpy - -class MagnetRing(magpy.Collection): - """ A ring of cuboid magnets - - Parameters - ---------- - cubes: int, default=6 - Number of cubes on ring - """ - - def __init__(self, cubes=6, **style_kwargs): - super().__init__(**style_kwargs) - self.update(cubes) - - @property - def cubes(self): - """ Number of cubes""" - return self._cubes - - @cubes.setter - def cubes(self, inp): - """ set cubes""" - self.update(inp) - - def update(self, cubes): - """updates the MagnetRing instance""" - self._cubes = cubes - ring_radius = cubes/3 - - # construct MagnetRing in temporary Collection - temp_coll = magpy.Collection() - for i in range(cubes): - child = magpy.magnet.Cuboid( - magnetization=(1000,0,0), - dimension=(1,1,1), - position=(ring_radius,0,0) - ) - child.rotate_from_angax(360/cubes*i, 'z', anchor=0) - temp_coll.add(child) - - # adjust position/orientation and replace children - temp_coll.position = self.position - temp_coll.orientation = self.orientation - self.children = temp_coll.children - - # add parameter-dependent 3d trace - self.style.model3d.data = [] - self.style.model3d.add_trace(self.create_trace3d('plotly')) - self.style.model3d.add_trace(self.create_trace3d('matplotlib')) - - return self - - def create_trace3d(self, backend): - """ creates a parameter-dependent 3d model""" - r1 = self.cubes/3 - .6 - r2 = self.cubes/3 + 0.6 - trace = magpy.graphics.model3d.make_CylinderSegment( - backend=backend, - dimension=(r1, r2, 1.1, 0, 360) - ) - if backend=='plotly': - trace['kwargs']['opacity'] = 0.5 - return trace - -# add a sensor -sensor = magpy.Sensor(position=(0, 0, 0)) - -# create a MagnetRing class instance -ring = MagnetRing() - -# treat the Magnetic ring like a native magpylib source object -ring.position = (0,0,10) -ring.rotate_from_angax(angle=45, axis=(1,-1,0)) -print(f"B-field at sensor → {ring.getB(sensor).round(2)}") -magpy.show(ring, sensor, backend='plotly') - -# modify object custom attribute -ring.cubes=15 -print(f"B-field at sensor for modified ring → {ring.getB(sensor).round(2)}") -magpy.show(ring, sensor, backend='plotly') -``` - -## Postponed trace construction - -Custom traces might be computationally costly to construct, and in the above example they are recomputed every time a parameter is changed. This can lead to quite some unwanted overhead, as the construction is only necessary once `show` is called. - -To make your classes ready for heavy computation, it is possible to provide a callable as a trace, which will only be constructed when `show` is called. The following modification of the above example demonstrates this. All we do is to remove the trace from the `update` method, and instead provide `create_trace3d` as callable `model3d`. - -```{code-cell} ipython3 -from functools import partial -import magpylib as magpy -import numpy as np - -class MagnetRing(magpy.Collection): - """ A ring of cuboid magnets - - Parameters - ---------- - cubes: int, default=6 - Number of cubes on ring - """ - - def __init__(self, cubes=6, **style_kwargs): - super().__init__(**style_kwargs) - self.update(cubes) - self.style.model3d.add_trace(partial(self.create_trace3d, 'plotly')) - self.style.model3d.add_trace(partial(self.create_trace3d, 'matplotlib')) - - @property - def cubes(self): - """ Number of cubes""" - return self._cubes - - @cubes.setter - def cubes(self, inp): - """ set cubes""" - self.update(inp) - - def update(self, cubes): - """updates the MagnetRing instance""" - self._cubes = cubes - ring_radius = cubes/3 - - # construct MagnetRing in temporary Collection - temp_coll = magpy.Collection() - for i in range(cubes): - child = magpy.magnet.Cuboid( - magnetization=(1000,0,0), - dimension=(1,1,1), - position=(ring_radius,0,0) - ) - child.rotate_from_angax(360/cubes*i, 'z', anchor=0) - temp_coll.add(child) - - # adjust position/orientation and replace children - temp_coll.position = self.position - temp_coll.orientation = self.orientation - self.children = temp_coll.children - - return self - - def create_trace3d(self, backend): - """ creates a parameter-dependent 3d model""" - r1 = self.cubes/3 - .6 - r2 = self.cubes/3 + 0.6 - trace = magpy.graphics.model3d.make_CylinderSegment( - backend=backend, - dimension=(r1, r2, 1.1, 0, 360), - **{('opacity' if backend=='plotly' else 'alpha') :0.5} - ) - return trace - -# create multiple `MagnetRing` instances and animate paths -rings = [] -for i,cub in zip([2,7,12,17,22], [20,16,12,8,4]): - ring = MagnetRing(cubes=cub, style_label=f'MagnetRing (x{cub})') - ring.rotate_from_angax(angle=np.linspace(0,45,10), axis=(1,-1,0)) - ring.move(np.linspace((0,0,0), (-i,-i,i), i)) - rings.append(ring) - -magpy.show(rings, animation=2, backend='plotly', style_path_show=False) -``` diff --git a/docs/examples/examples_22_fem_interpolation.md b/docs/examples/examples_22_field_interpolation.md similarity index 100% rename from docs/examples/examples_22_fem_interpolation.md rename to docs/examples/examples_22_field_interpolation.md diff --git a/docs/examples/examples_30_field_of_a_coil.md b/docs/examples/examples_30_field_of_a_coil.md index 58ea511b0..b81692056 100644 --- a/docs/examples/examples_30_field_of_a_coil.md +++ b/docs/examples/examples_30_field_of_a_coil.md @@ -13,9 +13,9 @@ kernelspec: # Field of a Coil -A coil consists of large number of windings, each of which can be modeled using `Loop` sources. The individual loops are combined in a `Collection` which can then be treated like a single magnetic field source itself, following the compound object paradigm, see {ref}`examples-collections-compound`. +In this example we model the magnetic field of a coil. Teh coil consists of multiple circular current loops, the windings, each of which can be modeled using Magpylib `Loop` source objects. The individual windings are combined in a `Collection` which itself behaves like a single magnetic field source. -One must be careful to take the line-current approximation into consideration. This means that the field diverges when approaching the current, while the field is correct outside a hypothetical wire with homogeneous current distribution. +One must be careful to take the line-current approximation into consideration. This means that the field diverges when approaching the current line, while the field is correct outside a hypothetical wire with homogeneous current distribution. ```{code-cell} ipython3 import numpy as np @@ -56,3 +56,5 @@ plt.colorbar(sp.lines, ax=ax2, label='[mT]') plt.tight_layout() plt.show() ``` + +In {ref}`intro-collections` we show a similar example where a Helmholtz coil is modeled using nested collections. diff --git a/docs/index.md b/docs/index.md index a411c532b..bf5ddff7f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -69,11 +69,9 @@ _autogen/magpylib _changelog.md ``` +-------------------------- + # Index - {ref}`genindex` - {ref}`modindex` - -```bash - -``` From 47163b5307d9c5236e0e66032fa0035099bdeaf1 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Wed, 30 Mar 2022 15:27:28 +0200 Subject: [PATCH 083/207] remove introduction from init --- magpylib/__init__.py | 100 +------------------------------------------ 1 file changed, 1 insertion(+), 99 deletions(-) diff --git a/magpylib/__init__.py b/magpylib/__init__.py index 2687efa9a..115fcbb09 100644 --- a/magpylib/__init__.py +++ b/magpylib/__init__.py @@ -12,7 +12,7 @@ Resources --------- -Documentation on Read-the-docs: +Examples and documentation on Read-the-docs: https://magpylib.readthedocs.io/en/latest/ @@ -24,104 +24,6 @@ https://www.sciencedirect.com/science/article/pii/S2352711020300170 -Introduction ------------- - -Define magnets, currents and sensors as python objects, set their position and orientation in a global coordinate system and compute the magnetic field. - ->>> import magpylib as magpy - -Define a ``Cuboid`` magnet source object. - ->>> src1 = magpy.magnet.Cuboid(magnetization=(0,0,1000), dimension=(1,2,3)) ->>> print(src1.position) ->>> print(src1.orientation.as_euler('xyz', degrees=True)) -[0. 0. 0.] -[0. 0. 0.] - -Define a sensor object at a specific position. - ->>> sens1 = magpy.Sensor(position=(1,2,3)) ->>> print(sens1.position) -[1. 2. 3.] - -Use the built-in move method to move a second source around. - ->>> src2 = magpy.current.Loop(current=15, diameter=3) ->>> src2.move((1, 1, 1)) ->>> print(src2.position) -[1. 1. 1.] - -Use the built-in rotate methods to move a second sensor around. - ->>> sens2 = magpy.Sensor(position=(1,0,0)) ->>> sens2.rotate_from_angax(angle=45, axis=(0,0,1), anchor=(0,0,0)) ->>> print(sens2.position) ->>> print(sens2.orientation.as_euler('xyz', degrees=True)) -[0.70710678 0.70710678 0. ] -[0. 0. 45. ] - -Compute the B-field generated by the source `src1` at the sensor `sens1`. - ->>> B = magpy.getB(src1, sens1) ->>> print(B) -[ 7.48940807 13.41208607 8.02900384] - -Compute the H-field of two sources at two sensors with one line of code. - ->>> H = magpy.getH([src1, src2], [sens1, sens2]) ->>> print(H) -[[[ 5.95988158e+00 1.06729990e+01 6.38927824e+00] - [ 1.98854533e-14 1.98854533e-14 -6.10055863e+01]] - - [[ 2.68813151e-17 4.39005190e-01 8.11887842e-01] - [ 5.64983190e-01 -2.77555756e-16 2.81121230e+00]]] - -Position and orientation attributes can be paths. - ->>> src1.move([(1,1,1), (2,2,2), (3,3,3)]) ->>> print(src1.position) -[[0. 0. 0.] - [1. 1. 1.] - [2. 2. 2.] - [3. 3. 3.]] - -Field computation is automatically performed on the whole path in a vectorized form. - ->>> B = src1.getB(sens1) ->>> print(B) -[[ 7.48940807 13.41208607 8.02900384] - [ 0. 99.07366165 109.1400359 ] - [-80.14272938 0. -71.27583002] - [ 0. 0. -24.62209631]] - -Group sources and sensors for common manipulation using the `Collection` class. - ->>> col = magpy.Collection(sens1, src2) ->>> print(sens1.position) ->>> print(src2.position) ->>> print(col.position) -[1. 2. 3.] -[1. 1. 1.] -[0. 0. 0.] - ->>> col.move((1,1,1)) ->>> print(sens1.position) ->>> print(src2.position) ->>> print(col.position) -[2. 3. 4.] -[2. 2. 2.] -[1. 1. 1.] - -When all source and sensor objects are created and all paths are defined the `show()` (top level function and method of all Magpylib objects) provides a convenient way to graphically display the geometric arrangement through the Matplotlib, - ->>> magpy.show(col) ----> graphic output from matplotlib - -and and the Plotly graphic backend. - ->>> magpy.show(col, backend='plotly') ----> graphic output from plotly """ # module level dunders From 24ab34fdf58aa1b9ee0c86cbf569388dba13eafe Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 16:32:20 +0200 Subject: [PATCH 084/207] implement field func --- magpylib/_src/fields/field_wrap_BH_level1.py | 7 ++- magpylib/_src/fields/field_wrap_BH_level2.py | 11 +---- magpylib/_src/input_checks.py | 38 +++++++++------ .../_src/obj_classes/class_misc_Custom.py | 48 +++++++------------ tests/test_CustomSource.py | 20 +++----- tests/test_input_checks.py | 14 +++--- 6 files changed, 59 insertions(+), 79 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py index 3a68876de..1733471a6 100644 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ b/magpylib/_src/fields/field_wrap_BH_level1.py @@ -80,12 +80,11 @@ def getBH_level1(**kwargs:dict) -> np.ndarray: B = current_line_field(field, pos_rel_rot, current, pos_start, pos_end) elif src_type == 'CustomSource': - #bh_key = 'B' if bh else 'H' - if kwargs[f'field_{field}_lambda'] is not None: - B = kwargs[f'field_{field}_lambda'](pos_rel_rot) + if kwargs.get('field_func', None) is not None: + B = kwargs['field_func'](field, pos_rel_rot) else: raise MagpylibInternalError( - f'{field}-field calculation not implemented for CustomSource class' + 'No field calculation not provided for CustomSource class' ) else: diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index e443d33af..55394fcfd 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -81,12 +81,7 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: kwargs.update({"current": currv, "vertices": vert_list}) elif src_type == "CustomSource": - kwargs.update( - { - "field_B_lambda": group[0].field_B_lambda, - "field_H_lambda": group[0].field_H_lambda, - } - ) + kwargs.update(field_func=group[0].field_func) else: raise MagpylibInternalError("Bad source_type in get_src_dict") @@ -228,9 +223,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: groups = {} for ind, src in enumerate(src_list): if src._object_type == "CustomSource": - group_key = ( - src.field_B_lambda if kwargs["field"] == "B" else src.field_H_lambda - ) + group_key = src.field_func else: group_key = src._object_type if group_key not in groups: diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 97b33aaa9..b0a6e811d 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -1,5 +1,6 @@ """ input checks code""" +import inspect import numbers import numpy as np from scipy.spatial.transform import Rotation @@ -127,27 +128,36 @@ def check_field_input(inp, origin): ) -def validate_field_lambda(val, bh): +def validate_field_func(val): """test if field function for custom source is valid - needs to be a callable - input and output shape must match """ if val is not None: + msg = "" if not callable(val): + msg = f"Instead received {type(val).__name__}." + else: + fn_args = inspect.getfullargspec(val).args + if fn_args[:2] != ["field", "observer"]: + msg = f"Instead received a callable, the first two args being: {fn_args[:2]}" + else: + out = val("B", np.array([[1, 2, 3], [4, 5, 6]])) + out_shape = np.array(out).shape + if out_shape != (2, 3): + msg = ( + "Function test call with observer of shape (2,3) failed, " + f"instead received shape {out_shape}." + ) + + if msg: raise MagpylibBadUserInput( - f"Input parameter `field_{bh}_lambda` must be a callable." - ) - - out = val(np.array([[1, 2, 3], [4, 5, 6]])) - out_shape = np.array(out).shape - case2 = out_shape != (2, 3) - - if case2: - raise MagpylibBadUserInput( - f"Input parameter `field_{bh}_lambda` must be a callable function" - " and return a field ndarray of shape (n,3) when its `observer`" - " input is of shape (n,3).\n" - f"Instead received shape {out_shape}." + "Input parameter `field_func` must be a callable " + "accepting the two positional arguments `field` and `observer` " + "(e.g. `def myfieldfunc(field, observer, ...): ...`. The `field` argument must " + "accept one of ('B','H') and the `observer` an ndarray of shape (n,3). " + "The returned field must be an ndarray matching the observer shape.\n" + + msg ) return val diff --git a/magpylib/_src/obj_classes/class_misc_Custom.py b/magpylib/_src/obj_classes/class_misc_Custom.py index e723b4742..1252151d9 100644 --- a/magpylib/_src/obj_classes/class_misc_Custom.py +++ b/magpylib/_src/obj_classes/class_misc_Custom.py @@ -5,7 +5,7 @@ from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH -from magpylib._src.input_checks import validate_field_lambda +from magpylib._src.input_checks import validate_field_func # ON INTERFACE @@ -19,13 +19,12 @@ class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): Parameters ---------- - field_B_lambda: callable, default=`None` - Field function for the B-field. Must accept position input with format (n,3) and - return the B-field with similar shape in units of [mT]. - - field_H_lambda: callable, default=`None` - Field function for the H-field. Must accept position input with format (n,3) and - return the H-field with similar shape in units of [kA/m]. + field_func: callable, default=`None` + Field function for the B-and-H-field. Must accept the two positional arguments `field` and + `observer` position input with format (n,3). The `field` argument must accept one of + `('B','H')` and the `observer` an ndarray of shape (n,3). The callable must return the field + in units of [mT] for `field='B'` and [kA/m] for `field='H'` and must match the observer + shape. position: array_like, shape (3,) or (m,3), default=`(0,0,0)` Object position(s) in the global coordinates in units of [mm]. For m>1, the @@ -57,9 +56,8 @@ class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): >>> import numpy as np >>> import magpylib as magpy >>> - >>> bfield = lambda observer: np.array([(100,0,0)]*len(observer)) - >>> hfield = lambda observer: np.array([(80,0,0)]*len(observer)) - >>> src = magpy.misc.CustomSource(field_B_lambda=bfield, field_H_lambda=hfield) + >>> funcBH = lambda field, observer: np.array([(100 if field=='B' else 80,0,0)]*len(observer)) + >>> src = magpy.misc.CustomSource(field_func=funcBH) >>> H = src.getH((1,1,1)) >>> print(H) [80. 0. 0.] @@ -87,16 +85,14 @@ class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): def __init__( self, - field_B_lambda=None, - field_H_lambda=None, + field_func=None, position=(0, 0, 0), orientation=None, style=None, **kwargs, ): # instance attributes - self.field_B_lambda = field_B_lambda - self.field_H_lambda = field_H_lambda + self.field_func = field_func self._object_type = "CustomSource" # init inheritance @@ -104,25 +100,13 @@ def __init__( BaseDisplayRepr.__init__(self) @property - def field_B_lambda(self): + def field_func(self): """ Field function for the B-field. Must accept position input with format (n,3) and return the B-field with similar shape in units of [mT]. """ - return self._field_B_lambda - - @field_B_lambda.setter - def field_B_lambda(self, val): - self._field_B_lambda = validate_field_lambda(val, "B") - - @property - def field_H_lambda(self): - """ - Field function for the H-field. Must accept position input with format (n,3) and - return the H-field with similar shape in units of [kA/m]. - """ - return self._field_H_lambda + return self._field_func - @field_H_lambda.setter - def field_H_lambda(self, val): - self._field_H_lambda = validate_field_lambda(val, "H") + @field_func.setter + def field_func(self, val): + self._field_func = validate_field_func(val) diff --git a/tests/test_CustomSource.py b/tests/test_CustomSource.py index 07121dd23..049a61d57 100644 --- a/tests/test_CustomSource.py +++ b/tests/test_CustomSource.py @@ -6,26 +6,20 @@ # pylint: disable=assignment-from-no-return # pylint: disable=unused-argument -def constant_Bfield(position=(0,0,0)): +def constant_field(field, observer=(0,0,0)): """ constant field""" - position = np.array(position) + position = np.array(observer) length = 1 if position.ndim==1 else len(position) return np.array([[1, 2, 3]] * length) -def constant_Hfield(position=(0, 0, 0)): - """ constant field - no idea why we need this """ - position = np.array(position) - length = 1 if position.ndim==1 else len(position) - return np.array([[1, 2, 3]] * length) - -def bad_Bfield_func(position): +def bad_Bfield_func(field, observer): """ another constant function without docstring""" return np.array([[1, 2, 3]]) def test_CustomSource_basicB(): """Basic custom source class test""" - external_field = magpy.misc.CustomSource(field_B_lambda=constant_Bfield) + external_field = magpy.misc.CustomSource(field_func=constant_field) B = external_field.getB((1, 2, 3)) Btest = np.array((1, 2, 3)) @@ -43,7 +37,7 @@ def test_CustomSource_basicB(): def test_CustomSource_basicH(): """Basic custom source class test""" - external_field = magpy.misc.CustomSource(field_H_lambda=constant_Hfield) + external_field = magpy.misc.CustomSource(field_func=constant_field) H = external_field.getH((1, 2, 3)) Htest = np.array((1, 2, 3)) @@ -62,10 +56,10 @@ def test_CustomSource_basicH(): def test_CustomSource_bad_inputs(): """missing docstring""" with pytest.raises(MagpylibBadUserInput): - magpy.misc.CustomSource(field_H_lambda='not a callable') + magpy.misc.CustomSource(field_func='not a callable') with pytest.raises(MagpylibBadUserInput): - magpy.misc.CustomSource(field_H_lambda=bad_Bfield_func) + magpy.misc.CustomSource(field_func=bad_Bfield_func) src = magpy.misc.CustomSource() with pytest.raises(MagpylibInternalError): diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index dfb7b3263..8b8d9db50 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -379,23 +379,23 @@ def test_input_objects_dimension_cylinderSegment_bad(): def test_input_objects_fiedBHlambda_good(): - """good input: magpy.misc.CustomSource(field_B_lambda=f, field_H_lambda=f)""" - def f(x): + """good input: magpy.misc.CustomSource(field_func=f)""" + def f(field, observer): """3 in 3 out""" - return x - src = magpy.misc.CustomSource(field_B_lambda=f, field_H_lambda=f) + return observer + src = magpy.misc.CustomSource(field_func=f) np.testing.assert_allclose(src.getB((1,2,3)), (1,2,3)) np.testing.assert_allclose(src.getH((1,2,3)), (1,2,3)) def test_input_objects_fiedBHlambda_bad(): - """bad input: magpy.misc.CustomSource(field_B_lambda=f, field_H_lambda=f)""" - def f(x): + """bad input: magpy.misc.CustomSource(field_func=f)""" + def f(field, observer): """bad fieldBH lambda""" return 1 np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f) - np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, field_H_lambda=f) + np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f) From b46c06e2b801c8a7a6ed22f131796c0d29f540bf Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 16:51:16 +0200 Subject: [PATCH 085/207] fix docs --- docs/_pages/page_01_introduction.md | 13 +++++++------ docs/examples/examples_04_custom_source.md | 17 +++++++++-------- docs/examples/examples_22_fem_interpolation.md | 10 +++++++--- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/docs/_pages/page_01_introduction.md b/docs/_pages/page_01_introduction.md index e2df39474..2f115b06f 100644 --- a/docs/_pages/page_01_introduction.md +++ b/docs/_pages/page_01_introduction.md @@ -110,7 +110,7 @@ All current objects have the `current` attribute which must be a scalar $i_0$ an - **`Dipole`**`(moment, position, orientation, style)` represents a magnetic dipole moment with moment $(m_x,m_y,m_z)$ given in \[mT mm³]. For homogeneous magnets the relation moment=magnetization$\times$volume holds. Can be used as Magpylib `sources` input. -- **`CustomSource`**`(field_B_lambda, field_H_lambda, position, orientation, style)` can be used to create user defined custom sources. Can be used as Magpylib `sources` input. +- **`CustomSource`**`(field_func, position, orientation, style)` can be used to create user defined custom sources. Can be used as Magpylib `sources` input. - **`Sensor`**`(position, pixel, orientation)` represents a 3D magnetic field sensor. The field is evaluated at the given pixel positions, by default `pixel=(0,0,0)`. Can be used as Magpylib `observers` input. @@ -517,7 +517,7 @@ It should be noted that collections have their own `style` attributes. Their pat **User-defined 3D models** (traces) that will be displayed by `show`, can be stored in `style.model3d.data`. A trace itself is a dictionary that contains all information necessary for plotting, and can be added with the method `style.model3d.data.add_trace`. In the example gallery {ref}`examples-3d-models` it is explained how to create custom traces with standard plotting backends such as `scatter3d` or `mesh3d` in Plotly, or `plot`, `plot_surface` and `plot_trisurf` in Matplotlib. Some pre-defined models are also provided for easy parts visualization. -**User-defined source classes** are easily realized through the `CustomSource` class. Such a custom source object can be provided with user-defined field computation functions, that are stored in the attributes `field_B_lambda` and `field_H_lambda`, and will be used when `getB` and `getH` are called. The provided functions must accept position arrays with shape (n,3), and return the field with a similar shape. Details on working with custom sources are given in {ref}`examples-custom-source-objects`. +**User-defined source classes** are easily realized through the `CustomSource` class. Such a custom source object can be provided with user-defined field computation functions, that are stored in the attribute `field_func`, and will be used when `getB` or `getH` are called. The provided function must accept a `field` argument as a string (`'B'` or `'H'`) and an `observer` array with shape (n,3), and return the field with a similar shape. Details on working with custom sources are given in {ref}`examples-custom-source-objects`. While each of these features can be used individually, the combination of the two (own source class with own 3D representation) enables a high level of customization in Magpylib. Such user-defined objects will feel like native Magpylib objects and can be used in combination with all other features, which is demonstrated in the following example: @@ -527,14 +527,15 @@ import plotly.graph_objects as go import magpylib as magpy # define B-field function for custom source -def custom_field(pos): +def custom_field(field, observer): """ easter field""" - dist = np.linalg.norm(pos, axis=1) - return np.c_[np.zeros((len(pos),2)), 1/dist**3] + if field=='B': + dist = np.linalg.norm(observer, axis=1) + return np.c_[np.zeros((len(observer),2)), 1/dist**3] # create custom source egg = magpy.misc.CustomSource( - field_B_lambda=custom_field, + field_func=custom_field, style=dict(color='orange', model3d_showdefault=False, label='The Egg'), ) diff --git a/docs/examples/examples_04_custom_source.md b/docs/examples/examples_04_custom_source.md index b24033297..a995b4bd7 100644 --- a/docs/examples/examples_04_custom_source.md +++ b/docs/examples/examples_04_custom_source.md @@ -14,24 +14,25 @@ kernelspec: (examples-custom-source-objects)= # Custom source objects -The custom source class `CustomSource` allows users to integrate their own custom-objects into the Magpylib interface. The `field_B_lambda` and `field_H_lambda` arguments can be provided with function that are called with `getB` and `getH`. +The custom source class `CustomSource` allows users to integrate their own custom-objects into the Magpylib interface. The `field_func` argument can be provided and make the custom source compatible with the general `getB` and `getH` functions. -These custom field functions are treated like core functions. they must accept position inputs (array_like, shape (n,3)) and return the respective field with a similar shape. A fundamental example how to create a custom source object is: +The custom field function is treated like a core functions. It must accept a `field` argument (`'B'` or `'H'`), an `observer` argument (array_like, shape (n,3)) and return the respective field with a similar shape. A fundamental example how to create a custom source object is shown below: ```{code-cell} ipython3 import numpy as np import magpylib as magpy # define field function -def custom_field(position): +def custom_field(field, observer): """ user defined custom field position input: array_like, shape (n,3) returns: ndarray, shape (n,3) """ - return np.array(position)*2 + if field=='B': + return np.array(observer)*2 # custom source -source = magpy.misc.CustomSource(field_B_lambda=custom_field) +source = magpy.misc.CustomSource(field_func=custom_field) # compute field with 2-pixel sensor sensor = magpy.Sensor(pixel=((1,1,1), (2,2,2))) @@ -65,15 +66,15 @@ trace_pole = magpy.graphics.model3d.make_Ellipsoid( # combine four monopole custom sources into a quadrupole collection def create_pole(charge): """ create a monopole object""" - field = lambda x: monopole_field( charge, x) + field = lambda field, observer: monopole_field(charge, observer) monopole = magpy.misc.CustomSource( - field_B_lambda=field, + field_func=field, style_model3d_showdefault=False, ) monopole.style.model3d.add_trace(trace_pole) return monopole -quadrupole = magpy.Collection([create_pole(q) for q in [1,1,-1,-1]]) +quadrupole = magpy.Collection(*[create_pole(q) for q in [1,1,-1,-1]]) # move and color the pole objects pole_pos = np.array([(1,0,0), (-1,0,0), (0,0,1), (0,0,-1)]) diff --git a/docs/examples/examples_22_fem_interpolation.md b/docs/examples/examples_22_fem_interpolation.md index 4659641a9..d5331f4e5 100644 --- a/docs/examples/examples_22_fem_interpolation.md +++ b/docs/examples/examples_22_fem_interpolation.md @@ -13,7 +13,7 @@ kernelspec: # Field interpolation -Working with complex magnet shapes can be cumbersome when a lot of base shapes are required, see {ref}`examples-complex-forms`. One way around this problem is to compute the field only a single time on a 3D grid, define an interpolation function, and use the interpolation function as `field_B_lambda` input of a custom source. +Working with complex magnet shapes can be cumbersome when a lot of base shapes are required, see {ref}`examples-complex-forms`. One way around this problem is to compute the field only a single time on a 3D grid, define an interpolation function, and use the interpolation function as `field_func` input of a custom source. This makes it possible to use the full Magpylib geometry interface, to move/rotate the complex source around, without having to recompute the total field at new observer positions. Of course this method would also allow it to integrate the field from a **finite element** computation or some **experimental data** into Magpylib. @@ -68,7 +68,11 @@ def interpolate_field(data, method="linear", bounds_error=False, fill_value=np.n for field in field_vec: rgi = RegularGridInterpolator((X, Y, Z), field.reshape(nx, ny, nz), **kwargs) field_interp.append(rgi) - return lambda x: np.array([field(x) for field in field_interp]).T + + def field_func(field, observer): + if field=='B': + return np.array([field(observer) for field in field_interp]).T + return field_func ``` ## Custom source with interpolation field @@ -83,7 +87,7 @@ grid = np.array(np.meshgrid(ts,ts,ts)).T.reshape(-1,3) data = np.hstack((grid, cube.getB(grid))) # create custom source with nice 3D model -custom = magpy.misc.CustomSource(field_B_lambda=interpolate_field(data)) +custom = magpy.misc.CustomSource(field_func=interpolate_field(data)) xs = 1.1*np.array([-1, -1, 1, 1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1]) ys = 1.1*np.array([-1, 1, 1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1, -1, -1, -1]) From c38f4524052930c8588b1eeeafbeed43f6369a9f Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 17:12:40 +0200 Subject: [PATCH 086/207] coverage --- tests/test_input_checks.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index 8b8d9db50..818a1e48a 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -391,11 +391,15 @@ def f(field, observer): def test_input_objects_fiedBHlambda_bad(): """bad input: magpy.misc.CustomSource(field_func=f)""" - def f(field, observer): + def f1(field, observer): """bad fieldBH lambda""" return 1 - np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f) - np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f) + np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f1) + + def f2(observer): + """bad args""" + return + np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f2) From 42c003ca70116214c5882a9bc83296debf902869 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 30 Mar 2022 17:14:02 +0200 Subject: [PATCH 087/207] coverage --- tests/test_input_checks.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index 818a1e48a..4ada690ce 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -396,9 +396,7 @@ def f1(field, observer): return 1 np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f1) - def f2(observer): - """bad args""" - return + f2 = lambda observer: observer np.testing.assert_raises(MagpylibBadUserInput, magpy.misc.CustomSource, f2) From a5d3977388ae468c6f665c6ee10dd2c86cd0f5f7 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Wed, 30 Mar 2022 21:06:41 +0200 Subject: [PATCH 088/207] extend pixel_agg testing --- tests/test_getBH_level2.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py index 99145fb75..35b670946 100644 --- a/tests/test_getBH_level2.py +++ b/tests/test_getBH_level2.py @@ -324,12 +324,14 @@ def test_pixel_agg(): def test_pixel_agg_heterogeneous_pixel_shapes(): """test pixel aggregator with heterogeneous pixel shapes""" src1 = magpy.magnet.Cuboid((0,0,1000),(1,1,1)) + src2 = magpy.magnet.Sphere((0,0,1000),1, position=(2,0,0)) sens1 = magpy.Sensor(position=(0,0,1), pixel=[0,0,0], style_label='sens1, pixel.shape = (3,)') sens2 = sens1.copy(position=(0,0,2), pixel=[1,1,1], style_label='sens2, pixel.shape = (3,)') sens3 = sens1.copy(position=(0,0,3), pixel=[2,2,2], style_label='sens3, pixel.shape = (3,)') sens4 = sens1.copy(style_label='sens4, pixel.shape = (3,)') sens5 = sens2.copy(pixel=np.zeros((4,5,3))+1, style_label='sens5, pixel.shape = (3,)') sens6 = sens3.copy(pixel=np.zeros((4,5,1,3))+2, style_label='sens6, pixel.shape = (4,5,1,3)') + src_col = magpy.Collection(src1, src2) sens_col1 = magpy.Collection(sens1, sens2, sens3) sens_col2 = magpy.Collection(sens4, sens5, sens6) sens_col1.rotate_from_angax([45], 'z', anchor = (5,0,0)) @@ -358,3 +360,15 @@ def test_pixel_agg_heterogeneous_pixel_shapes(): # B3 and B4 should deliver the same results since pixel all have the same # positions respectively for each sensor, so mean equals single value np.testing.assert_allclose(B3, B4) + + # Testing autmatic vs manual aggregation (mean) with different pixel shapes + B_by_sens_agg_1 = magpy.getB(src_col, sens_col2, squeeze=False, pixel_agg='mean') + B_by_sens_agg_2 = [] + for sens in sens_col2: + B = magpy.getB(src_col, sens, squeeze=False) + B = B.mean(axis=tuple(range(3 - B.ndim, -1))) + B = np.expand_dims(B, axis=-2) + B_by_sens_agg_2.append(B) + B_by_sens_agg_2 = np.concatenate(B_by_sens_agg_2, axis=2) + + np.testing.assert_allclose(B_by_sens_agg_1, B_by_sens_agg_2) From dee53b4077eb3d0b69899f007c4024baf03f5726 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 00:06:45 +0200 Subject: [PATCH 089/207] fix bad tiling bug --- magpylib/_src/fields/field_wrap_BH_level2.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 55394fcfd..2bc801123 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -18,12 +18,10 @@ def tile_group_property(group: list, n_pp: int, prop_name: str): """tile up group property""" - prop0 = getattr(group[0], prop_name) out = np.array([getattr(src, prop_name) for src in group]) - out = np.tile(out, n_pp) - if np.isscalar(prop0): - return out.flatten() - return out.reshape((-1, prop0.shape[0])) + if np.isscalar(out[0]): + return np.repeat(out, n_pp) + return np.tile(out, (n_pp, 1)) def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: @@ -274,8 +272,8 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: sens_orient = sens._orientation[0] else: sens_orient = R.from_quat( - np.tile( # tile for each source from list - np.repeat( # same orientation path index for all indices + np.tile( # tile for each source from list + np.repeat( # same orientation path index for all indices sens._orientation.as_quat(), pix_nums[sens_ind], axis=0 ), (num_of_sources, 1), From a134ba74d23f0d61b150a4ae06f32fef0f9648e7 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 10:46:51 +0200 Subject: [PATCH 090/207] add test --- tests/test_getBH_level2.py | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py index 35b670946..5270c3da9 100644 --- a/tests/test_getBH_level2.py +++ b/tests/test_getBH_level2.py @@ -285,6 +285,26 @@ def test_object_tiling(): assert src4.orientation.as_quat().shape == (31, 4), 'd4' assert sens.orientation.as_quat().shape == (4,), 'd5' +def test_superposition_vs_tiling(): + """test superposition vs tiling, see issue #507""" + + loop_1 = magpy.current.Loop(current=10000, diameter=20, position=(1, 20, 10)) + loop_1.rotate_from_angax([45,90], "x") + + loop_2 = magpy.current.Loop(current=2000, diameter=40, position=(20, 10, 1)) + loop_2.rotate_from_angax([45,90], "y") + + loop_collection = magpy.Collection(loop_1, loop_2) + + observer_positions = [[0, 0, 0], [1, 1, 1]] + + B1 = magpy.getB(loop_1, observer_positions) + B2 = magpy.getB(loop_2, observer_positions) + superposed_B = B1 + B2 + + collection_B = magpy.getB(loop_collection, observer_positions) + + np.testing.assert_allclose(superposed_B, collection_B) def test_squeeze_sumup(): """ make sure that sumup does not lead to false output shape From 57be573ea67723b917208943b92f606aebba7373 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 20:24:45 +0200 Subject: [PATCH 091/207] fix example --- docs/_pages/page_01_introduction.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_pages/page_01_introduction.md b/docs/_pages/page_01_introduction.md index ae5e94ec3..4e5c05467 100644 --- a/docs/_pages/page_01_introduction.md +++ b/docs/_pages/page_01_introduction.md @@ -554,7 +554,7 @@ import magpylib as magpy def easter_field(field, observer): """ points in z-direction and decays with 1/r^3""" if field=='B': - dist = np.linalg.norm(pos, axis=1) + dist = np.linalg.norm(observer, axis=1) return np.c_[np.zeros((len(observer),2)), 1/dist**3] # create custom source From bc5fe3da5e92236f5d18569873f4061d644cb86d Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 21:41:20 +0200 Subject: [PATCH 092/207] fix tile_group_property --- magpylib/_src/fields/field_wrap_BH_level2.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index b89a91368..4c2890daa 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -19,9 +19,7 @@ def tile_group_property(group: list, n_pp: int, prop_name: str): """tile up group property""" out = np.array([getattr(src, prop_name) for src in group]) - if np.isscalar(out[0]): - return np.repeat(out, n_pp) - return np.tile(out, (n_pp, 1)) + return np.repeat(out, n_pp, axis=0) def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: @@ -241,6 +239,8 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: lg = len(group["sources"]) gr = group["sources"] src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 + for k in ('observer', 'magnetization'): + print(k+': ', src_dict.get(k, None)) B_group = getBH_level1(field=kwargs["field"], **src_dict) # compute field B_group = B_group.reshape( (lg, max_path_len, n_pix, 3) From da21c56f3cb1c5e16a3478901195cce83bf575a6 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 21:48:53 +0200 Subject: [PATCH 093/207] remove print statement --- magpylib/_src/fields/field_wrap_BH_level2.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 4c2890daa..01872aa31 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -239,8 +239,6 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: lg = len(group["sources"]) gr = group["sources"] src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 - for k in ('observer', 'magnetization'): - print(k+': ', src_dict.get(k, None)) B_group = getBH_level1(field=kwargs["field"], **src_dict) # compute field B_group = B_group.reshape( (lg, max_path_len, n_pix, 3) From d01195feb43fb2f53461a2ac002dd49ce9a93abc Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 22:15:10 +0200 Subject: [PATCH 094/207] make tiling vs superposition test more general --- tests/test_getBH_level2.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py index 3f781fd58..6ec3df4dc 100644 --- a/tests/test_getBH_level2.py +++ b/tests/test_getBH_level2.py @@ -288,18 +288,18 @@ def test_object_tiling(): def test_superposition_vs_tiling(): """test superposition vs tiling, see issue #507""" - loop_1 = magpy.current.Loop(current=10000, diameter=20, position=(1, 20, 10)) - loop_1.rotate_from_angax([45,90], "x") + loop = magpy.current.Loop(current=10000, diameter=20, position=(1, 20, 10)) + loop.rotate_from_angax([45,90], "x") - loop_2 = magpy.current.Loop(current=2000, diameter=40, position=(20, 10, 1)) - loop_2.rotate_from_angax([45,90], "y") + cube = magpy.magnet.Sphere(magnetization=(0,0,1), diameter=40, position=(20, 10, 1)) + cube.rotate_from_angax([45,90], "y") - loop_collection = magpy.Collection(loop_1, loop_2) + loop_collection = magpy.Collection(loop, cube) observer_positions = [[0, 0, 0], [1, 1, 1]] - B1 = magpy.getB(loop_1, observer_positions) - B2 = magpy.getB(loop_2, observer_positions) + B1 = magpy.getB(loop, observer_positions) + B2 = magpy.getB(cube, observer_positions) superposed_B = B1 + B2 collection_B = magpy.getB(loop_collection, observer_positions) From 99a0a354704cf75ef82a4e7814ec27aa591b8b49 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 22:23:41 +0200 Subject: [PATCH 095/207] make tiling vs superposition test more general --- tests/test_getBH_level2.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_getBH_level2.py b/tests/test_getBH_level2.py index 6ec3df4dc..5f4d440f8 100644 --- a/tests/test_getBH_level2.py +++ b/tests/test_getBH_level2.py @@ -291,16 +291,20 @@ def test_superposition_vs_tiling(): loop = magpy.current.Loop(current=10000, diameter=20, position=(1, 20, 10)) loop.rotate_from_angax([45,90], "x") - cube = magpy.magnet.Sphere(magnetization=(0,0,1), diameter=40, position=(20, 10, 1)) + cube = magpy.magnet.Cuboid(magnetization=(0,0,1), dimension=(1,1,1), position=(20, 10, 1)) cube.rotate_from_angax([45,90], "y") - loop_collection = magpy.Collection(loop, cube) + sphere = magpy.magnet.Sphere(magnetization=(0,0,1), diameter=40, position=(10, 20, 1)) + sphere.rotate_from_angax([45,90], "y") + + loop_collection = magpy.Collection(loop, cube , sphere) observer_positions = [[0, 0, 0], [1, 1, 1]] B1 = magpy.getB(loop, observer_positions) B2 = magpy.getB(cube, observer_positions) - superposed_B = B1 + B2 + B3 = magpy.getB(sphere, observer_positions) + superposed_B = B1 + B2 + B3 collection_B = magpy.getB(loop_collection, observer_positions) From 12211079ca41f7a0e8ef8b19019e85d97da4b722 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 22:41:36 +0200 Subject: [PATCH 096/207] fix current_line_field call --- magpylib/_src/fields/field_BH_line.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/fields/field_BH_line.py b/magpylib/_src/fields/field_BH_line.py index 73f7821f0..6571c5059 100644 --- a/magpylib/_src/fields/field_BH_line.py +++ b/magpylib/_src/fields/field_BH_line.py @@ -30,7 +30,7 @@ def current_vertices_field( - B-field (ndarray nx3): B-field vectors at pos_obs in units of mT """ if vertices is None: - return current_line_field(current, segment_start, segment_end, observer, field=field) + return current_line_field(field, observer, current, segment_start, segment_end) nv = len(vertices) # number of input vertex_sets npp = int(observer.shape[0]/nv) # number of position vectors From f07c1dcf275f756e683d4111badd6dd6ec0f5102 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 31 Mar 2022 22:47:00 +0200 Subject: [PATCH 097/207] fix pixel_agg --- magpylib/_src/fields/field_wrap_BH_level2.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index c86a62b06..21e7210c5 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -156,7 +156,6 @@ def getBH_level2( # allow only bare sensor, collection, pos_vec or list thereof # transform input into an ordered list of sensors (pos_vec->pixel) # check if all pixel shapes are similar - or else if pixel_agg is given - pixel_agg = kwargs.get("pixel_agg", None) pixel_agg_func = check_format_pixel_agg(pixel_agg) sensors, pix_shapes = check_format_input_observers(observers, pixel_agg) pix_nums = [ From ccc070cf9842c970f346c7c02bc4d2165a3758d1 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 1 Apr 2022 17:28:09 +0200 Subject: [PATCH 098/207] finish merge --- magpylib/_src/fields/field_wrap_BH_level1.py | 2 +- magpylib/_src/fields/field_wrap_BH_level2.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py index 972c31b2a..bce038870 100644 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ b/magpylib/_src/fields/field_wrap_BH_level1.py @@ -55,7 +55,7 @@ def getBH_level1( if kwargs.get("field_func", None) is not None: BH = kwargs["field_func"](field, pos_rel_rot) elif field_func is not None: - BH = field_func(observer=pos_rel_rot, **kwargs) + BH = field_func(observers=pos_rel_rot, **kwargs) else: raise MagpylibInternalError(f'Bad src input type "{source_type}" in level1') diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index b12eaabd1..b572d3193 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -149,7 +149,7 @@ def getBH_level2( # test if all source dimensions and excitations are initialized check_dimensions(sources) - check_excitations(sources, kwargs['field']) + check_excitations(sources, field) # format observers input: # allow only bare sensor, collection, pos_vec or list thereof From cd98829af36326d370d96ff46565c67563b8f2d6 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Apr 2022 10:12:29 +0200 Subject: [PATCH 099/207] add pandas --- setup.py | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.py b/setup.py index 2a0826ba2..de873dade 100644 --- a/setup.py +++ b/setup.py @@ -76,6 +76,7 @@ def run(self): "pylint", "jupyterlab>=3.2", "sphinx==4.4.0", + "pandas", ] }, classifiers=[ From 70cd5edd70fa2fffbe4fd21c64d03cc4e82f2038 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Apr 2022 10:12:39 +0200 Subject: [PATCH 100/207] draft --- magpylib/_src/fields/field_wrap_BH_level2.py | 34 ++++++++++++++++---- magpylib/_src/input_checks.py | 23 +++++++++++++ 2 files changed, 51 insertions(+), 6 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 0deff6ef2..531dd46ae 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -1,3 +1,5 @@ +from itertools import product + import numpy as np from scipy.spatial.transform import Rotation as R @@ -9,6 +11,7 @@ from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_observers from magpylib._src.input_checks import check_format_pixel_agg +from magpylib._src.input_checks import check_getBH_output_type from magpylib._src.utility import check_static_sensor_orient from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs @@ -128,7 +131,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # bad user inputs mixing getBH_dict kwargs with object oriented interface kwargs_check = kwargs.copy() - for popit in ["field", "sumup", "squeeze", "pixel_agg"]: + for popit in ["field", "sumup", "squeeze", "pixel_agg", "output"]: kwargs_check.pop(popit, None) if kwargs_check: raise MagpylibBadUserInput( @@ -295,10 +298,34 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: Bagg = [np.expand_dims(pixel_agg_func(b, axis=2), axis=2) for b in Bsplit] B = np.concatenate(Bagg, axis=2) + # reset tiled objects + for obj, m0 in zip(reset_obj, reset_obj_m0): + obj._position = obj._position[:m0] + obj._orientation = obj._orientation[:m0] + # sumup over sources if kwargs["sumup"]: B = np.sum(B, axis=0, keepdims=True) + output = check_getBH_output_type(kwargs.get("output", "ndarray")) + + if output == "dataframe": + # pylint: disable=import-outside-toplevel + import pandas as pd + + if kwargs["sumup"] and len(sources) > 1: + src_ids = [f"sumup ({len(sources)})"] + else: + src_ids = [s.style.label if s.style.label else f"{s}" for s in sources] + sens_ids = [s.style.label if s.style.label else f"{s}" for s in sensors] + num_of_pixels = np.prod(pix_shapes[0][:-1]) + df = pd.DataFrame( + data=product(src_ids, range(max_path_len), sens_ids, range(num_of_pixels)), + columns=["source", "path", "sensor", "pixel"], + ) + df[[kwargs["field"] + k for k in "xyz"]] = B.reshape(-1, 3) + return df + # reduce all size-1 levels if kwargs["squeeze"]: B = np.squeeze(B) @@ -307,9 +334,4 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: # dimensions to zero. Only needed if `squeeze is False`` B = np.expand_dims(B, axis=-2) - # reset tiled objects - for obj, m0 in zip(reset_obj, reset_obj_m0): - obj._position = obj._position[:m0] - obj._orientation = obj._orientation[:m0] - return B diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index f57d642f2..8ea55c977 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -617,3 +617,26 @@ def check_format_pixel_agg(pixel_agg): raise AttributeError(PIXEL_AGG_ERR_MSG) return pixel_agg_func + + +def check_getBH_output_type(output): + """check if getBH output is acceptable""" + acceptable = ("ndrarray", "dataframe") + if output not in acceptable: + raise AttributeError( + "The `output` argument must be one of {acceptable}." + f"\nInstead received {output}." + ) + elif output == "dataframe": + try: + # pylint: disable=import-outside-toplevel + # pylint: disable=unused-import + import pandas + except ImportError as missing_module: # pragma: no cover + raise ModuleNotFoundError( + "In order to use the `dataframe` output type, you need to install pandas " + "via pip or conda, " + "see https://pandas.pydata.org/docs/getting_started/install.html" + ) from missing_module + + return output From cc16bc645f220f00f0aff3487f20488e0e3fb3dd Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Apr 2022 10:28:32 +0200 Subject: [PATCH 101/207] typo --- magpylib/_src/input_checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 8ea55c977..e442cc656 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -621,7 +621,7 @@ def check_format_pixel_agg(pixel_agg): def check_getBH_output_type(output): """check if getBH output is acceptable""" - acceptable = ("ndrarray", "dataframe") + acceptable = ("ndarray", "dataframe") if output not in acceptable: raise AttributeError( "The `output` argument must be one of {acceptable}." From a0259f7ca68b6b66866c42582eca2b62cf445f87 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 21 Apr 2022 11:46:37 +0200 Subject: [PATCH 102/207] pylint --- magpylib/_src/input_checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index e442cc656..3af777a4b 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -627,7 +627,7 @@ def check_getBH_output_type(output): "The `output` argument must be one of {acceptable}." f"\nInstead received {output}." ) - elif output == "dataframe": + if output == "dataframe": try: # pylint: disable=import-outside-toplevel # pylint: disable=unused-import From 9807990e2a4bc6cfec3031ed900284ea3c805d19 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 21 Apr 2022 12:01:05 +0200 Subject: [PATCH 103/207] add pandas --- docs/requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/requirements.txt b/docs/requirements.txt index fea403532..737f2540f 100644 --- a/docs/requirements.txt +++ b/docs/requirements.txt @@ -1,4 +1,5 @@ -e . +pandas sphinx==4.4.0 sphinx-copybutton sphinx-book-theme From 6b3990a160b6d5dd7f92963c7255c3564fcc53d9 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 21 Apr 2022 12:34:28 +0200 Subject: [PATCH 104/207] update docstrings --- magpylib/_src/fields/field_wrap_BH_level3.py | 34 ++++++++++++++-- magpylib/_src/obj_classes/class_BaseGetBH.py | 22 ++++++++-- magpylib/_src/obj_classes/class_Collection.py | 22 ++++++++-- magpylib/_src/obj_classes/class_Sensor.py | 40 ++++++++++++++++--- 4 files changed, 100 insertions(+), 18 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level3.py b/magpylib/_src/fields/field_wrap_BH_level3.py index 4725e0241..031c21239 100644 --- a/magpylib/_src/fields/field_wrap_BH_level3.py +++ b/magpylib/_src/fields/field_wrap_BH_level3.py @@ -2,7 +2,13 @@ def getB( - sources=None, observers=None, sumup=False, squeeze=True, pixel_agg=None, **kwargs + sources=None, + observers=None, + sumup=False, + squeeze=True, + pixel_agg=None, + output="ndarray", + **kwargs ): """Compute B-field in [mT] for given sources and observers. @@ -41,6 +47,12 @@ def getB( which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observers input with different (pixel) shapes is allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Other Parameters (Direct interface) ----------------------------------- position: array_like, shape (3,) or (n,3), default=`(0,0,0)` @@ -84,7 +96,7 @@ def getB( Returns ------- - B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) + B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame B-field at each path position (m) for each sensor (k) and each sensor pixel position (n1, n2, ...) in units of [mT]. Sensor pixel positions are equivalent to simple observer positions. Paths of objects that are shorter than m will be @@ -153,13 +165,20 @@ def getB( sumup=sumup, squeeze=squeeze, pixel_agg=pixel_agg, + output=output, field="B", **kwargs ) def getH( - sources=None, observers=None, sumup=False, squeeze=True, pixel_agg=None, **kwargs + sources=None, + observers=None, + sumup=False, + squeeze=True, + pixel_agg=None, + output="ndarray", + **kwargs ): """Compute H-field in [kA/m] for given sources and observers. @@ -198,6 +217,12 @@ def getH( which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observer inputs with different (pixel) shapes are allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Other Parameters (Direct interface) ----------------------------------- position: array_like, shape (3,) or (n,3), default=`(0,0,0)` @@ -241,7 +266,7 @@ def getH( Returns ------- - H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) + H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame H-field at each path position (m) for each sensor (k) and each sensor pixel position (n1, n2, ...) in units of [kA/m]. Sensor pixel positions are equivalent to simple observer positions. Paths of objects that are shorter than m will be @@ -310,6 +335,7 @@ def getH( sumup=sumup, squeeze=squeeze, pixel_agg=pixel_agg, + output=output, field="H", **kwargs ) diff --git a/magpylib/_src/obj_classes/class_BaseGetBH.py b/magpylib/_src/obj_classes/class_BaseGetBH.py index bc8240be8..260758fe2 100644 --- a/magpylib/_src/obj_classes/class_BaseGetBH.py +++ b/magpylib/_src/obj_classes/class_BaseGetBH.py @@ -8,7 +8,7 @@ class BaseGetBH: """provides getB and getH methods for source objects""" - def getB(self, *observers, squeeze=True, pixel_agg=None): + def getB(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): """Compute the B-field in units of [mT] generated by the source. Parameters @@ -28,9 +28,15 @@ def getB(self, *observers, squeeze=True, pixel_agg=None): which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observers input with different (pixel) shapes is allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Returns ------- - B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) + B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame B-field at each path position (m) for each sensor (k) and each sensor pixel position (n1,n2,...) in units of [mT]. Sensor pixel positions are equivalent to simple observer positions. Paths of objects that are shorter than m will be @@ -67,10 +73,11 @@ def getB(self, *observers, squeeze=True, pixel_agg=None): sumup=False, squeeze=squeeze, pixel_agg=pixel_agg, + output=output, field="B", ) - def getH(self, *observers, squeeze=True, pixel_agg=None): + def getH(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): """Compute the H-field in units of [kA/m] generated by the source. Parameters @@ -90,9 +97,15 @@ def getH(self, *observers, squeeze=True, pixel_agg=None): which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observers input with different (pixel) shapes is allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Returns ------- - H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) + H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame H-field at each path position (m) for each sensor (k) and each sensor pixel position (n1,n2,...) in units of [kA/m]. Sensor pixel positions are equivalent to simple observer positions. Paths of objects that are shorter than m will be @@ -130,5 +143,6 @@ def getH(self, *observers, squeeze=True, pixel_agg=None): sumup=False, squeeze=squeeze, pixel_agg=pixel_agg, + output=output, field="H", ) diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 865ce786b..20218e650 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -513,7 +513,7 @@ def _validate_getBH_inputs(self, *inputs): sources, sensors = self, inputs return sources, sensors - def getB(self, *inputs, squeeze=True, pixel_agg=None): + def getB(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"): """Compute B-field in [mT] for given sources and observers. Parameters @@ -533,9 +533,15 @@ def getB(self, *inputs, squeeze=True, pixel_agg=None): which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observers input with different (pixel) shapes is allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Returns ------- - B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) + B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame B-field at each path position (m) for each sensor (k) and each sensor pixel position (n1,n2,...) in units of [mT]. Sensor pixel positions are equivalent to simple observer positions. Paths of objects that are shorter than m will be @@ -575,10 +581,11 @@ def getB(self, *inputs, squeeze=True, pixel_agg=None): sumup=False, squeeze=squeeze, pixel_agg=pixel_agg, + output=output, field="B", ) - def getH(self, *inputs, squeeze=True, pixel_agg=None): + def getH(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"): """Compute H-field in [kA/m] for given sources and observers. Parameters @@ -598,9 +605,15 @@ def getH(self, *inputs, squeeze=True, pixel_agg=None): which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observers input with different (pixel) shapes is allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Returns ------- - H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) + H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame H-field at each path position (m) for each sensor (k) and each sensor pixel position (n1,n2,...) in units of [kA/m]. Sensor pixel positions are equivalent to simple observer positions. Paths of objects that are shorter than m will be @@ -640,6 +653,7 @@ def getH(self, *inputs, squeeze=True, pixel_agg=None): sumup=False, squeeze=squeeze, pixel_agg=pixel_agg, + output=output, field="H", ) diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 26df345d4..592f9371d 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -112,7 +112,9 @@ def pixel(self, pix): sig_type="array_like (list, tuple, ndarray) with shape (n1, n2, ..., 3)", ) - def getB(self, *sources, sumup=False, squeeze=True, pixel_agg=None): + def getB( + self, *sources, sumup=False, squeeze=True, pixel_agg=None, output="ndarray" + ): """Compute the B-field in units of [mT] as seen by the sensor. Parameters @@ -133,9 +135,15 @@ def getB(self, *sources, sumup=False, squeeze=True, pixel_agg=None): which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observers input with different (pixel) shapes is allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Returns ------- - B-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) + B-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) or DataFrame B-field of each source (l) at each path position (m) and each sensor pixel position (n1,n2,...) in units of [mT]. Paths of objects that are shorter than m will be considered as static beyond their end. @@ -171,10 +179,18 @@ def getB(self, *sources, sumup=False, squeeze=True, pixel_agg=None): """ sources = format_star_input(sources) return getBH_level2( - sources, self, sumup=sumup, squeeze=squeeze, pixel_agg=pixel_agg, field="B" + sources, + self, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="B", ) - def getH(self, *sources, sumup=False, squeeze=True, pixel_agg=None): + def getH( + self, *sources, sumup=False, squeeze=True, pixel_agg=None, output="ndarray" + ): """Compute the H-field in units of [kA/m] as seen by the sensor. Parameters @@ -195,9 +211,15 @@ def getH(self, *sources, sumup=False, squeeze=True, pixel_agg=None): which is applied to observer output values, e.g. mean of all sensor pixel outputs. With this option, observers input with different (pixel) shapes is allowed. + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + Returns ------- - H-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) + H-field: ndarray, shape squeeze(l, m, n1, n2, ..., 3) or DataFrame H-field of each source (l) at each path position (m) and each sensor pixel position (n1,n2,...) in units of [kA/m]. Paths of objects that are shorter than m will be considered as static beyond their end. @@ -233,5 +255,11 @@ def getH(self, *sources, sumup=False, squeeze=True, pixel_agg=None): """ sources = format_star_input(sources) return getBH_level2( - sources, self, sumup=sumup, squeeze=squeeze, pixel_agg=pixel_agg, field="H" + sources, + self, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="H", ) From eabed095a21d8cb515675e81cb13efc6bf615bd0 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 21 Apr 2022 12:46:54 +0200 Subject: [PATCH 105/207] install .dev on circleci --- .circleci/config.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.circleci/config.yml b/.circleci/config.yml index cabc59d26..2f166df57 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -21,10 +21,10 @@ jobs: - image: python:3.9 steps: - checkout - - run: + - run: name: Install local magpylib - command: pip install . - - run: + command: pip install .[dev] + - run: name: Set up testing tools and environment command: mkdir test-results && pip install tox && pip install pylint - run: @@ -33,7 +33,7 @@ jobs: - run: name: Run code test coverage suite command: tox - + deploy: docker: - image: python:3 From a80b1365ff25f3c573d1c39b5976deb7bb194bb6 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 28 Apr 2022 16:19:38 +0200 Subject: [PATCH 106/207] code style --- magpylib/_src/fields/field_BH_line.py | 4 ++-- magpylib/_src/fields/field_wrap_BH_level1.py | 3 +-- tests/test_exceptions.py | 7 ------- tests/test_field_functions.py | 7 +++---- 4 files changed, 6 insertions(+), 15 deletions(-) diff --git a/magpylib/_src/fields/field_BH_line.py b/magpylib/_src/fields/field_BH_line.py index e0924a704..f1b0ab7bd 100644 --- a/magpylib/_src/fields/field_BH_line.py +++ b/magpylib/_src/fields/field_BH_line.py @@ -11,10 +11,10 @@ def current_vertices_field( field: str, observers: np.ndarray, current: np.ndarray, - vertices: list=None, + vertices: list = None, segment_start=None, # list of mix3 ndarrays segment_end=None, - ) -> np.ndarray: +) -> np.ndarray: """ This function accepts n (mi,3) shaped vertex-sets, creates a single long input array for field_BH_line(), computes, sums and returns a single field for each diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py index 61b6f09b6..c13885675 100644 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ b/magpylib/_src/fields/field_wrap_BH_level1.py @@ -8,10 +8,9 @@ ) from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.fields.field_BH_line import current_line_field +from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.fields.field_BH_line import field_BH_line_from_vert from magpylib._src.fields.field_BH_loop import current_loop_field -from magpylib._src.fields.field_BH_line import current_vertices_field -from magpylib._src.exceptions import MagpylibInternalError FIELD_FUNCTIONS = { "Cuboid": magnet_cuboid_field, diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 0f222c8c7..77a15b297 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -8,19 +8,12 @@ from magpylib._src.exceptions import MagpylibInternalError from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 -from magpylib._src.exceptions import ( - MagpylibInternalError, - MagpylibBadUserInput, -) -from magpylib._src.utility import format_obj_input, format_src_inputs -from magpylib._src.utility import test_path_format as tpf from magpylib._src.input_checks import check_format_input_observers from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs from magpylib._src.utility import test_path_format as tpf - def getBHv_unknown_source_type(): """unknown source type""" getBH_level2( diff --git a/tests/test_field_functions.py b/tests/test_field_functions.py index b0ef40869..90059c770 100644 --- a/tests/test_field_functions.py +++ b/tests/test_field_functions.py @@ -5,10 +5,9 @@ from magpylib._src.fields.field_BH_cylinder_segment import magnet_cylinder_segment_field from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.fields.field_BH_line import current_line_field +from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.fields.field_BH_line import field_BH_line_from_vert from magpylib._src.fields.field_BH_loop import current_loop_field -from magpylib._src.fields.field_BH_line import current_line_field -from magpylib._src.fields.field_BH_line import current_vertices_field def test_magnet_cuboid_Bfield(): @@ -308,8 +307,8 @@ def test_field_line_from_vert(): vert2 = np.array([(0, 0, 0), (3, 3, 3), (-3, 4, -5)]) vert3 = np.array([(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)]) - pos_tiled = np.tile(p, (3,1)) - B_vert = current_vertices_field('B', pos_tiled, curr, [vert1,vert2,vert3]) + pos_tiled = np.tile(p, (3, 1)) + B_vert = current_vertices_field("B", pos_tiled, curr, [vert1, vert2, vert3]) B = [] for i, vert in enumerate([vert1, vert2, vert3]): From e5f9e0fd31b04017794155b38d5e08980f3b1f6c Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 28 Apr 2022 16:58:23 +0200 Subject: [PATCH 107/207] fix tests --- magpylib/_src/fields/field_wrap_BH_level1.py | 5 ++--- magpylib/_src/fields/field_wrap_BH_level2.py | 5 +++++ tests/test_exceptions.py | 21 +++++++++++++++++++- tests/test_field_functions.py | 2 +- 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py index c13885675..2f0cd8b31 100644 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ b/magpylib/_src/fields/field_wrap_BH_level1.py @@ -7,15 +7,14 @@ magnet_cylinder_segment_field_internal, ) from magpylib._src.fields.field_BH_dipole import dipole_field -from magpylib._src.fields.field_BH_line import current_line_field from magpylib._src.fields.field_BH_line import current_vertices_field -from magpylib._src.fields.field_BH_line import field_BH_line_from_vert from magpylib._src.fields.field_BH_loop import current_loop_field +from magpylib._src.fields.field_BH_sphere import magnet_sphere_field FIELD_FUNCTIONS = { "Cuboid": magnet_cuboid_field, "Cylinder": magnet_cylinder_field, - "CylinderSegment": magnet_cylinder_segment_field, + "CylinderSegment": magnet_cylinder_segment_field_internal, "Sphere": magnet_sphere_field, "Dipole": dipole_field, "Loop": current_loop_field, diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index cbd83dc33..37023b6d1 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -108,6 +108,11 @@ def getBH_level2( which applies on pixel output values. field : {'B', 'H'} 'B' computes B field, 'H' computes H-field + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). Returns ------- diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 77a15b297..0b7f3e428 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -25,6 +25,7 @@ def getBHv_unknown_source_type(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", field="B", ) @@ -49,7 +50,13 @@ def getBH_level2_bad_input1(): src = magpy.magnet.Cuboid((1, 1, 2), (1, 1, 1)) sens = magpy.Sensor() getBH_level2( - [src, sens], (0, 0, 0), sumup=False, squeeze=True, pixel_agg=None, field="B" + [src, sens], + (0, 0, 0), + sumup=False, + squeeze=True, + pixel_agg=None, + field="B", + output="ndarray", ) @@ -83,6 +90,7 @@ def getBHv_missing_input1(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -97,6 +105,7 @@ def getBHv_missing_input2(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -111,6 +120,7 @@ def getBHv_missing_input3(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -125,6 +135,7 @@ def getBHv_missing_input4_cuboid(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -139,6 +150,7 @@ def getBHv_missing_input5_cuboid(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -154,6 +166,7 @@ def getBHv_missing_input4_cyl(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -168,6 +181,7 @@ def getBHv_missing_input5_cyl(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -182,6 +196,7 @@ def getBHv_missing_input4_sphere(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -196,6 +211,7 @@ def getBHv_missing_input5_sphere(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -213,6 +229,7 @@ def getBHv_bad_input1(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -228,6 +245,7 @@ def getBHv_bad_input2(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) @@ -244,6 +262,7 @@ def getBHv_bad_input3(): sumup=False, squeeze=True, pixel_agg=None, + output="ndarray", ) diff --git a/tests/test_field_functions.py b/tests/test_field_functions.py index 90059c770..fd0d0fc9c 100644 --- a/tests/test_field_functions.py +++ b/tests/test_field_functions.py @@ -6,8 +6,8 @@ from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.fields.field_BH_line import current_line_field from magpylib._src.fields.field_BH_line import current_vertices_field -from magpylib._src.fields.field_BH_line import field_BH_line_from_vert from magpylib._src.fields.field_BH_loop import current_loop_field +from magpylib._src.fields.field_BH_sphere import magnet_sphere_field def test_magnet_cuboid_Bfield(): From 09432ab4b67538c718395f88113b0f3718935e72 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 28 Apr 2022 17:08:21 +0200 Subject: [PATCH 108/207] docstring --- magpylib/_src/fields/field_wrap_BH_level2.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 531dd46ae..c08472662 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -106,6 +106,11 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: which applies on pixel output values. field : {'B', 'H'} 'B' computes B field, 'H' computes H-field + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). Returns ------- From 234b1319ea8bd40b0b2d915f80db1b6224084a96 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 7 May 2022 20:46:46 +0200 Subject: [PATCH 109/207] move getBHdict module into level2 getBH --- magpylib/_src/fields/field_wrap_BH_level2.py | 110 ++++++++++++++++- .../_src/fields/field_wrap_BH_level2_dict.py | 115 ------------------ 2 files changed, 109 insertions(+), 116 deletions(-) delete mode 100644 magpylib/_src/fields/field_wrap_BH_level2_dict.py diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 37023b6d1..f127a6938 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -6,7 +6,6 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibInternalError from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 -from magpylib._src.fields.field_wrap_BH_level2_dict import getBH_dict_level2 from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_observers @@ -15,6 +14,21 @@ from magpylib._src.utility import check_static_sensor_orient from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs +from magpylib._src.utility import LIBRARY_BH_DICT_SOURCE_STRINGS + + +PARAM_TILE_DIMS = { + "observers": 2, + "position": 2, + "orientation": 2, + "magnetization": 2, + "current": 1, + "moment": 2, + "dimension": 2, + "diameter": 1, + "segment_start": 2, + "segment_end": 2, +} def tile_group_property(group: list, n_pp: int, prop_name: str): @@ -343,3 +357,97 @@ def getBH_level2( B = np.expand_dims(B, axis=-2) return B + + +def getBH_dict_level2( + source_type, + observers, + *, + field: str, + position=(0, 0, 0), + orientation=R.identity(), + squeeze=True, + **kwargs: dict, +) -> np.ndarray: + """Direct interface access to vectorized computation + + Parameters + ---------- + kwargs: dict that describes the computation. + + Returns + ------- + field: ndarray, shape (N,3), field at obs_pos in [mT] or [kA/m] + + Info + ---- + - check inputs + + - secures input types (list/tuple -> ndarray) + - test if mandatory inputs are there + - sets default input variables (e.g. pos, rot) if missing + - tiles 1D inputs vectors to correct dimension + """ + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + + # generate dict of secured inputs for auto-tiling --------------- + # entries in this dict will be tested for input length, and then + # be automatically tiled up and stored back into kwargs for calling + # getBH_level1(). + # To allow different input dimensions, the tdim argument is also given + # which tells the program which dimension it should tile up. + + if source_type not in LIBRARY_BH_DICT_SOURCE_STRINGS: + raise MagpylibBadUserInput( + f"Input parameter `sources` must be one of {LIBRARY_BH_DICT_SOURCE_STRINGS}" + " when using the direct interface." + ) + + kwargs["observers"] = observers + kwargs["position"] = position + + # change orientation to Rotation numpy array for tiling + kwargs["orientation"] = orientation.as_quat() + + # evaluation vector lengths + vec_lengths = [] + for key, val in kwargs.items(): + try: + val = np.array(val, dtype=float) + except TypeError as err: + raise MagpylibBadUserInput( + f"{key} input must be array-like.\n" f"Instead received {val}" + ) from err + tdim = PARAM_TILE_DIMS.get(key, 1) + if val.ndim == tdim: + vec_lengths.append(len(val)) + kwargs[key] = val + + if len(set(vec_lengths)) > 1: + raise MagpylibBadUserInput( + "Input array lengths must be 1 or of a similar length.\n" + f"Instead received {set(vec_lengths)}" + ) + vec_len = max(vec_lengths, default=1) + + # tile 1D inputs and replace original values in kwargs + for key, val in kwargs.items(): + tdim = PARAM_TILE_DIMS.get(key, 1) + if val.ndim < tdim: + if tdim == 2: + kwargs[key] = np.tile(val, (vec_len, 1)) + elif tdim == 1: + kwargs[key] = np.array([val] * vec_len) + else: + kwargs[key] = val + + # change orientation back to Rotation object + kwargs["orientation"] = R.from_quat(kwargs["orientation"]) + + # compute and return B + B = getBH_level1(source_type=source_type, field=field, **kwargs) + + if squeeze: + return np.squeeze(B) + return B diff --git a/magpylib/_src/fields/field_wrap_BH_level2_dict.py b/magpylib/_src/fields/field_wrap_BH_level2_dict.py deleted file mode 100644 index 53c3e4232..000000000 --- a/magpylib/_src/fields/field_wrap_BH_level2_dict.py +++ /dev/null @@ -1,115 +0,0 @@ -""" getBHv wrapper codes""" -import numpy as np -from scipy.spatial.transform import Rotation as R - -from magpylib._src.exceptions import MagpylibBadUserInput -from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 -from magpylib._src.utility import LIBRARY_BH_DICT_SOURCE_STRINGS - - -PARAM_TILE_DIMS = { - "observers": 2, - "position": 2, - "orientation": 2, - "magnetization": 2, - "current": 1, - "moment": 2, - "dimension": 2, - "diameter": 1, - "segment_start": 2, - "segment_end": 2, -} - - -def getBH_dict_level2( - source_type, - observers, - *, - field: str, - position=(0, 0, 0), - orientation=R.identity(), - squeeze=True, - **kwargs: dict, -) -> np.ndarray: - """Direct interface access to vectorized computation - - Parameters - ---------- - kwargs: dict that describes the computation. - - Returns - ------- - field: ndarray, shape (N,3), field at obs_pos in [mT] or [kA/m] - - Info - ---- - - check inputs - - - secures input types (list/tuple -> ndarray) - - test if mandatory inputs are there - - sets default input variables (e.g. pos, rot) if missing - - tiles 1D inputs vectors to correct dimension - """ - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - - # generate dict of secured inputs for auto-tiling --------------- - # entries in this dict will be tested for input length, and then - # be automatically tiled up and stored back into kwargs for calling - # getBH_level1(). - # To allow different input dimensions, the tdim argument is also given - # which tells the program which dimension it should tile up. - - if source_type not in LIBRARY_BH_DICT_SOURCE_STRINGS: - raise MagpylibBadUserInput( - f"Input parameter `sources` must be one of {LIBRARY_BH_DICT_SOURCE_STRINGS}" - " when using the direct interface." - ) - - kwargs["observers"] = observers - kwargs["position"] = position - - # change orientation to Rotation numpy array for tiling - kwargs["orientation"] = orientation.as_quat() - - # evaluation vector lengths - vec_lengths = [] - for key, val in kwargs.items(): - try: - val = np.array(val, dtype=float) - except TypeError as err: - raise MagpylibBadUserInput( - f"{key} input must be array-like.\n" f"Instead received {val}" - ) from err - tdim = PARAM_TILE_DIMS.get(key, 1) - if val.ndim == tdim: - vec_lengths.append(len(val)) - kwargs[key] = val - - if len(set(vec_lengths)) > 1: - raise MagpylibBadUserInput( - "Input array lengths must be 1 or of a similar length.\n" - f"Instead received {set(vec_lengths)}" - ) - vec_len = max(vec_lengths, default=1) - - # tile 1D inputs and replace original values in kwargs - for key, val in kwargs.items(): - tdim = PARAM_TILE_DIMS.get(key, 1) - if val.ndim < tdim: - if tdim == 2: - kwargs[key] = np.tile(val, (vec_len, 1)) - elif tdim == 1: - kwargs[key] = np.array([val] * vec_len) - else: - kwargs[key] = val - - # change orientation back to Rotation object - kwargs["orientation"] = R.from_quat(kwargs["orientation"]) - - # compute and return B - B = getBH_level1(source_type=source_type, field=field, **kwargs) - - if squeeze: - return np.squeeze(B) - return B From 056de0cf3566ea0ed2f9a956200d8eeeb9dc73a2 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 7 May 2022 22:07:59 +0200 Subject: [PATCH 110/207] refactoring --- magpylib/_src/fields/field_wrap_BH_level2.py | 44 +++++++++----------- 1 file changed, 19 insertions(+), 25 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index f127a6938..8635897eb 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -30,6 +30,17 @@ "segment_end": 2, } +SOURCE_PROPERTIES = { + "Cuboid": ("magnetization", "dimension"), + "Cylinder": ("magnetization", "dimension"), + "CylinderSegment": ("magnetization", "dimension"), + "Sphere": ("magnetization", "diameter"), + "Dipole": ("moment",), + "Loop": ("current", "diameter"), + "Line": ("current", "vertices"), + "CustomSource": (), +} + def tile_group_property(group: list, n_pp: int, prop_name: str): """tile up group property""" @@ -55,7 +66,7 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: # pos_obs posov = np.tile(poso, (len(group), 1)) - # determine which group we are dealing with and tile up dim and excitation + # determine which group we are dealing with and tile up properties src_type = group[0]._object_type kwargs = { @@ -65,37 +76,20 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: "orientation": rotobj, } - if src_type in ("Sphere", "Cuboid", "Cylinder", "CylinderSegment"): - magv = tile_group_property(group, n_pp, "magnetization") - kwargs.update(magnetization=magv) - if src_type == "Sphere": - diav = tile_group_property(group, n_pp, "diameter") - kwargs.update(diameter=diav) - else: - dimv = tile_group_property(group, n_pp, "dimension") - kwargs.update(dimension=dimv) - - elif src_type == "Dipole": - momv = tile_group_property(group, n_pp, "moment") - kwargs.update({"moment": momv}) + try: + src_props = SOURCE_PROPERTIES[src_type] + except KeyError as err: + raise MagpylibInternalError("Bad source_type in get_src_dict") from err - elif src_type == "Loop": - currv = tile_group_property(group, n_pp, "current") - diav = tile_group_property(group, n_pp, "diameter") - kwargs.update({"current": currv, "diameter": diav}) - - elif src_type == "Line": - # get_BH_line_from_vert function tiles internally ! - # currv = tile_current(group, n_pp) + if src_type == "Line": # get_BH_line_from_vert function tiles internally ! currv = np.array([src.current for src in group]) vert_list = [src.vertices for src in group] kwargs.update({"current": currv, "vertices": vert_list}) - elif src_type == "CustomSource": kwargs.update(field_func=group[0].field_func) - else: - raise MagpylibInternalError("Bad source_type in get_src_dict") + for prop in src_props: + kwargs[prop] = tile_group_property(group, n_pp, prop) return kwargs From 689ba02acbeec5a81e807ef586614515a215e0d5 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 17 Jun 2022 09:02:33 +0200 Subject: [PATCH 111/207] move matplotlib folder --- magpylib/_src/display/display.py | 2 +- .../{display_matplotlib.py => matplotlib/matplotlib_display.py} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename magpylib/_src/display/{display_matplotlib.py => matplotlib/matplotlib_display.py} (100%) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 945463bfe..5cd5d5e95 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -1,7 +1,7 @@ """ Display function codes""" import warnings -from magpylib._src.display.display_matplotlib import display_matplotlib +from magpylib._src.display.matplotlib.matplotlib_display import display_matplotlib from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_backend diff --git a/magpylib/_src/display/display_matplotlib.py b/magpylib/_src/display/matplotlib/matplotlib_display.py similarity index 100% rename from magpylib/_src/display/display_matplotlib.py rename to magpylib/_src/display/matplotlib/matplotlib_display.py From 9344e9a530a927171312b2699b5b8cb49dc7cfff Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 17 Jun 2022 09:02:58 +0200 Subject: [PATCH 112/207] remove double input checks --- magpylib/_src/display/display.py | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 5cd5d5e95..c5cb1a0b5 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -121,17 +121,6 @@ def show( allow_None=True, ) - check_input_zoom(zoom) - check_input_animation(animation) - check_format_input_vector( - markers, - dims=(2,), - shape_m1=3, - sig_name="markers", - sig_type="array_like of shape (n,3)", - allow_None=True, - ) - if backend == "matplotlib": if animation is not False: msg = "The matplotlib backend does not support animation at the moment.\n" From cd05dca9ae488ddb2d5cb433b3eae77cd1ed2ffe Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 18 Jun 2022 19:34:52 +0200 Subject: [PATCH 113/207] refactoring --- .../plotly_display.py => backend_generic.py} | 405 ++---------------- ...otlib_display.py => backend_matplotlib.py} | 0 magpylib/_src/display/backend_plotly.py | 353 +++++++++++++++ magpylib/_src/display/base_traces.py | 2 +- magpylib/_src/display/display.py | 4 +- magpylib/_src/display/display_utility.py | 145 +++++++ magpylib/_src/display/plotly/__init__.py | 1 - .../_src/display/plotly/plotly_utility.py | 147 ------- .../plotly_sensor_mesh.py => sensor_mesh.py} | 0 tests/test_display_plotly.py | 8 +- 10 files changed, 533 insertions(+), 532 deletions(-) rename magpylib/_src/display/{plotly/plotly_display.py => backend_generic.py} (68%) rename magpylib/_src/display/{matplotlib/matplotlib_display.py => backend_matplotlib.py} (100%) create mode 100644 magpylib/_src/display/backend_plotly.py delete mode 100644 magpylib/_src/display/plotly/__init__.py delete mode 100644 magpylib/_src/display/plotly/plotly_utility.py rename magpylib/_src/display/{plotly/plotly_sensor_mesh.py => sensor_mesh.py} (100%) diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/backend_generic.py similarity index 68% rename from magpylib/_src/display/plotly/plotly_display.py rename to magpylib/_src/display/backend_generic.py index 1a06a5427..5a84c6cc2 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/backend_generic.py @@ -1,59 +1,39 @@ """ plotly draw-functionalities""" # pylint: disable=C0302 # pylint: disable=too-many-branches -import numbers -import warnings from itertools import combinations from typing import Tuple -try: - import plotly.graph_objects as go -except ImportError as missing_module: # pragma: no cover - raise ModuleNotFoundError( - """In order to use the plotly plotting backend, you need to install plotly via pip or conda, - see https://github.com/plotly/plotly.py""" - ) from missing_module - import numpy as np from scipy.spatial.transform import Rotation as RotScipy + from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.plotly.plotly_sensor_mesh import get_sensor_mesh -from magpylib._src.style import ( - get_style, - LINESTYLES_MATPLOTLIB_TO_PLOTLY, - SYMBOLS_MATPLOTLIB_TO_PLOTLY, -) -from magpylib._src.display.display_utility import ( - get_rot_pos_from_path, - MagpyMarkers, - draw_arrow_from_vertices, - draw_arrowed_circle, - place_and_orient_model3d, - get_flatten_objects_properties, -) -from magpylib._src.defaults.defaults_utility import ( - SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY, - linearize_dict, -) - -from magpylib._src.input_checks import check_excitations -from magpylib._src.utility import unit_prefix, format_obj_input +from magpylib._src.defaults.defaults_utility import linearize_dict +from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY +from magpylib._src.display.base_traces import make_Arrow as make_BaseArrow +from magpylib._src.display.base_traces import make_Cuboid as make_BaseCuboid from magpylib._src.display.base_traces import ( - make_Cuboid as make_BaseCuboid, make_CylinderSegment as make_BaseCylinderSegment, - make_Ellipsoid as make_BaseEllipsoid, - make_Prism as make_BasePrism, - # make_Pyramid as make_BasePyramid, - make_Arrow as make_BaseArrow, -) -from magpylib._src.display.plotly.plotly_utility import ( - merge_mesh3d, - merge_traces, - getColorscale, - getIntensity, - clean_legendgroups, ) +from magpylib._src.display.base_traces import make_Ellipsoid as make_BaseEllipsoid +from magpylib._src.display.base_traces import make_Prism as make_BasePrism +from magpylib._src.display.display_utility import draw_arrow_from_vertices +from magpylib._src.display.display_utility import draw_arrowed_circle +from magpylib._src.display.display_utility import get_flatten_objects_properties +from magpylib._src.display.display_utility import get_rot_pos_from_path +from magpylib._src.display.display_utility import getColorscale +from magpylib._src.display.display_utility import getIntensity +from magpylib._src.display.display_utility import MagpyMarkers +from magpylib._src.display.display_utility import merge_mesh3d +from magpylib._src.display.display_utility import merge_traces +from magpylib._src.display.display_utility import place_and_orient_model3d +from magpylib._src.display.sensor_mesh import get_sensor_mesh +from magpylib._src.input_checks import check_excitations +from magpylib._src.style import get_style +from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY +from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY +from magpylib._src.utility import unit_prefix def make_Line( @@ -481,7 +461,7 @@ def get_name_and_suffix(default_name, default_suffix, style): return name, name_suffix -def get_plotly_traces( +def get_generic_traces( input_obj, color=None, autosize=None, @@ -544,7 +524,8 @@ def get_plotly_traces( default_name = "Marker" if len(x) == 1 else "Markers" default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" name, name_suffix = get_name_and_suffix(default_name, default_suffix, style) - trace = go.Scatter3d( + trace = dict( + type="scatter3d", name=f"{name}{name_suffix}", x=x, y=y, @@ -760,7 +741,7 @@ def draw_frame( x, y, z = obj._position.T traces_out[obj] = [dict(x=x, y=y, z=z)] else: - traces_out[obj] = get_plotly_traces(obj, **params) + traces_out[obj] = get_generic_traces(obj, **params) traces = [t for tr in traces_out.values() for t in tr] ranges = get_scene_ranges(*traces, zoom=zoom) if autosize is None or autosize == "return": @@ -768,7 +749,7 @@ def draw_frame( return_autosize = True autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor for obj, params in traces_to_resize.items(): - traces_out[obj] = get_plotly_traces(obj, autosize=autosize, **params) + traces_out[obj] = get_generic_traces(obj, autosize=autosize, **params) if output == "list": traces = [t for tr in traces_out.values() for t in tr] traces_out = group_traces(*traces) @@ -864,333 +845,3 @@ def get_scene_ranges(*traces, zoom=1) -> np.ndarray: else: ranges = np.array([[-1.0, 1.0]] * 3) return ranges - - -def animate_path( - fig, - objs, - color_sequence=None, - zoom=1, - title="3D-Paths Animation", - animation_time=3, - animation_fps=30, - animation_maxfps=50, - animation_maxframes=200, - animation_slider=False, - **kwargs, -): - """This is a helper function which attaches plotly frames to the provided `fig` object - according to a certain zoom level. All three space direction will be equal and match the - maximum of the ranges needed to display all objects, including their paths. - - Parameters - ---------- - animation_time: float, default = 3 - Sets the animation duration - - animation_fps: float, default = 30 - This sets the maximum allowed frame rate. In case of path positions needed to be displayed - exceeds the `animation_fps` the path position will be downsampled to be lower or equal - the `animation_fps`. This is mainly depending on the pc/browser performance and is set to - 50 by default to avoid hanging the animation process. - - animation_slider: bool, default = False - if True, an interactive slider will be displayed and stay in sync with the animation - - title: str, default = "3D-Paths Animation" - When zoom=0 all objects are just inside the 3D-axes. - - color_sequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - - Returns - ------- - None: NoneTyp - """ - # make sure the number of frames does not exceed the max frames and max frame rate - # downsample if necessary - path_lengths = [] - for obj in objs: - subobjs = [obj] - if getattr(obj, "_object_type", None) == "Collection": - subobjs.extend(obj.children) - for subobj in subobjs: - path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] - path_lengths.append(path_len) - - max_pl = max(path_lengths) - if animation_fps > animation_maxfps: - warnings.warn( - f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" - f" {animation_maxfps}. `animation_fps` will be set to {animation_maxfps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxfps`" - ) - animation_fps = animation_maxfps - - maxpos = min(animation_time * animation_fps, animation_maxframes) - - if max_pl <= maxpos: - path_indices = np.arange(max_pl) - else: - round_step = max_pl / (maxpos - 1) - ar = np.linspace(0, max_pl, max_pl, endpoint=False) - path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( - int - ) # downsampled indices - path_indices[-1] = ( - max_pl - 1 - ) # make sure the last frame is the last path position - - # calculate exponent of last frame index to avoid digit shift in - # frame number display during animation - exp = ( - np.log10(path_indices.max()).astype(int) + 1 - if path_indices.ndim != 0 and path_indices.max() > 0 - else 1 - ) - - frame_duration = int(animation_time * 1000 / path_indices.shape[0]) - new_fps = int(1000 / frame_duration) - if max_pl > animation_maxframes: - warnings.warn( - f"The number of frames ({max_pl}) is greater than the max allowed " - f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxframes`" - ) - - if animation_slider: - sliders_dict = { - "active": 0, - "yanchor": "top", - "font": {"size": 10}, - "xanchor": "left", - "currentvalue": { - "prefix": f"Fps={new_fps}, Path index: ", - "visible": True, - "xanchor": "right", - }, - "pad": {"b": 10, "t": 10}, - "len": 0.9, - "x": 0.1, - "y": 0, - "steps": [], - } - - buttons_dict = { - "buttons": [ - { - "args": [ - None, - { - "frame": {"duration": frame_duration}, - "transition": {"duration": 0}, - "fromcurrent": True, - }, - ], - "label": "Play", - "method": "animate", - }, - { - "args": [[None], {"frame": {"duration": 0}, "mode": "immediate"}], - "label": "Pause", - "method": "animate", - }, - ], - "direction": "left", - "pad": {"r": 10, "t": 20}, - "showactive": False, - "type": "buttons", - "x": 0.1, - "xanchor": "right", - "y": 0, - "yanchor": "top", - } - - # create frame for each path index or downsampled path index - frames = [] - autosize = "return" - for i, ind in enumerate(path_indices): - kwargs["style_path_frames"] = [ind] - frame = draw_frame( - objs, - color_sequence, - zoom, - autosize=autosize, - output="list", - **kwargs, - ) - if i == 0: # get the dipoles and sensors autosize from first frame - traces, autosize = frame - else: - traces = frame - frames.append( - go.Frame( - data=traces, - name=str(ind + 1), - layout=dict(title=f"""{title} - path index: {ind+1:0{exp}d}"""), - ) - ) - if animation_slider: - slider_step = { - "args": [ - [str(ind + 1)], - { - "frame": {"duration": 0, "redraw": True}, - "mode": "immediate", - }, - ], - "label": str(ind + 1), - "method": "animate", - } - sliders_dict["steps"].append(slider_step) - - # update fig - fig.frames = frames - fig.add_traces(frames[0].data) - fig.update_layout( - height=None, - title=title, - updatemenus=[buttons_dict], - sliders=[sliders_dict] if animation_slider else None, - ) - apply_fig_ranges(fig, zoom=zoom) - - -def display_plotly( - *obj_list, - markers=None, - zoom=1, - fig=None, - renderer=None, - animation=False, - color_sequence=None, - **kwargs, -): - - """ - Display objects and paths graphically using the plotly library. - - Parameters - ---------- - objects: sources, collections or sensors - Objects to be displayed. - - markers: array_like, None, shape (N,3), default=None - Display position markers in the global CS. By default no marker is displayed. - - zoom: float, default = 1 - Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. - - fig: plotly Figure, default=None - Display graphical output in a given figure: - - plotly.graph_objects.Figure - - plotly.graph_objects.FigureWidget - By default a new `Figure` is created and displayed. - - renderer: str. default=None, - The renderers framework is a flexible approach for displaying plotly.py figures in a variety - of contexts. - Available renderers are: - ['plotly_mimetype', 'jupyterlab', 'nteract', 'vscode', - 'notebook', 'notebook_connected', 'kaggle', 'azure', 'colab', - 'cocalc', 'databricks', 'json', 'png', 'jpeg', 'jpg', 'svg', - 'pdf', 'browser', 'firefox', 'chrome', 'chromium', 'iframe', - 'iframe_connected', 'sphinx_gallery', 'sphinx_gallery_png'] - - title: str, default = "3D-Paths Animation" - When zoom=0 all objects are just inside the 3D-axes. - - color_sequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - - Returns - ------- - None: NoneType - """ - - flat_obj_list = format_obj_input(obj_list) - - show_fig = False - if fig is None: - show_fig = True - fig = go.Figure() - - # set animation and animation_time - if isinstance(animation, numbers.Number) and not isinstance(animation, bool): - kwargs["animation_time"] = animation - animation = True - if ( - not any( - getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list - ) - and animation is not False - ): # check if some path exist for any object - animation = False - warnings.warn("No path to be animated detected, displaying standard plot") - - animation_kwargs = { - k: v for k, v in kwargs.items() if k.split("_")[0] == "animation" - } - if animation is False: - kwargs = {k: v for k, v in kwargs.items() if k not in animation_kwargs} - else: - for k, v in Config.display.animation.as_dict().items(): - anim_key = f"animation_{k}" - if kwargs.get(anim_key, None) is None: - kwargs[anim_key] = v - - if obj_list: - style = getattr(obj_list[0], "style", None) - label = getattr(style, "label", None) - title = label if len(obj_list) == 1 else None - else: - title = "No objects to be displayed" - - if markers is not None and markers: - obj_list = list(obj_list) + [MagpyMarkers(*markers)] - - if color_sequence is None: - color_sequence = Config.display.colorsequence - - with fig.batch_update(): - if animation is not False: - title = "3D-Paths Animation" if title is None else title - animate_path( - fig=fig, - objs=obj_list, - color_sequence=color_sequence, - zoom=zoom, - title=title, - **kwargs, - ) - else: - traces = draw_frame(obj_list, color_sequence, zoom, output="list", **kwargs) - fig.add_traces(traces) - fig.update_layout(title_text=title) - apply_fig_ranges(fig, zoom=zoom) - clean_legendgroups(fig) - fig.update_layout(legend_itemsizing="constant") - if show_fig: - fig.show(renderer=renderer) diff --git a/magpylib/_src/display/matplotlib/matplotlib_display.py b/magpylib/_src/display/backend_matplotlib.py similarity index 100% rename from magpylib/_src/display/matplotlib/matplotlib_display.py rename to magpylib/_src/display/backend_matplotlib.py diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py new file mode 100644 index 000000000..8f645b3da --- /dev/null +++ b/magpylib/_src/display/backend_plotly.py @@ -0,0 +1,353 @@ +""" plotly draw-functionalities""" +# pylint: disable=C0302 +# pylint: disable=too-many-branches +import numbers +import warnings + +try: + import plotly.graph_objects as go +except ImportError as missing_module: # pragma: no cover + raise ModuleNotFoundError( + """In order to use the plotly plotting backend, you need to install plotly via pip or conda, + see https://github.com/plotly/plotly.py""" + ) from missing_module + +import numpy as np +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.utility import format_obj_input +from magpylib._src.display.display_utility import clean_legendgroups +from magpylib._src.display.backend_generic import ( + draw_frame, + apply_fig_ranges, + MagpyMarkers, +) + + +def animate_path( + fig, + objs, + color_sequence=None, + zoom=1, + title="3D-Paths Animation", + animation_time=3, + animation_fps=30, + animation_maxfps=50, + animation_maxframes=200, + animation_slider=False, + **kwargs, +): + """This is a helper function which attaches plotly frames to the provided `fig` object + according to a certain zoom level. All three space direction will be equal and match the + maximum of the ranges needed to display all objects, including their paths. + + Parameters + ---------- + animation_time: float, default = 3 + Sets the animation duration + + animation_fps: float, default = 30 + This sets the maximum allowed frame rate. In case of path positions needed to be displayed + exceeds the `animation_fps` the path position will be downsampled to be lower or equal + the `animation_fps`. This is mainly depending on the pc/browser performance and is set to + 50 by default to avoid hanging the animation process. + + animation_slider: bool, default = False + if True, an interactive slider will be displayed and stay in sync with the animation + + title: str, default = "3D-Paths Animation" + When zoom=0 all objects are just inside the 3D-axes. + + color_sequence: list or array_like, iterable, default= + ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', + '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', + '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', + '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] + An iterable of color values used to cycle trough for every object displayed. + A color and may be specified as: + - A hex string (e.g. '#ff0000') + - An rgb/rgba string (e.g. 'rgb(255,0,0)') + - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') + - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') + - A named CSS color + + Returns + ------- + None: NoneTyp + """ + # make sure the number of frames does not exceed the max frames and max frame rate + # downsample if necessary + path_lengths = [] + for obj in objs: + subobjs = [obj] + if getattr(obj, "_object_type", None) == "Collection": + subobjs.extend(obj.children) + for subobj in subobjs: + path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] + path_lengths.append(path_len) + + max_pl = max(path_lengths) + if animation_fps > animation_maxfps: + warnings.warn( + f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" + f" {animation_maxfps}. `animation_fps` will be set to {animation_maxfps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxfps`" + ) + animation_fps = animation_maxfps + + maxpos = min(animation_time * animation_fps, animation_maxframes) + + if max_pl <= maxpos: + path_indices = np.arange(max_pl) + else: + round_step = max_pl / (maxpos - 1) + ar = np.linspace(0, max_pl, max_pl, endpoint=False) + path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( + int + ) # downsampled indices + path_indices[-1] = ( + max_pl - 1 + ) # make sure the last frame is the last path position + + # calculate exponent of last frame index to avoid digit shift in + # frame number display during animation + exp = ( + np.log10(path_indices.max()).astype(int) + 1 + if path_indices.ndim != 0 and path_indices.max() > 0 + else 1 + ) + + frame_duration = int(animation_time * 1000 / path_indices.shape[0]) + new_fps = int(1000 / frame_duration) + if max_pl > animation_maxframes: + warnings.warn( + f"The number of frames ({max_pl}) is greater than the max allowed " + f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxframes`" + ) + + if animation_slider: + sliders_dict = { + "active": 0, + "yanchor": "top", + "font": {"size": 10}, + "xanchor": "left", + "currentvalue": { + "prefix": f"Fps={new_fps}, Path index: ", + "visible": True, + "xanchor": "right", + }, + "pad": {"b": 10, "t": 10}, + "len": 0.9, + "x": 0.1, + "y": 0, + "steps": [], + } + + buttons_dict = { + "buttons": [ + { + "args": [ + None, + { + "frame": {"duration": frame_duration}, + "transition": {"duration": 0}, + "fromcurrent": True, + }, + ], + "label": "Play", + "method": "animate", + }, + { + "args": [[None], {"frame": {"duration": 0}, "mode": "immediate"}], + "label": "Pause", + "method": "animate", + }, + ], + "direction": "left", + "pad": {"r": 10, "t": 20}, + "showactive": False, + "type": "buttons", + "x": 0.1, + "xanchor": "right", + "y": 0, + "yanchor": "top", + } + + # create frame for each path index or downsampled path index + frames = [] + autosize = "return" + for i, ind in enumerate(path_indices): + kwargs["style_path_frames"] = [ind] + frame = draw_frame( + objs, + color_sequence, + zoom, + autosize=autosize, + output="list", + **kwargs, + ) + if i == 0: # get the dipoles and sensors autosize from first frame + traces, autosize = frame + else: + traces = frame + frames.append( + go.Frame( + data=traces, + name=str(ind + 1), + layout=dict(title=f"""{title} - path index: {ind+1:0{exp}d}"""), + ) + ) + if animation_slider: + slider_step = { + "args": [ + [str(ind + 1)], + { + "frame": {"duration": 0, "redraw": True}, + "mode": "immediate", + }, + ], + "label": str(ind + 1), + "method": "animate", + } + sliders_dict["steps"].append(slider_step) + + # update fig + fig.frames = frames + fig.add_traces(frames[0].data) + fig.update_layout( + height=None, + title=title, + updatemenus=[buttons_dict], + sliders=[sliders_dict] if animation_slider else None, + ) + apply_fig_ranges(fig, zoom=zoom) + + +def display_plotly( + *obj_list, + markers=None, + zoom=1, + fig=None, + renderer=None, + animation=False, + color_sequence=None, + **kwargs, +): + + """ + Display objects and paths graphically using the plotly library. + + Parameters + ---------- + objects: sources, collections or sensors + Objects to be displayed. + + markers: array_like, None, shape (N,3), default=None + Display position markers in the global CS. By default no marker is displayed. + + zoom: float, default = 1 + Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. + + fig: plotly Figure, default=None + Display graphical output in a given figure: + - plotly.graph_objects.Figure + - plotly.graph_objects.FigureWidget + By default a new `Figure` is created and displayed. + + renderer: str. default=None, + The renderers framework is a flexible approach for displaying plotly.py figures in a variety + of contexts. + Available renderers are: + ['plotly_mimetype', 'jupyterlab', 'nteract', 'vscode', + 'notebook', 'notebook_connected', 'kaggle', 'azure', 'colab', + 'cocalc', 'databricks', 'json', 'png', 'jpeg', 'jpg', 'svg', + 'pdf', 'browser', 'firefox', 'chrome', 'chromium', 'iframe', + 'iframe_connected', 'sphinx_gallery', 'sphinx_gallery_png'] + + title: str, default = "3D-Paths Animation" + When zoom=0 all objects are just inside the 3D-axes. + + color_sequence: list or array_like, iterable, default= + ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', + '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', + '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', + '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] + An iterable of color values used to cycle trough for every object displayed. + A color and may be specified as: + - A hex string (e.g. '#ff0000') + - An rgb/rgba string (e.g. 'rgb(255,0,0)') + - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') + - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') + - A named CSS color + + Returns + ------- + None: NoneType + """ + + flat_obj_list = format_obj_input(obj_list) + + show_fig = False + if fig is None: + show_fig = True + fig = go.Figure() + + # set animation and animation_time + if isinstance(animation, numbers.Number) and not isinstance(animation, bool): + kwargs["animation_time"] = animation + animation = True + if ( + not any( + getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list + ) + and animation is not False + ): # check if some path exist for any object + animation = False + warnings.warn("No path to be animated detected, displaying standard plot") + + animation_kwargs = { + k: v for k, v in kwargs.items() if k.split("_")[0] == "animation" + } + if animation is False: + kwargs = {k: v for k, v in kwargs.items() if k not in animation_kwargs} + else: + for k, v in Config.display.animation.as_dict().items(): + anim_key = f"animation_{k}" + if kwargs.get(anim_key, None) is None: + kwargs[anim_key] = v + + if obj_list: + style = getattr(obj_list[0], "style", None) + label = getattr(style, "label", None) + title = label if len(obj_list) == 1 else None + else: + title = "No objects to be displayed" + + if markers is not None and markers: + obj_list = list(obj_list) + [MagpyMarkers(*markers)] + + if color_sequence is None: + color_sequence = Config.display.colorsequence + + with fig.batch_update(): + if animation is not False: + title = "3D-Paths Animation" if title is None else title + animate_path( + fig=fig, + objs=obj_list, + color_sequence=color_sequence, + zoom=zoom, + title=title, + **kwargs, + ) + else: + traces = draw_frame(obj_list, color_sequence, zoom, output="list", **kwargs) + fig.add_traces(traces) + fig.update_layout(title_text=title) + apply_fig_ranges(fig, zoom=zoom) + clean_legendgroups(fig) + fig.update_layout(legend_itemsizing="constant") + if show_fig: + fig.show(renderer=renderer) diff --git a/magpylib/_src/display/base_traces.py b/magpylib/_src/display/base_traces.py index 3d74f342f..0487690b1 100644 --- a/magpylib/_src/display/base_traces.py +++ b/magpylib/_src/display/base_traces.py @@ -3,8 +3,8 @@ import numpy as np +from magpylib._src.display.display_utility import merge_mesh3d from magpylib._src.display.display_utility import place_and_orient_model3d -from magpylib._src.display.plotly.plotly_utility import merge_mesh3d def base_validator(name, value, conditions): diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index c5cb1a0b5..3430e3417 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -1,7 +1,7 @@ """ Display function codes""" import warnings -from magpylib._src.display.matplotlib.matplotlib_display import display_matplotlib +from magpylib._src.display.backend_matplotlib import display_matplotlib from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_backend @@ -136,7 +136,7 @@ def show( ) elif backend == "plotly": # pylint: disable=import-outside-toplevel - from magpylib._src.display.plotly.plotly_display import display_plotly + from magpylib._src.display.backend_plotly import display_plotly display_plotly( *obj_list_semi_flat, diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index 6b26219eb..800ba9ada 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -502,3 +502,148 @@ def get_flatten_objects_properties( ) ) return flat_objs + + +def merge_mesh3d(*traces): + """Merges a list of plotly mesh3d dictionaries. The `i,j,k` index parameters need to cumulate + the indices of each object in order to point to the right vertices in the concatenated + vertices. `x,y,z,i,j,k` are mandatory fields, the `intensity` and `facecolor` parameters also + get concatenated if they are present in all objects. All other parameter found in the + dictionary keys are taken from the first object, other keys from further objects are ignored. + """ + merged_trace = {} + L = np.array([0] + [len(b["x"]) for b in traces[:-1]]).cumsum() + for k in "ijk": + if k in traces[0]: + merged_trace[k] = np.hstack([b[k] + l for b, l in zip(traces, L)]) + for k in "xyz": + merged_trace[k] = np.concatenate([b[k] for b in traces]) + for k in ("intensity", "facecolor"): + if k in traces[0] and traces[0][k] is not None: + merged_trace[k] = np.hstack([b[k] for b in traces]) + for k, v in traces[0].items(): + if k not in merged_trace: + merged_trace[k] = v + return merged_trace + + +def merge_scatter3d(*traces): + """Merges a list of plotly scatter3d. `x,y,z` are mandatory fields and are concatenated with a + `None` vertex to prevent line connection between objects to be concatenated. Keys are taken + from the first object, other keys from further objects are ignored. + """ + merged_trace = {} + for k in "xyz": + merged_trace[k] = np.hstack([pts for b in traces for pts in [[None], b[k]]]) + for k, v in traces[0].items(): + if k not in merged_trace: + merged_trace[k] = v + return merged_trace + + +def merge_traces(*traces): + """Merges a list of plotly 3d-traces. Supported trace types are `mesh3d` and `scatter3d`. + All traces have be of the same type when merging. Keys are taken from the first object, other + keys from further objects are ignored. + """ + if len(traces) > 1: + if traces[0]["type"] == "mesh3d": + trace = merge_mesh3d(*traces) + elif traces[0]["type"] == "scatter3d": + trace = merge_scatter3d(*traces) + elif len(traces) == 1: + trace = traces[0] + else: + trace = [] + return trace + + +def getIntensity(vertices, axis) -> np.ndarray: + """Calculates the intensity values for vertices based on the distance of the vertices to + the mean vertices position in the provided axis direction. It can be used for plotting + fields on meshes. If `mag` See more infos here:https://plotly.com/python/3d-mesh/ + + Parameters + ---------- + vertices : ndarray, shape (n,3) + The n vertices of the mesh object. + axis : ndarray, shape (3,) + Direction vector. + + Returns + ------- + Intensity values: ndarray, shape (n,) + """ + p = np.array(vertices).T + pos = np.mean(p, axis=1) + m = np.array(axis) + intensity = (p[0] - pos[0]) * m[0] + (p[1] - pos[1]) * m[1] + (p[2] - pos[2]) * m[2] + # normalize to interval [0,1] (necessary for when merging mesh3d traces) + ptp = np.ptp(intensity) + ptp = ptp if ptp != 0 else 1 + intensity = (intensity - np.min(intensity)) / ptp + return intensity + + +def getColorscale( + color_transition=0, + color_north="#E71111", # 'red' + color_middle="#DDDDDD", # 'grey' + color_south="#00B050", # 'green' +) -> list: + """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array + containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named + color string. At minimum, a mapping for the lowest (0) and highest (1) values is required. + For example, `[[0, 'rgb(0,0,255)'], [1,'rgb(255,0,0)']]`. In this case the colorscale + is created depending on the north/middle/south poles colors. If the middle color is + None, the colorscale will only have north and south pole colors. + + Parameters + ---------- + color_transition : float, default=0.1 + A value between 0 and 1. Sets the smoothness of the color transitions from adjacent colors + visualization. + color_north : str, default=None + Magnetic north pole color. + color_middle : str, default=None + Color of area between south and north pole. + color_south : str, default=None + Magnetic north pole color. + + Returns + ------- + colorscale: list + Colorscale as list of tuples. + """ + if color_middle is False: + colorscale = [ + [0.0, color_south], + [0.5 * (1 - color_transition), color_south], + [0.5 * (1 + color_transition), color_north], + [1, color_north], + ] + else: + colorscale = [ + [0.0, color_south], + [0.2 - 0.2 * (color_transition), color_south], + [0.2 + 0.3 * (color_transition), color_middle], + [0.8 - 0.3 * (color_transition), color_middle], + [0.8 + 0.2 * (color_transition), color_north], + [1.0, color_north], + ] + return colorscale + + +def clean_legendgroups(fig): + """removes legend duplicates for a plotly figure""" + frames = [fig.data] + if fig.frames: + data_list = [f["data"] for f in fig.frames] + frames.extend(data_list) + for f in frames: + legendgroups = [] + for t in f: + if t.legendgroup not in legendgroups and t.legendgroup is not None: + legendgroups.append(t.legendgroup) + elif t.legendgroup is not None and t.legendgrouptitle.text is None: + t.showlegend = False diff --git a/magpylib/_src/display/plotly/__init__.py b/magpylib/_src/display/plotly/__init__.py deleted file mode 100644 index 21c94b0be..000000000 --- a/magpylib/_src/display/plotly/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""display.plotly""" diff --git a/magpylib/_src/display/plotly/plotly_utility.py b/magpylib/_src/display/plotly/plotly_utility.py deleted file mode 100644 index 92cebb96e..000000000 --- a/magpylib/_src/display/plotly/plotly_utility.py +++ /dev/null @@ -1,147 +0,0 @@ -"""utility functions for plotly backend""" -import numpy as np - - -def merge_mesh3d(*traces): - """Merges a list of plotly mesh3d dictionaries. The `i,j,k` index parameters need to cumulate - the indices of each object in order to point to the right vertices in the concatenated - vertices. `x,y,z,i,j,k` are mandatory fields, the `intensity` and `facecolor` parameters also - get concatenated if they are present in all objects. All other parameter found in the - dictionary keys are taken from the first object, other keys from further objects are ignored. - """ - merged_trace = {} - L = np.array([0] + [len(b["x"]) for b in traces[:-1]]).cumsum() - for k in "ijk": - if k in traces[0]: - merged_trace[k] = np.hstack([b[k] + l for b, l in zip(traces, L)]) - for k in "xyz": - merged_trace[k] = np.concatenate([b[k] for b in traces]) - for k in ("intensity", "facecolor"): - if k in traces[0] and traces[0][k] is not None: - merged_trace[k] = np.hstack([b[k] for b in traces]) - for k, v in traces[0].items(): - if k not in merged_trace: - merged_trace[k] = v - return merged_trace - - -def merge_scatter3d(*traces): - """Merges a list of plotly scatter3d. `x,y,z` are mandatory fields and are concatenated with a - `None` vertex to prevent line connection between objects to be concatenated. Keys are taken - from the first object, other keys from further objects are ignored. - """ - merged_trace = {} - for k in "xyz": - merged_trace[k] = np.hstack([pts for b in traces for pts in [[None], b[k]]]) - for k, v in traces[0].items(): - if k not in merged_trace: - merged_trace[k] = v - return merged_trace - - -def merge_traces(*traces): - """Merges a list of plotly 3d-traces. Supported trace types are `mesh3d` and `scatter3d`. - All traces have be of the same type when merging. Keys are taken from the first object, other - keys from further objects are ignored. - """ - if len(traces) > 1: - if traces[0]["type"] == "mesh3d": - trace = merge_mesh3d(*traces) - elif traces[0]["type"] == "scatter3d": - trace = merge_scatter3d(*traces) - elif len(traces) == 1: - trace = traces[0] - else: - trace = [] - return trace - - -def getIntensity(vertices, axis) -> np.ndarray: - """Calculates the intensity values for vertices based on the distance of the vertices to - the mean vertices position in the provided axis direction. It can be used for plotting - fields on meshes. If `mag` See more infos here:https://plotly.com/python/3d-mesh/ - - Parameters - ---------- - vertices : ndarray, shape (n,3) - The n vertices of the mesh object. - axis : ndarray, shape (3,) - Direction vector. - - Returns - ------- - Intensity values: ndarray, shape (n,) - """ - p = np.array(vertices).T - pos = np.mean(p, axis=1) - m = np.array(axis) - intensity = (p[0] - pos[0]) * m[0] + (p[1] - pos[1]) * m[1] + (p[2] - pos[2]) * m[2] - # normalize to interval [0,1] (necessary for when merging mesh3d traces) - ptp = np.ptp(intensity) - ptp = ptp if ptp != 0 else 1 - intensity = (intensity - np.min(intensity)) / ptp - return intensity - - -def getColorscale( - color_transition=0, - color_north="#E71111", # 'red' - color_middle="#DDDDDD", # 'grey' - color_south="#00B050", # 'green' -) -> list: - """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array - containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named - color string. At minimum, a mapping for the lowest (0) and highest (1) values is required. - For example, `[[0, 'rgb(0,0,255)'], [1,'rgb(255,0,0)']]`. In this case the colorscale - is created depending on the north/middle/south poles colors. If the middle color is - None, the colorscale will only have north and south pole colors. - - Parameters - ---------- - color_transition : float, default=0.1 - A value between 0 and 1. Sets the smoothness of the color transitions from adjacent colors - visualization. - color_north : str, default=None - Magnetic north pole color. - color_middle : str, default=None - Color of area between south and north pole. - color_south : str, default=None - Magnetic north pole color. - - Returns - ------- - colorscale: list - Colorscale as list of tuples. - """ - if color_middle is False: - colorscale = [ - [0.0, color_south], - [0.5 * (1 - color_transition), color_south], - [0.5 * (1 + color_transition), color_north], - [1, color_north], - ] - else: - colorscale = [ - [0.0, color_south], - [0.2 - 0.2 * (color_transition), color_south], - [0.2 + 0.3 * (color_transition), color_middle], - [0.8 - 0.3 * (color_transition), color_middle], - [0.8 + 0.2 * (color_transition), color_north], - [1.0, color_north], - ] - return colorscale - - -def clean_legendgroups(fig): - """removes legend duplicates""" - frames = [fig.data] - if fig.frames: - data_list = [f["data"] for f in fig.frames] - frames.extend(data_list) - for f in frames: - legendgroups = [] - for t in f: - if t.legendgroup not in legendgroups and t.legendgroup is not None: - legendgroups.append(t.legendgroup) - elif t.legendgroup is not None and t.legendgrouptitle.text is None: - t.showlegend = False diff --git a/magpylib/_src/display/plotly/plotly_sensor_mesh.py b/magpylib/_src/display/sensor_mesh.py similarity index 100% rename from magpylib/_src/display/plotly/plotly_sensor_mesh.py rename to magpylib/_src/display/sensor_mesh.py diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index ee45c0123..cb612d64f 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -3,7 +3,7 @@ import pytest import magpylib as magpy -from magpylib._src.display.plotly.plotly_display import get_plotly_traces +from magpylib._src.display.backend_generic import get_generic_traces from magpylib._src.exceptions import MagpylibBadUserInput from magpylib.magnet import Cuboid from magpylib.magnet import Cylinder @@ -183,14 +183,14 @@ class Unkwnown2DPosition: orientation = None with pytest.raises(AttributeError): - get_plotly_traces(UnkwnownNoPosition()) + get_generic_traces(UnkwnownNoPosition()) - traces = get_plotly_traces(Unkwnown1DPosition) + traces = get_generic_traces(Unkwnown1DPosition) assert ( traces[0]["type"] == "scatter3d" ), "make trace has failed, should be 'scatter3d'" - traces = get_plotly_traces(Unkwnown2DPosition) + traces = get_generic_traces(Unkwnown2DPosition) assert ( traces[0]["type"] == "scatter3d" ), "make trace has failed, should be 'scatter3d'" From 1b928c3504d3cbf244019b11176e098c6e9e7751 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 18 Jun 2022 23:34:46 +0200 Subject: [PATCH 114/207] pyvista draft implementation --- magpylib/_src/defaults/defaults_classes.py | 4 +- magpylib/_src/defaults/defaults_utility.py | 6 +- magpylib/_src/display/backend_matplotlib.py | 21 ++- magpylib/_src/display/backend_plotly.py | 48 +++--- magpylib/_src/display/backend_pyvista.py | 150 ++++++++++++++++++ magpylib/_src/display/display.py | 42 ++--- magpylib/_src/display/display_utility.py | 10 +- .../{base_traces.py => traces_base.py} | 0 .../{backend_generic.py => traces_generic.py} | 26 +-- magpylib/_src/input_checks.py | 7 +- magpylib/_src/style.py | 9 +- magpylib/graphics/model3d/__init__.py | 2 +- tests/test_Coumpound_setters.py | 2 +- tests/test_defaults.py | 2 +- tests/test_display_plotly.py | 2 +- 15 files changed, 245 insertions(+), 86 deletions(-) create mode 100644 magpylib/_src/display/backend_pyvista.py rename magpylib/_src/display/{base_traces.py => traces_base.py} (100%) rename magpylib/_src/display/{backend_generic.py => traces_generic.py} (98%) diff --git a/magpylib/_src/defaults/defaults_classes.py b/magpylib/_src/defaults/defaults_classes.py index 6730d90d8..d2dfd0b65 100644 --- a/magpylib/_src/defaults/defaults_classes.py +++ b/magpylib/_src/defaults/defaults_classes.py @@ -50,7 +50,7 @@ class Display(MagicProperties): ---------- backend: str, default='matplotlib' Defines the plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly']` + function. Can be one of `['matplotlib', 'plotly', 'pyvista']` colorsequence: iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', @@ -80,7 +80,7 @@ class Display(MagicProperties): @property def backend(self): """plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly']`""" + function. Can be one of `['matplotlib', 'plotly', 'pyvista']`""" return self._backend @backend.setter diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index c3573bb1b..e0520e5c2 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -5,7 +5,7 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly") +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista") MAGPYLIB_FAMILIES = { "Line": ("current",), @@ -244,7 +244,7 @@ def color_validator(color_input, allow_None=True, parent_name=""): if isinstance(color_input, (tuple, list)): - if len(color_input) == 4: # do not allow opacity values for now + if len(color_input) == 4: # do not allow opacity values for now color_input = color_input[:-1] if len(color_input) != 3: raise ValueError( @@ -253,7 +253,7 @@ def color_validator(color_input, allow_None=True, parent_name=""): ) # transform matplotlib colors scaled from 0-1 to rgb colors if not isinstance(color_input[0], int): - color_input = [int(255*c) for c in color_input] + color_input = [int(255 * c) for c in color_input] c = tuple(color_input) color_input = f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}" diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 049b4aaa6..1c4d36302 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -1,4 +1,6 @@ """ matplotlib draw-functionalities""" +import warnings + import matplotlib.pyplot as plt import numpy as np from mpl_toolkits.mplot3d.art3d import Poly3DCollection @@ -320,20 +322,21 @@ def draw_model3d_extra(obj, style, show_path, ax, color): def display_matplotlib( *obj_list_semi_flat, - axis=None, + canvas=None, markers=None, zoom=0, - color_sequence=None, + colorsequence=None, + animation=False, **kwargs, ): """ Display objects and paths graphically with the matplotlib backend. - - axis: matplotlib axis3d object + - canvas: matplotlib axis3d object - markers: list of marker positions - path: bool / int / list of ints - zoom: zoom level, 0=tight boundaries - - color_sequence: list of colors for object coloring + - colorsequence: list of colors for object coloring """ # pylint: disable=protected-access # pylint: disable=too-many-branches @@ -341,6 +344,14 @@ def display_matplotlib( # apply config default values if None # create or set plotting axis + + if animation is not False: + msg = "The matplotlib backend does not support animation at the moment.\n" + msg += "Use `backend=plotly` instead." + warnings.warn(msg) + # animation = False + + axis = canvas if axis is None: fig = plt.figure(dpi=80, figsize=(8, 8)) ax = fig.add_subplot(111, projection="3d") @@ -357,7 +368,7 @@ def display_matplotlib( dipoles = [] sensors = [] flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, color_sequence=color_sequence + *obj_list_semi_flat, colorsequence=colorsequence ) for obj, props in flat_objs_props.items(): color = props["color"] diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index 8f645b3da..8ebba238d 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -16,7 +16,7 @@ from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.utility import format_obj_input from magpylib._src.display.display_utility import clean_legendgroups -from magpylib._src.display.backend_generic import ( +from magpylib._src.display.traces_generic import ( draw_frame, apply_fig_ranges, MagpyMarkers, @@ -26,7 +26,7 @@ def animate_path( fig, objs, - color_sequence=None, + colorsequence=None, zoom=1, title="3D-Paths Animation", animation_time=3, @@ -57,7 +57,7 @@ def animate_path( title: str, default = "3D-Paths Animation" When zoom=0 all objects are just inside the 3D-axes. - color_sequence: list or array_like, iterable, default= + colorsequence: list or array_like, iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', @@ -182,7 +182,7 @@ def animate_path( kwargs["style_path_frames"] = [ind] frame = draw_frame( objs, - color_sequence, + colorsequence, zoom, autosize=autosize, output="list", @@ -229,10 +229,10 @@ def display_plotly( *obj_list, markers=None, zoom=1, - fig=None, + canvas=None, renderer=None, animation=False, - color_sequence=None, + colorsequence=None, **kwargs, ): @@ -269,7 +269,7 @@ def display_plotly( title: str, default = "3D-Paths Animation" When zoom=0 all objects are just inside the 3D-axes. - color_sequence: list or array_like, iterable, default= + colorsequence: list or array_like, iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', @@ -289,10 +289,10 @@ def display_plotly( flat_obj_list = format_obj_input(obj_list) - show_fig = False - if fig is None: - show_fig = True - fig = go.Figure() + show_canvas = False + if canvas is None: + show_canvas = True + canvas = go.Figure() # set animation and animation_time if isinstance(animation, numbers.Number) and not isinstance(animation, bool): @@ -328,26 +328,26 @@ def display_plotly( if markers is not None and markers: obj_list = list(obj_list) + [MagpyMarkers(*markers)] - if color_sequence is None: - color_sequence = Config.display.colorsequence + if colorsequence is None: + colorsequence = Config.display.colorsequence - with fig.batch_update(): + with canvas.batch_update(): if animation is not False: title = "3D-Paths Animation" if title is None else title animate_path( - fig=fig, + fig=canvas, objs=obj_list, - color_sequence=color_sequence, + colorsequence=colorsequence, zoom=zoom, title=title, **kwargs, ) else: - traces = draw_frame(obj_list, color_sequence, zoom, output="list", **kwargs) - fig.add_traces(traces) - fig.update_layout(title_text=title) - apply_fig_ranges(fig, zoom=zoom) - clean_legendgroups(fig) - fig.update_layout(legend_itemsizing="constant") - if show_fig: - fig.show(renderer=renderer) + traces = draw_frame(obj_list, colorsequence, zoom, output="list", **kwargs) + canvas.add_traces(traces) + canvas.update_layout(title_text=title) + apply_fig_ranges(canvas, zoom=zoom) + clean_legendgroups(canvas) + canvas.update_layout(legend_itemsizing="constant") + if show_canvas: + canvas.show(renderer=renderer) diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py new file mode 100644 index 000000000..e116e9637 --- /dev/null +++ b/magpylib/_src/display/backend_pyvista.py @@ -0,0 +1,150 @@ +import warnings + +import numpy as np + +try: + import pyvista as pv +except ImportError as missing_module: # pragma: no cover + raise ModuleNotFoundError( + """In order to use the pyvista plotting backend, you need to install pyvista via pip or + conda, see https://docs.pyvista.org/getting-started/installation.html""" + ) from missing_module + +from pyvista.plotting.colors import Color +from magpylib._src.display.traces_generic import draw_frame +from magpylib._src.display.display_utility import MagpyMarkers + +# from magpylib._src.utility import format_obj_input + + +def generic_trace_to_pyvista(trace): + """Transform a generic trace into a pyvista trace""" + + traces_pv = [] + if trace["type"] == "mesh3d": + vertices = np.array([trace[k] for k in "xyz"]).T + faces = np.array([trace[k] for k in "ijk"]).T.flatten() + faces = np.insert(faces, range(0, len(faces), 3), 3) + colorscale = trace.get("colorscale", None) + mesh = pv.PolyData(vertices, faces) + facecolor = trace.get("facecolor", None) + trace_pv = { + "mesh": mesh, + "opacity": trace.get("opacity", None), + "color": trace.get("color", None), + "scalars": trace.get("intensity", None), + } + if facecolor is not None: + # pylint: disable=unsupported-assignment-operation + mesh.cell_data["colors"] = [ + Color(c, default_color=(0, 0, 0)).int_rgb for c in facecolor + ] + trace_pv.update( + { + "scalars": "colors", + "rgb": True, + "preference": "cell", + } + ) + traces_pv.append(trace_pv) + if colorscale is not None: + trace_pv["cmap"] = [v[1] for v in colorscale] + elif trace["type"] == "scatter3d": + points = np.array([trace[k] for k in "xyz"]).T + line = trace.get("line", {}) + line_color = line.get("color", trace.get("line_color", None)) + line_width = line.get("width", trace.get("line_width", None)) + trace_pv_line = { + "mesh": pv.lines_from_points(points), + "opacity": trace.get("opacity", None), + "color": line_color, + "line_width": line_width, + } + traces_pv.append(trace_pv_line) + marker = trace.get("marker", {}) + marker_color = marker.get("color", trace.get("marker_color", None)) + marker_symbol = marker.get("symbol", trace.get("marker_symbol", None)) + marker_size = marker.get("size", trace.get("marker_size", None)) + trace_pv_marker = { + "mesh": pv.PolyData(points), + "opacity": trace.get("opacity", None), + "color": marker_color, + "point_size": 1 if marker_size is None else marker_size, + } + traces_pv.append(trace_pv_marker) + else: + raise ValueError( + f"Trace type {trace['type']!r} cannot be transformed into pyvista trace" + ) + return traces_pv + + +def display_pyvista( + *obj_list, + markers=None, + zoom=1, + canvas=None, + animation=False, + colorsequence=None, + **kwargs, +): + + """ + Display objects and paths graphically using the pyvista library. + + Parameters + ---------- + objects: sources, collections or sensors + Objects to be displayed. + + markers: array_like, None, shape (N,3), default=None + Display position markers in the global CS. By default no marker is displayed. + + zoom: float, default = 1 + Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. + + canvas: pyvista Plotter, default=None + Display graphical output in a given canvas + By default a new `Figure` is created and displayed. + + title: str, default = "3D-Paths Animation" + When zoom=0 all objects are just inside the 3D-axes. + + colorsequence: list or array_like, iterable, default= + ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', + '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', + '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', + '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] + An iterable of color values used to cycle trough for every object displayed. + A color and may be specified as: + - A hex string (e.g. '#ff0000') + - An rgb/rgba string (e.g. 'rgb(255,0,0)') + - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') + - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') + - A named CSS color + """ + + if animation is not False: + msg = "The matplotlib backend does not support animation at the moment.\n" + msg += "Use `backend=plotly` instead." + warnings.warn(msg) + # animation = False + + # flat_obj_list = format_obj_input(obj_list) + + show_canvas = False + if canvas is None: + show_canvas = True + canvas = pv.Plotter() + + if markers is not None and markers: + obj_list = list(obj_list) + [MagpyMarkers(*markers)] + + generic_traces = draw_frame(obj_list, colorsequence, zoom, output="list", **kwargs) + for tr0 in generic_traces: + for tr1 in generic_trace_to_pyvista(tr0): + canvas.add_mesh(**tr1) + + # apply_fig_ranges(canvas, zoom=zoom) + if show_canvas: + canvas.show() diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 3430e3417..81454bd7c 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -1,7 +1,6 @@ """ Display function codes""" -import warnings +from importlib import import_module -from magpylib._src.display.backend_matplotlib import display_matplotlib from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_backend @@ -43,7 +42,7 @@ def show( Display position markers in the global coordinate system. backend: string, default=`None` - Define plotting backend. Must be one of `'matplotlib'` or `'plotly'`. If not + Define plotting backend. Must be one of `'matplotlib'`, `'plotly'` or `'pyvista'`. If not set, parameter will default to `magpylib.defaults.display.backend` which is `'matplotlib'` by installation default. @@ -51,6 +50,7 @@ def show( Display graphical output on a given canvas: - with matplotlib: `matplotlib.axes._subplots.AxesSubplot` with `projection=3d. - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. + - with pyvista: `pyvista.Plotter`. By default a new canvas is created and immediately displayed. Returns @@ -121,28 +121,14 @@ def show( allow_None=True, ) - if backend == "matplotlib": - if animation is not False: - msg = "The matplotlib backend does not support animation at the moment.\n" - msg += "Use `backend=plotly` instead." - warnings.warn(msg) - # animation = False - display_matplotlib( - *obj_list_semi_flat, - markers=markers, - zoom=zoom, - axis=canvas, - **kwargs, - ) - elif backend == "plotly": - # pylint: disable=import-outside-toplevel - from magpylib._src.display.backend_plotly import display_plotly - - display_plotly( - *obj_list_semi_flat, - markers=markers, - zoom=zoom, - fig=canvas, - animation=animation, - **kwargs, - ) + # pylint: disable=import-outside-toplevel + display_func = getattr( + import_module(f"magpylib._src.display.backend_{backend}"), f"display_{backend}" + ) + display_func( + *obj_list_semi_flat, + markers=markers, + zoom=zoom, + canvas=canvas, + **kwargs, + ) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index 800ba9ada..acc1a0a2e 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -463,15 +463,15 @@ def system_size(points): def get_flatten_objects_properties( *obj_list_semi_flat, - color_sequence=None, + colorsequence=None, color_cycle=None, **parent_props, ): """returns a flat dict -> (obj: display_props, ...) from nested collections""" - if color_sequence is None: - color_sequence = Config.display.colorsequence + if colorsequence is None: + colorsequence = Config.display.colorsequence if color_cycle is None: - color_cycle = cycle(color_sequence) + color_cycle = cycle(colorsequence) flat_objs = {} for subobj in obj_list_semi_flat: isCollection = getattr(subobj, "children", None) is not None @@ -496,7 +496,7 @@ def get_flatten_objects_properties( flat_objs.update( get_flatten_objects_properties( *subobj.children, - color_sequence=color_sequence, + colorsequence=colorsequence, color_cycle=color_cycle, **flat_objs[subobj], ) diff --git a/magpylib/_src/display/base_traces.py b/magpylib/_src/display/traces_base.py similarity index 100% rename from magpylib/_src/display/base_traces.py rename to magpylib/_src/display/traces_base.py diff --git a/magpylib/_src/display/backend_generic.py b/magpylib/_src/display/traces_generic.py similarity index 98% rename from magpylib/_src/display/backend_generic.py rename to magpylib/_src/display/traces_generic.py index 5a84c6cc2..6e313abce 100644 --- a/magpylib/_src/display/backend_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -11,13 +11,6 @@ from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.defaults.defaults_utility import linearize_dict from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY -from magpylib._src.display.base_traces import make_Arrow as make_BaseArrow -from magpylib._src.display.base_traces import make_Cuboid as make_BaseCuboid -from magpylib._src.display.base_traces import ( - make_CylinderSegment as make_BaseCylinderSegment, -) -from magpylib._src.display.base_traces import make_Ellipsoid as make_BaseEllipsoid -from magpylib._src.display.base_traces import make_Prism as make_BasePrism from magpylib._src.display.display_utility import draw_arrow_from_vertices from magpylib._src.display.display_utility import draw_arrowed_circle from magpylib._src.display.display_utility import get_flatten_objects_properties @@ -29,6 +22,13 @@ from magpylib._src.display.display_utility import merge_traces from magpylib._src.display.display_utility import place_and_orient_model3d from magpylib._src.display.sensor_mesh import get_sensor_mesh +from magpylib._src.display.traces_base import make_Arrow as make_BaseArrow +from magpylib._src.display.traces_base import make_Cuboid as make_BaseCuboid +from magpylib._src.display.traces_base import ( + make_CylinderSegment as make_BaseCylinderSegment, +) +from magpylib._src.display.traces_base import make_Ellipsoid as make_BaseEllipsoid +from magpylib._src.display.traces_base import make_Prism as make_BasePrism from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY @@ -711,7 +711,12 @@ def make_path(input_obj, style, legendgroup, kwargs): def draw_frame( - obj_list_semi_flat, color_sequence, zoom, autosize=None, output="dict", **kwargs + obj_list_semi_flat, + colorsequence=None, + zoom=0.0, + autosize=None, + output="dict", + **kwargs, ) -> Tuple: """ Creates traces from input `objs` and provided parameters, updates the size of objects like @@ -723,6 +728,9 @@ def draw_frame( returns the traces in a obj/traces_list dictionary and updated kwargs """ # pylint: disable=protected-access + if colorsequence is None: + colorsequence = Config.display.colorsequence + return_autosize = False Sensor = _src.obj_classes.Sensor Dipole = _src.obj_classes.Dipole @@ -731,7 +739,7 @@ def draw_frame( # autosize is calculated from the other traces overall scene range traces_to_resize = {} flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, color_sequence=color_sequence + *obj_list_semi_flat, colorsequence=colorsequence ) for obj, params in flat_objs_props.items(): params.update(kwargs) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index f57d642f2..4a5b0fb95 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -7,6 +7,7 @@ from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings +from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibMissingInput from magpylib._src.utility import format_obj_input @@ -14,7 +15,6 @@ from magpylib._src.utility import LIBRARY_SOURCES from magpylib._src.utility import wrong_obj_msg - ################################################################# ################################################################# # FUNDAMENTAL CHECKS @@ -412,12 +412,13 @@ def check_format_input_cylinder_segment(inp): def check_format_input_backend(inp): """checks show-backend input and returns Non if bad input value""" + backends = SUPPORTED_PLOTTING_BACKENDS if inp is None: inp = default_settings.display.backend - if inp in ("matplotlib", "plotly"): + if inp in backends: return inp raise MagpylibBadUserInput( - "Input parameter `backend` must be one of `('matplotlib', 'plotly', None)`.\n" + f"Input parameter `backend` must be one of `{backends+(None,)}`.\n" f"Instead received {inp}." ) diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 6bc8e4b3a..e7c642a71 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -291,7 +291,8 @@ def add_trace(self, trace=None, **kwargs): pairs, or a callable returning the equivalent dictionary. backend: str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['matplotlib', 'plotly', 'pyvista']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -334,7 +335,8 @@ class Trace3d(MagicProperties): Parameters ---------- backend: str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['matplotlib', 'plotly', 'pyvista']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -487,7 +489,8 @@ def coordsargs(self, val): @property def backend(self): - """Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`.""" + """Plotting backend corresponding to the trace. Can be one of + `['matplotlib', 'plotly', 'pyvista']`.""" return self._backend @backend.setter diff --git a/magpylib/graphics/model3d/__init__.py b/magpylib/graphics/model3d/__init__.py index 9fefd8ce5..f6ad0f308 100644 --- a/magpylib/graphics/model3d/__init__.py +++ b/magpylib/graphics/model3d/__init__.py @@ -13,7 +13,7 @@ "make_Prism", ] -from magpylib._src.display.base_traces import ( +from magpylib._src.display.traces_base import ( make_Arrow, make_Ellipsoid, make_Pyramid, diff --git a/tests/test_Coumpound_setters.py b/tests/test_Coumpound_setters.py index bb5c587f0..3bacec196 100644 --- a/tests/test_Coumpound_setters.py +++ b/tests/test_Coumpound_setters.py @@ -8,7 +8,7 @@ from scipy.spatial.transform import Rotation as R import magpylib as magpy -from magpylib._src.display.base_traces import make_Prism +from magpylib._src.display.traces_base import make_Prism magpy.defaults.display.backend = "plotly" diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 84d88529c..91f421ae0 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -98,7 +98,7 @@ def test_defaults_bad_inputs(key, value, expected_errortype): "display_animation_time": (10,), # int>0 "display_animation_maxframes": (200,), # int>0 "display_animation_slider": (True, False), # bool - "display_backend": ("matplotlib", "plotly"), # str typo + "display_backend": ("matplotlib", "plotly", "pyvista"), # str typo "display_colorsequence": ( ["#2E91E5", "#0D2A63"], ["blue", "red"], diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index cb612d64f..bb069d7f1 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -3,7 +3,7 @@ import pytest import magpylib as magpy -from magpylib._src.display.backend_generic import get_generic_traces +from magpylib._src.display.traces_generic import get_generic_traces from magpylib._src.exceptions import MagpylibBadUserInput from magpylib.magnet import Cuboid from magpylib.magnet import Cylinder From 0a0c51f9863a5e9d5270c5eb2237bf5ff2f18258 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 18 Jun 2022 23:45:57 +0200 Subject: [PATCH 115/207] remove scalar bar --- magpylib/_src/display/backend_pyvista.py | 1 + 1 file changed, 1 insertion(+) diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index e116e9637..59556fc0f 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -146,5 +146,6 @@ def display_pyvista( canvas.add_mesh(**tr1) # apply_fig_ranges(canvas, zoom=zoom) + canvas.remove_scalar_bar() if show_canvas: canvas.show() From e994abd0004d0c5a112441828e2bb63de8c66107 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 18 Jun 2022 23:49:15 +0200 Subject: [PATCH 116/207] fix remove scalar bar --- magpylib/_src/display/backend_pyvista.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index 59556fc0f..27925d1f4 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -146,6 +146,9 @@ def display_pyvista( canvas.add_mesh(**tr1) # apply_fig_ranges(canvas, zoom=zoom) - canvas.remove_scalar_bar() + try: + canvas.remove_scalar_bar() + except IndexError: + pass if show_canvas: canvas.show() From f91249ad57cd1b1db77ce835555d2cd95c5673f9 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 19 Jun 2022 00:03:16 +0200 Subject: [PATCH 117/207] typo --- magpylib/_src/display/backend_pyvista.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index 27925d1f4..5bb05820e 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -125,7 +125,7 @@ def display_pyvista( """ if animation is not False: - msg = "The matplotlib backend does not support animation at the moment.\n" + msg = "The pyvista backend does not support animation at the moment.\n" msg += "Use `backend=plotly` instead." warnings.warn(msg) # animation = False From 93d27f02b8c976d50d180dbe853ba1600cd9791e Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 19 Jun 2022 01:39:19 +0200 Subject: [PATCH 118/207] add back animation --- magpylib/_src/display/display.py | 1 + 1 file changed, 1 insertion(+) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 81454bd7c..a6c749a1e 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -130,5 +130,6 @@ def show( markers=markers, zoom=zoom, canvas=canvas, + animation=animation, **kwargs, ) From 2d5cffe3e293fa056070156b0c02aeab5a17d367 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 19 Jun 2022 01:55:41 +0200 Subject: [PATCH 119/207] add matplotlib auto backend --- magpylib/_src/defaults/defaults_utility.py | 2 +- .../_src/display/backend_matplotlib_auto.py | 133 ++++++++++++++++++ magpylib/_src/display/backend_plotly.py | 27 +++- magpylib/_src/display/backend_pyvista.py | 6 +- magpylib/_src/display/traces_generic.py | 35 ++--- 5 files changed, 176 insertions(+), 27 deletions(-) create mode 100644 magpylib/_src/display/backend_matplotlib_auto.py diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index e0520e5c2..c8fc06ba9 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -5,7 +5,7 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista") +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista", "matplotlib_auto") MAGPYLIB_FAMILIES = { "Line": ("current",), diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py new file mode 100644 index 000000000..eae6201bc --- /dev/null +++ b/magpylib/_src/display/backend_matplotlib_auto.py @@ -0,0 +1,133 @@ +import warnings + +import matplotlib.pyplot as plt +import numpy as np + +from magpylib._src.display.display_utility import MagpyMarkers +from magpylib._src.display.traces_generic import draw_frame + +# from magpylib._src.utility import format_obj_input + + +def generic_trace_to_matplotlib(trace): + """Transform a generic trace into a matplotlib trace""" + if trace["type"] == "mesh3d": + x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) + triangles = np.array([trace[k] for k in "ijk"]).T + trace_mpl = { + "constructor": "plot_trisurf", + "args": (x, y, z), + "kwargs": { + "triangles": triangles, + "alpha": trace.get("opacity", None), + "color": trace.get("color", None), + }, + } + elif trace["type"] == "scatter3d": + x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) + props = { + k[0]: trace.get(k[1], {}).get(k[2], trace.get(k, None)) + for k in ( + ("ls", "line", "dash"), + ("lw", "line", "width"), + ("color", "line", "color"), + ("marker", "marker", "symbol"), + ("mfc", "marker", "color"), + ("mec", "marker", "color"), + ("ms", "marker", "size"), + ) + } + trace_mpl = { + "constructor": "plot", + "args": (x, y, z), + "kwargs": { + **{k: v for k, v in props.items() if v is not None}, + "alpha": trace.get("opacity", None), + }, + } + else: + raise ValueError( + f"Trace type {trace['type']!r} cannot be transformed into pyvista trace" + ) + return trace_mpl + + +def display_matplotlib_auto( + *obj_list, + markers=None, + zoom=1, + canvas=None, + animation=False, + colorsequence=None, + **kwargs, +): + + """ + Display objects and paths graphically using the matplotlib library. + + Parameters + ---------- + objects: sources, collections or sensors + Objects to be displayed. + + markers: array_like, None, shape (N,3), default=None + Display position markers in the global CS. By default no marker is displayed. + + zoom: float, default = 1 + Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. + + canvas: `matplotlib.axes._subplots.AxesSubplot` with `projection=3d, default=None + Display graphical output in a given canvas + By default a new `Figure` is created and displayed. + + title: str, default = "3D-Paths Animation" + When zoom=0 all objects are just inside the 3D-axes. + + colorsequence: list or array_like, iterable, default= + ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', + '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', + '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', + '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] + An iterable of color values used to cycle trough for every object displayed. + A color and may be specified as: + - A hex string (e.g. '#ff0000') + - An rgb/rgba string (e.g. 'rgb(255,0,0)') + - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') + - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') + - A named CSS color + """ + + if animation is not False: + msg = "The matplotlib backend does not support animation at the moment.\n" + msg += "Use `backend=plotly` instead." + warnings.warn(msg) + # animation = False + + # flat_obj_list = format_obj_input(obj_list) + + show_canvas = False + if canvas is None: + show_canvas = True + fig = plt.figure(dpi=80, figsize=(8, 8)) + canvas = fig.add_subplot(111, projection="3d") + canvas.set_box_aspect((1, 1, 1)) + + if markers is not None and markers: + obj_list = list(obj_list) + [MagpyMarkers(*markers)] + + generic_traces, ranges = draw_frame( + obj_list, colorsequence, zoom, output="list", return_ranges=True, **kwargs + ) + for tr in generic_traces: + tr1 = generic_trace_to_matplotlib(tr) + constructor = tr1["constructor"] + args = tr1["args"] + kwargs = tr1["kwargs"] + getattr(canvas, constructor)(*args, **kwargs) + canvas.set( + **{f"{k}label": f"{k} [mm]" for k in "xyz"}, + **{f"{k}lim": r for k, r in zip("xyz", ranges)}, + ) + # apply_fig_ranges(canvas, zoom=zoom) + if show_canvas: + plt.show() diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index 8ebba238d..ccf264de0 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -21,6 +21,9 @@ apply_fig_ranges, MagpyMarkers, ) +from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY +from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY +from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY def animate_path( @@ -194,7 +197,7 @@ def animate_path( traces = frame frames.append( go.Frame( - data=traces, + data=[generic_trace_to_plotly(trace) for trace in traces], name=str(ind + 1), layout=dict(title=f"""{title} - path index: {ind+1:0{exp}d}"""), ) @@ -225,6 +228,23 @@ def animate_path( apply_fig_ranges(fig, zoom=zoom) +def generic_trace_to_plotly(trace): + print(trace) + """Transform a generic trace into a plotly trace""" + if trace["type"] == "scatter3d": + if "line_width" in trace: + trace["line_width"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] + dash = trace.get("line_dash", None) + if dash is not None: + trace["line_dash"] = LINESTYLES_MATPLOTLIB_TO_PLOTLY.get(dash, dash) + symb = trace.get("marker_symbol", None) + if symb is not None: + trace["marker_symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) + if "marker_size" in trace: + trace["marker_size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] + return trace + + def display_plotly( *obj_list, markers=None, @@ -343,7 +363,10 @@ def display_plotly( **kwargs, ) else: - traces = draw_frame(obj_list, colorsequence, zoom, output="list", **kwargs) + generic_traces = draw_frame( + obj_list, colorsequence, zoom, output="list", **kwargs + ) + traces = [generic_trace_to_plotly(trace) for trace in generic_traces] canvas.add_traces(traces) canvas.update_layout(title_text=title) apply_fig_ranges(canvas, zoom=zoom) diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index 5bb05820e..d3b0f90c7 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -22,7 +22,7 @@ def generic_trace_to_pyvista(trace): traces_pv = [] if trace["type"] == "mesh3d": - vertices = np.array([trace[k] for k in "xyz"]).T + vertices = np.array([trace[k] for k in "xyz"], dtype=float).T faces = np.array([trace[k] for k in "ijk"]).T.flatten() faces = np.insert(faces, range(0, len(faces), 3), 3) colorscale = trace.get("colorscale", None) @@ -50,7 +50,7 @@ def generic_trace_to_pyvista(trace): if colorscale is not None: trace_pv["cmap"] = [v[1] for v in colorscale] elif trace["type"] == "scatter3d": - points = np.array([trace[k] for k in "xyz"]).T + points = np.array([trace[k] for k in "xyz"], dtype=float).T line = trace.get("line", {}) line_color = line.get("color", trace.get("line_color", None)) line_width = line.get("width", trace.get("line_width", None)) @@ -63,7 +63,7 @@ def generic_trace_to_pyvista(trace): traces_pv.append(trace_pv_line) marker = trace.get("marker", {}) marker_color = marker.get("color", trace.get("marker_color", None)) - marker_symbol = marker.get("symbol", trace.get("marker_symbol", None)) + # marker_symbol = marker.get("symbol", trace.get("marker_symbol", None)) marker_size = marker.get("size", trace.get("marker_size", None)) trace_pv_marker = { "mesh": pv.PolyData(points), diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 6e313abce..3acf8c302 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -10,7 +10,6 @@ from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY from magpylib._src.display.display_utility import draw_arrow_from_vertices from magpylib._src.display.display_utility import draw_arrowed_circle from magpylib._src.display.display_utility import get_flatten_objects_properties @@ -31,8 +30,6 @@ from magpylib._src.display.traces_base import make_Prism as make_BasePrism from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style -from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY -from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY from magpylib._src.utility import unit_prefix @@ -64,7 +61,7 @@ def make_Line( if orientation is not None: vertices = orientation.apply(vertices.T).T x, y, z = (vertices.T + position).T - line_width = style.arrow.width * SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] + line_width = style.arrow.width line = dict( type="scatter3d", x=x, @@ -103,7 +100,7 @@ def make_Loop( if orientation is not None: vertices = orientation.apply(vertices.T).T x, y, z = (vertices.T + position).T - line_width = style.arrow.width * SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] + line_width = style.arrow.width circular = dict( type="scatter3d", x=x, @@ -517,10 +514,7 @@ def get_generic_traces( traces = [] if isinstance(input_obj, MagpyMarkers): x, y, z = input_obj.markers.T - marker = style.as_dict()["marker"] - symb = marker["symbol"] - marker["symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) - marker["size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] + marker = style.marker.as_dict() default_name = "Marker" if len(x) == 1 else "Markers" default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" name, name_suffix = get_name_and_suffix(default_name, default_suffix, style) @@ -684,15 +678,11 @@ def make_path(input_obj, style, legendgroup, kwargs): else {"mode": "markers+lines"} ) marker = style.path.marker.as_dict() - symb = marker["symbol"] - marker["symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) + marker["symbol"] = marker["symbol"] marker["color"] = kwargs["color"] if marker["color"] is None else marker["color"] - marker["size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] line = style.path.line.as_dict() - dash = line["style"] - line["dash"] = LINESTYLES_MATPLOTLIB_TO_PLOTLY.get(dash, dash) + line["dash"] = line["style"] line["color"] = kwargs["color"] if line["color"] is None else line["color"] - line["width"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] line = {k: v for k, v in line.items() if k != "style"} scatter_path = dict( type="scatter3d", @@ -702,8 +692,8 @@ def make_path(input_obj, style, legendgroup, kwargs): name=f"Path: {input_obj}", showlegend=False, legendgroup=legendgroup, - marker=marker, - line=line, + **{f"marker_{k}": v for k, v in marker.items()}, + **{f"line_{k}": v for k, v in line.items()}, **txt_kwargs, opacity=kwargs["opacity"], ) @@ -716,6 +706,7 @@ def draw_frame( zoom=0.0, autosize=None, output="dict", + return_ranges=False, **kwargs, ) -> Tuple: """ @@ -761,11 +752,12 @@ def draw_frame( if output == "list": traces = [t for tr in traces_out.values() for t in tr] traces_out = group_traces(*traces) + res = (traces_out,) if return_autosize: - res = traces_out, autosize - else: - res = traces_out - return res + res += (autosize,) + if return_ranges: + res += (ranges,) + return res[0] if len(res) == 1 else res def group_traces(*traces): @@ -773,6 +765,7 @@ def group_traces(*traces): browser rendering performance when displaying a lot of mesh3d objects.""" mesh_groups = {} common_keys = ["legendgroup", "opacity"] + # TODO grouping does not dectect line_width vs line=dict(with=...) spec_keys = {"mesh3d": ["colorscale"], "scatter3d": ["marker", "line"]} for tr in traces: gr = [tr["type"]] From a22c352145efdcfc62a8340f88574411fd40448c Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 20 Jun 2022 12:27:52 +0200 Subject: [PATCH 120/207] add colorscale caching --- magpylib/_src/display/display_utility.py | 32 +++++++++++++----------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index acc1a0a2e..ec894baa4 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -1,6 +1,7 @@ """ Display function codes""" from itertools import cycle from typing import Tuple +from functools import lru_cache import numpy as np from scipy.spatial.transform import Rotation as RotScipy @@ -585,12 +586,13 @@ def getIntensity(vertices, axis) -> np.ndarray: return intensity +@lru_cache(maxsize=32) def getColorscale( color_transition=0, color_north="#E71111", # 'red' color_middle="#DDDDDD", # 'grey' color_south="#00B050", # 'green' -) -> list: +) -> Tuple: """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named color string. At minimum, a mapping for the lowest (0) and highest (1) values is required. @@ -616,21 +618,21 @@ def getColorscale( Colorscale as list of tuples. """ if color_middle is False: - colorscale = [ - [0.0, color_south], - [0.5 * (1 - color_transition), color_south], - [0.5 * (1 + color_transition), color_north], - [1, color_north], - ] + colorscale = ( + (0.0, color_south), + (0.5 * (1 - color_transition), color_south), + (0.5 * (1 + color_transition), color_north), + (1, color_north), + ) else: - colorscale = [ - [0.0, color_south], - [0.2 - 0.2 * (color_transition), color_south], - [0.2 + 0.3 * (color_transition), color_middle], - [0.8 - 0.3 * (color_transition), color_middle], - [0.8 + 0.2 * (color_transition), color_north], - [1.0, color_north], - ] + colorscale = ( + (0.0, color_south), + (0.2 - 0.2 * (color_transition), color_south), + (0.2 + 0.3 * (color_transition), color_middle), + (0.8 - 0.3 * (color_transition), color_middle), + (0.8 + 0.2 * (color_transition), color_north), + (1.0, color_north), + ) return colorscale From 7ea02db10967b6cad994c9ee4325b4a546f09a1b Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 20 Jun 2022 12:28:09 +0200 Subject: [PATCH 121/207] remove print statement --- magpylib/_src/display/backend_plotly.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index ccf264de0..287fd9cc7 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -229,7 +229,6 @@ def animate_path( def generic_trace_to_plotly(trace): - print(trace) """Transform a generic trace into a plotly trace""" if trace["type"] == "scatter3d": if "line_width" in trace: From 1848b7928731ccb2bd675a3cf046635b9dd0bc98 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 20 Jun 2022 12:28:40 +0200 Subject: [PATCH 122/207] add cached colorscale to colormap translator --- magpylib/_src/display/backend_pyvista.py | 29 ++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index d3b0f90c7..d460ba316 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -1,4 +1,6 @@ import warnings +from functools import lru_cache +from attr import frozen import numpy as np @@ -11,15 +13,33 @@ ) from missing_module from pyvista.plotting.colors import Color +from matplotlib.colors import LinearSegmentedColormap from magpylib._src.display.traces_generic import draw_frame from magpylib._src.display.display_utility import MagpyMarkers # from magpylib._src.utility import format_obj_input +@lru_cache(maxsize=32) +def colormap_from_colorscale(colorscale, name="plotly_to_mpl", N=256, gamma=1.0): + """Create matplotlib colormap from plotly colorscale""" + + cs_rgb = [(v[0], Color(v[1]).float_rgb) for v in colorscale] + cdict = { + rgb_col: [ + ( + v[0], + *[cs_rgb[i][1][rgb_ind]] * 2, + ) + for i, v in enumerate(cs_rgb) + ] + for rgb_ind, rgb_col in enumerate(("red", "green", "blue")) + } + return LinearSegmentedColormap(name, cdict, N, gamma) + + def generic_trace_to_pyvista(trace): """Transform a generic trace into a pyvista trace""" - traces_pv = [] if trace["type"] == "mesh3d": vertices = np.array([trace[k] for k in "xyz"], dtype=float).T @@ -48,7 +68,12 @@ def generic_trace_to_pyvista(trace): ) traces_pv.append(trace_pv) if colorscale is not None: - trace_pv["cmap"] = [v[1] for v in colorscale] + if colorscale is not None: + # ipygany does not support custom colorsequences + if pv.global_theme.jupyter_backend == "ipygany": + trace_pv["cmap"] = "PiYG" + else: + trace_pv["cmap"] = colormap_from_colorscale(colorscale) elif trace["type"] == "scatter3d": points = np.array([trace[k] for k in "xyz"], dtype=float).T line = trace.get("line", {}) From 531fa808660576452d902492fa78961c286ffd3c Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 20 Jun 2022 18:39:35 +0200 Subject: [PATCH 123/207] add facecolor subdivision for matplotlib_auto --- .../_src/display/backend_matplotlib_auto.py | 96 +++++++++++++------ 1 file changed, 66 insertions(+), 30 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py index eae6201bc..fce77b27a 100644 --- a/magpylib/_src/display/backend_matplotlib_auto.py +++ b/magpylib/_src/display/backend_matplotlib_auto.py @@ -9,47 +9,77 @@ # from magpylib._src.utility import format_obj_input +def subdivide_mesh_by_facecolor(trace): + """Subdivide a mesh into a list of meshes based on facecolor""" + # TODO so far the function keeps all x,y,z coords for all subtraces, which is convienient since + # it does not require to recalculate the indices i,j,k. If many different colors, this is + # become inpractical. + facecolor = trace["facecolor"] + subtraces = [] + last_ind = 0 + prev_color = facecolor[0] + # pylint: disable=singleton-comparison + facecolor[facecolor == None] = "black" + for ind, color in enumerate(facecolor): + if color != prev_color or ind == len(facecolor) - 1: + new_trace = trace.copy() + for k in "ijk": + new_trace[k] = trace[k][last_ind:ind] + new_trace["color"] = prev_color + last_ind = ind + prev_color = color + subtraces.append(new_trace) + return subtraces + + def generic_trace_to_matplotlib(trace): """Transform a generic trace into a matplotlib trace""" + traces_mpl = [] if trace["type"] == "mesh3d": - x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) - triangles = np.array([trace[k] for k in "ijk"]).T - trace_mpl = { - "constructor": "plot_trisurf", - "args": (x, y, z), - "kwargs": { - "triangles": triangles, - "alpha": trace.get("opacity", None), - "color": trace.get("color", None), - }, - } + subtraces = [trace] + if trace.get("facecolor", None) is not None: + subtraces = subdivide_mesh_by_facecolor(trace) + for subtrace in subtraces: + x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) + triangles = np.array([subtrace[k] for k in "ijk"]).T + trace_mpl = { + "constructor": "plot_trisurf", + "args": (x, y, z), + "kwargs": { + "triangles": triangles, + "alpha": subtrace.get("opacity", None), + "color": subtrace.get("color", None), + }, + } + traces_mpl.append(trace_mpl) elif trace["type"] == "scatter3d": x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) props = { - k[0]: trace.get(k[1], {}).get(k[2], trace.get(k, None)) - for k in ( - ("ls", "line", "dash"), - ("lw", "line", "width"), - ("color", "line", "color"), - ("marker", "marker", "symbol"), - ("mfc", "marker", "color"), - ("mec", "marker", "color"), - ("ms", "marker", "size"), - ) + k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None)) + for k, v in { + "ls": ("line", "dash"), + "lw": ("line", "width"), + "color": ("line", "color"), + "marker": ("marker", "symbol"), + "mfc": ("marker", "color"), + "mec": ("marker", "color"), + "ms": ("marker", "size"), + }.items() } trace_mpl = { "constructor": "plot", "args": (x, y, z), "kwargs": { **{k: v for k, v in props.items() if v is not None}, - "alpha": trace.get("opacity", None), + "alpha": trace.get("opacity", 1), }, } + traces_mpl.append(trace_mpl) else: raise ValueError( - f"Trace type {trace['type']!r} cannot be transformed into pyvista trace" + f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" ) - return trace_mpl + return traces_mpl def display_matplotlib_auto( @@ -116,14 +146,20 @@ def display_matplotlib_auto( obj_list = list(obj_list) + [MagpyMarkers(*markers)] generic_traces, ranges = draw_frame( - obj_list, colorsequence, zoom, output="list", return_ranges=True, **kwargs + obj_list, + colorsequence, + zoom, + output="list", + return_ranges=True, + mag_arrows=True, + **kwargs, ) for tr in generic_traces: - tr1 = generic_trace_to_matplotlib(tr) - constructor = tr1["constructor"] - args = tr1["args"] - kwargs = tr1["kwargs"] - getattr(canvas, constructor)(*args, **kwargs) + for tr1 in generic_trace_to_matplotlib(tr): + constructor = tr1["constructor"] + args = tr1["args"] + kwargs = tr1["kwargs"] + getattr(canvas, constructor)(*args, **kwargs) canvas.set( **{f"{k}label": f"{k} [mm]" for k in "xyz"}, **{f"{k}lim": r for k, r in zip("xyz", ranges)}, From 7e362e28966921cc19c9decdb21403eba9d69133 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 20 Jun 2022 18:39:51 +0200 Subject: [PATCH 124/207] remove bad import --- magpylib/_src/display/backend_pyvista.py | 1 - 1 file changed, 1 deletion(-) diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index d460ba316..372bc5d66 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -1,6 +1,5 @@ import warnings from functools import lru_cache -from attr import frozen import numpy as np From 5d750ca398c1169255be86ff41e8e6cbd2b6c2be Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 20 Jun 2022 18:40:12 +0200 Subject: [PATCH 125/207] add mag arrows for generic traces --- magpylib/_src/display/display_utility.py | 23 +++++--- magpylib/_src/display/traces_generic.py | 67 ++++++++++++++++++++++-- 2 files changed, 80 insertions(+), 10 deletions(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index ec894baa4..be673c73b 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -96,7 +96,9 @@ def place_and_orient_model3d( return out[0] if len(out) == 1 else out -def draw_arrowed_line(vec, pos, sign=1, arrow_size=1) -> Tuple: +def draw_arrowed_line( + vec, pos, sign=1, arrow_size=1, arrow_pos=0.5, pivot="middle" +) -> Tuple: """ Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and @@ -108,21 +110,30 @@ def draw_arrowed_line(vec, pos, sign=1, arrow_size=1) -> Tuple: cross = np.cross(nvec, yaxis) dot = np.dot(nvec, yaxis) n = np.linalg.norm(cross) + arrow_shift = arrow_pos - 0.5 if dot == -1: sign *= -1 hy = sign * 0.1 * arrow_size hx = 0.06 * arrow_size + anchor = ( + (0, -0.5, 0) + if pivot == "tip" + else (0, 0.5, 0) + if pivot == "tail" + else (0, 0, 0) + ) arrow = ( np.array( [ [0, -0.5, 0], - [0, 0, 0], - [-hx, 0 - hy, 0], - [0, 0, 0], - [hx, 0 - hy, 0], - [0, 0, 0], + [0, arrow_shift, 0], + [-hx, arrow_shift - hy, 0], + [0, arrow_shift, 0], + [hx, arrow_shift - hy, 0], + [0, arrow_shift, 0], [0, 0.5, 0], ] + + np.array(anchor) ) * norm ) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 3acf8c302..435feb2e5 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -10,7 +10,10 @@ from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.display.display_utility import draw_arrow_from_vertices +from magpylib._src.display.display_utility import ( + draw_arrow_from_vertices, + draw_arrowed_line, +) from magpylib._src.display.display_utility import draw_arrowed_circle from magpylib._src.display.display_utility import get_flatten_objects_properties from magpylib._src.display.display_utility import get_rot_pos_from_path @@ -458,6 +461,55 @@ def get_name_and_suffix(default_name, default_suffix, style): return name, name_suffix +def make_mag_arrows(obj, style, legendgroup, kwargs): + """draw direction of magnetization of faced magnets + + Parameters + ---------- + - faced_objects(list of src objects): with magnetization vector to be drawn + - colors: colors of faced_objects + - show_path(bool or int): draw on every position where object is displayed + """ + # pylint: disable=protected-access + + # add src attributes position and orientation depending on show_path + rots, _, inds = get_rot_pos_from_path(obj, style.path.frames) + + # vector length, color and magnetization + if obj._object_type in ("Cuboid", "Cylinder"): + length = 1.8 * np.amax(obj.dimension) + elif obj._object_type == "CylinderSegment": + length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h + else: + length = 1.8 * obj.diameter # Sphere + length *= style.magnetization.size + mag = obj.magnetization + # collect all draw positions and directions + points = [] + for rot, ind in zip(rots, inds): + pos = getattr(obj, "_barycenter", obj._position)[ind] + direc = mag / (np.linalg.norm(mag) + 1e-6) * length + vec = rot.apply(direc) + pts = draw_arrowed_line(vec, pos, sign=1, arrow_pos=1, pivot="tail") + points.append(pts) + # insert empty point to avoid connecting line between arrows + points = np.array(points) + points = np.insert(points, points.shape[-1], np.nan, axis=2) + x, y, z = np.concatenate(points.swapaxes(1, 2)).T + trace = { + "type": "scatter3d", + "mode": "lines", + "line_color": kwargs["color"], + "opacity": kwargs["opacity"], + "x": x, + "y": y, + "z": z, + "legendgroup": legendgroup, + "showlegend": False, + } + return trace + + def get_generic_traces( input_obj, color=None, @@ -465,6 +517,7 @@ def get_generic_traces( legendgroup=None, showlegend=None, legendtext=None, + mag_arrows=False, **kwargs, ) -> list: """ @@ -666,6 +719,9 @@ def get_generic_traces( scatter_path = make_path(input_obj, style, legendgroup, kwargs) traces.append(scatter_path) + if mag_arrows and getattr(input_obj, "magnetization", None) is not None: + traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) + return traces @@ -707,6 +763,7 @@ def draw_frame( autosize=None, output="dict", return_ranges=False, + mag_arrows=False, **kwargs, ) -> Tuple: """ @@ -740,7 +797,7 @@ def draw_frame( x, y, z = obj._position.T traces_out[obj] = [dict(x=x, y=y, z=z)] else: - traces_out[obj] = get_generic_traces(obj, **params) + traces_out[obj] = get_generic_traces(obj, mag_arrows=mag_arrows, **params) traces = [t for tr in traces_out.values() for t in tr] ranges = get_scene_ranges(*traces, zoom=zoom) if autosize is None or autosize == "return": @@ -748,7 +805,9 @@ def draw_frame( return_autosize = True autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor for obj, params in traces_to_resize.items(): - traces_out[obj] = get_generic_traces(obj, autosize=autosize, **params) + traces_out[obj] = get_generic_traces( + obj, autosize=autosize, mag_arrows=mag_arrows, **params + ) if output == "list": traces = [t for tr in traces_out.values() for t in tr] traces_out = group_traces(*traces) @@ -766,7 +825,7 @@ def group_traces(*traces): mesh_groups = {} common_keys = ["legendgroup", "opacity"] # TODO grouping does not dectect line_width vs line=dict(with=...) - spec_keys = {"mesh3d": ["colorscale"], "scatter3d": ["marker", "line"]} + spec_keys = {"mesh3d": ["colorscale"], "scatter3d": ["marker", "line", "mode"]} for tr in traces: gr = [tr["type"]] for k in common_keys + spec_keys[tr["type"]]: From 5fb470b8a32d92055056976deba953509180aaf4 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 20 Jun 2022 18:41:30 +0200 Subject: [PATCH 126/207] run precommit --- magpylib/_src/display/display_utility.py | 2 +- magpylib/_src/display/traces_generic.py | 6 ++---- tests/test_default_utils.py | 2 +- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index be673c73b..f921bd2c9 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -1,7 +1,7 @@ """ Display function codes""" +from functools import lru_cache from itertools import cycle from typing import Tuple -from functools import lru_cache import numpy as np from scipy.spatial.transform import Rotation as RotScipy diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 435feb2e5..89dca1431 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -10,11 +10,9 @@ from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.display.display_utility import ( - draw_arrow_from_vertices, - draw_arrowed_line, -) +from magpylib._src.display.display_utility import draw_arrow_from_vertices from magpylib._src.display.display_utility import draw_arrowed_circle +from magpylib._src.display.display_utility import draw_arrowed_line from magpylib._src.display.display_utility import get_flatten_objects_properties from magpylib._src.display.display_utility import get_rot_pos_from_path from magpylib._src.display.display_utility import getColorscale diff --git a/tests/test_default_utils.py b/tests/test_default_utils.py index c43afd207..1f0c4d63e 100644 --- a/tests/test_default_utils.py +++ b/tests/test_default_utils.py @@ -107,7 +107,7 @@ def test_linearize_dict(): ((127, 127, 127), True, "#7f7f7f"), ("rgb(127, 127, 127)", True, "#7f7f7f"), ((0, 0, 0, 0), False, "#000000"), - ((.1, .2, .3), False, "#19334c"), + ((0.1, 0.2, 0.3), False, "#19334c"), ] + [(shortC, True, longC) for shortC, longC in COLORS_MATPLOTLIB_TO_PLOTLY.items()], ) From 086c6f7faeb4bcdadd147ed59c9967304fe400c8 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Tue, 21 Jun 2022 01:39:58 +0200 Subject: [PATCH 127/207] fix subdivide mesh by facecolor --- .../_src/display/backend_matplotlib_auto.py | 24 +------------------ magpylib/_src/display/traces_generic.py | 23 ++++++++++++++++++ 2 files changed, 24 insertions(+), 23 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py index fce77b27a..e46b660da 100644 --- a/magpylib/_src/display/backend_matplotlib_auto.py +++ b/magpylib/_src/display/backend_matplotlib_auto.py @@ -5,33 +5,11 @@ from magpylib._src.display.display_utility import MagpyMarkers from magpylib._src.display.traces_generic import draw_frame +from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor # from magpylib._src.utility import format_obj_input -def subdivide_mesh_by_facecolor(trace): - """Subdivide a mesh into a list of meshes based on facecolor""" - # TODO so far the function keeps all x,y,z coords for all subtraces, which is convienient since - # it does not require to recalculate the indices i,j,k. If many different colors, this is - # become inpractical. - facecolor = trace["facecolor"] - subtraces = [] - last_ind = 0 - prev_color = facecolor[0] - # pylint: disable=singleton-comparison - facecolor[facecolor == None] = "black" - for ind, color in enumerate(facecolor): - if color != prev_color or ind == len(facecolor) - 1: - new_trace = trace.copy() - for k in "ijk": - new_trace[k] = trace[k][last_ind:ind] - new_trace["color"] = prev_color - last_ind = ind - prev_color = color - subtraces.append(new_trace) - return subtraces - - def generic_trace_to_matplotlib(trace): """Transform a generic trace into a matplotlib trace""" traces_mpl = [] diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 89dca1431..5f70f9bcc 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -847,6 +847,29 @@ def group_traces(*traces): return traces +def subdivide_mesh_by_facecolor(trace): + """Subdivide a mesh into a list of meshes based on facecolor""" + facecolor = trace["facecolor"] + subtraces = [] + # pylint: disable=singleton-comparison + facecolor[facecolor == None] = "black" + for color in np.unique(facecolor): + mask = facecolor == color + new_trace = trace.copy() + uniq = np.unique(np.hstack([trace[k][mask] for k in "ijk"])) + new_inds = np.arange(len(uniq)) + mapping_ar = np.zeros(uniq.max() + 1, dtype=new_inds.dtype) + mapping_ar[uniq] = new_inds + for k in "ijk": + new_trace[k] = mapping_ar[trace[k][mask]] + for k in "xyz": + new_trace[k] = new_trace[k][uniq] + new_trace["color"] = color + new_trace.pop("facecolor") + subtraces.append(new_trace) + return subtraces + + def apply_fig_ranges(fig, ranges=None, zoom=None): """This is a helper function which applies the ranges properties of the provided `fig` object according to a certain zoom level. All three space direction will be equal and match the From cf3a5d291f1d4c530dfeedd5e2365f41f3e504b7 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 21 Jun 2022 13:32:25 +0200 Subject: [PATCH 128/207] renaming --- magpylib/_src/display/backend_matplotlib.py | 22 ++++++++--------- .../_src/display/backend_matplotlib_auto.py | 2 +- magpylib/_src/display/backend_plotly.py | 2 +- magpylib/_src/display/backend_pyvista.py | 2 +- magpylib/_src/display/traces_base.py | 4 ++-- magpylib/_src/display/traces_generic.py | 24 +++++++++---------- .../{display_utility.py => traces_utility.py} | 0 tests/test_display_utility.py | 2 +- 8 files changed, 29 insertions(+), 29 deletions(-) rename magpylib/_src/display/{display_utility.py => traces_utility.py} (100%) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 1c4d36302..69eda1177 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -6,17 +6,17 @@ from mpl_toolkits.mplot3d.art3d import Poly3DCollection from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.display_utility import draw_arrow_from_vertices -from magpylib._src.display.display_utility import draw_arrowed_circle -from magpylib._src.display.display_utility import faces_cuboid -from magpylib._src.display.display_utility import faces_cylinder -from magpylib._src.display.display_utility import faces_cylinder_segment -from magpylib._src.display.display_utility import faces_sphere -from magpylib._src.display.display_utility import get_flatten_objects_properties -from magpylib._src.display.display_utility import get_rot_pos_from_path -from magpylib._src.display.display_utility import MagpyMarkers -from magpylib._src.display.display_utility import place_and_orient_model3d -from magpylib._src.display.display_utility import system_size +from magpylib._src.display.traces_utility import draw_arrow_from_vertices +from magpylib._src.display.traces_utility import draw_arrowed_circle +from magpylib._src.display.traces_utility import faces_cuboid +from magpylib._src.display.traces_utility import faces_cylinder +from magpylib._src.display.traces_utility import faces_cylinder_segment +from magpylib._src.display.traces_utility import faces_sphere +from magpylib._src.display.traces_utility import get_flatten_objects_properties +from magpylib._src.display.traces_utility import get_rot_pos_from_path +from magpylib._src.display.traces_utility import MagpyMarkers +from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.traces_utility import system_size from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py index e46b660da..1f2f9d5a5 100644 --- a/magpylib/_src/display/backend_matplotlib_auto.py +++ b/magpylib/_src/display/backend_matplotlib_auto.py @@ -3,9 +3,9 @@ import matplotlib.pyplot as plt import numpy as np -from magpylib._src.display.display_utility import MagpyMarkers from magpylib._src.display.traces_generic import draw_frame from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor +from magpylib._src.display.traces_utility import MagpyMarkers # from magpylib._src.utility import format_obj_input diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index 287fd9cc7..0d5d66315 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -15,7 +15,7 @@ import numpy as np from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.utility import format_obj_input -from magpylib._src.display.display_utility import clean_legendgroups +from magpylib._src.display.traces_utility import clean_legendgroups from magpylib._src.display.traces_generic import ( draw_frame, apply_fig_ranges, diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index 372bc5d66..f91342f30 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -14,7 +14,7 @@ from pyvista.plotting.colors import Color from matplotlib.colors import LinearSegmentedColormap from magpylib._src.display.traces_generic import draw_frame -from magpylib._src.display.display_utility import MagpyMarkers +from magpylib._src.display.traces_utility import MagpyMarkers # from magpylib._src.utility import format_obj_input diff --git a/magpylib/_src/display/traces_base.py b/magpylib/_src/display/traces_base.py index 0487690b1..f8a7ecf96 100644 --- a/magpylib/_src/display/traces_base.py +++ b/magpylib/_src/display/traces_base.py @@ -3,8 +3,8 @@ import numpy as np -from magpylib._src.display.display_utility import merge_mesh3d -from magpylib._src.display.display_utility import place_and_orient_model3d +from magpylib._src.display.traces_utility import merge_mesh3d +from magpylib._src.display.traces_utility import place_and_orient_model3d def base_validator(name, value, conditions): diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 5f70f9bcc..b0890111c 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -1,4 +1,4 @@ -""" plotly draw-functionalities""" +"""Generic trace drawing functionalities""" # pylint: disable=C0302 # pylint: disable=too-many-branches from itertools import combinations @@ -10,17 +10,6 @@ from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.display.display_utility import draw_arrow_from_vertices -from magpylib._src.display.display_utility import draw_arrowed_circle -from magpylib._src.display.display_utility import draw_arrowed_line -from magpylib._src.display.display_utility import get_flatten_objects_properties -from magpylib._src.display.display_utility import get_rot_pos_from_path -from magpylib._src.display.display_utility import getColorscale -from magpylib._src.display.display_utility import getIntensity -from magpylib._src.display.display_utility import MagpyMarkers -from magpylib._src.display.display_utility import merge_mesh3d -from magpylib._src.display.display_utility import merge_traces -from magpylib._src.display.display_utility import place_and_orient_model3d from magpylib._src.display.sensor_mesh import get_sensor_mesh from magpylib._src.display.traces_base import make_Arrow as make_BaseArrow from magpylib._src.display.traces_base import make_Cuboid as make_BaseCuboid @@ -29,6 +18,17 @@ ) from magpylib._src.display.traces_base import make_Ellipsoid as make_BaseEllipsoid from magpylib._src.display.traces_base import make_Prism as make_BasePrism +from magpylib._src.display.traces_utility import draw_arrow_from_vertices +from magpylib._src.display.traces_utility import draw_arrowed_circle +from magpylib._src.display.traces_utility import draw_arrowed_line +from magpylib._src.display.traces_utility import get_flatten_objects_properties +from magpylib._src.display.traces_utility import get_rot_pos_from_path +from magpylib._src.display.traces_utility import getColorscale +from magpylib._src.display.traces_utility import getIntensity +from magpylib._src.display.traces_utility import MagpyMarkers +from magpylib._src.display.traces_utility import merge_mesh3d +from magpylib._src.display.traces_utility import merge_traces +from magpylib._src.display.traces_utility import place_and_orient_model3d from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style from magpylib._src.utility import unit_prefix diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/traces_utility.py similarity index 100% rename from magpylib/_src/display/display_utility.py rename to magpylib/_src/display/traces_utility.py diff --git a/tests/test_display_utility.py b/tests/test_display_utility.py index 3f7c2581d..fd9f96e12 100644 --- a/tests/test_display_utility.py +++ b/tests/test_display_utility.py @@ -2,7 +2,7 @@ import pytest import magpylib as magpy -from magpylib._src.display.display_utility import draw_arrow_from_vertices +from magpylib._src.display.traces_utility import draw_arrow_from_vertices from magpylib._src.exceptions import MagpylibBadUserInput From 775e814d2e914676d286528b8e1e99b293239a51 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 21 Jun 2022 16:18:18 +0200 Subject: [PATCH 129/207] make animation generic --- magpylib/_src/display/backend_plotly.py | 231 ++++++------------------ magpylib/_src/display/traces_generic.py | 218 ++++++++++++++++++---- magpylib/_src/display/traces_utility.py | 15 -- 3 files changed, 241 insertions(+), 223 deletions(-) diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index 0d5d66315..0426ce3bc 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -1,8 +1,6 @@ """ plotly draw-functionalities""" # pylint: disable=C0302 # pylint: disable=too-many-branches -import numbers -import warnings try: import plotly.graph_objects as go @@ -12,13 +10,9 @@ see https://github.com/plotly/plotly.py""" ) from missing_module -import numpy as np from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.utility import format_obj_input -from magpylib._src.display.traces_utility import clean_legendgroups from magpylib._src.display.traces_generic import ( - draw_frame, - apply_fig_ranges, + get_frames, MagpyMarkers, ) from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY @@ -26,110 +20,46 @@ from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY -def animate_path( - fig, - objs, - colorsequence=None, - zoom=1, - title="3D-Paths Animation", - animation_time=3, - animation_fps=30, - animation_maxfps=50, - animation_maxframes=200, - animation_slider=False, - **kwargs, -): - """This is a helper function which attaches plotly frames to the provided `fig` object +def apply_fig_ranges(fig, ranges, zoom=None): + """This is a helper function which applies the ranges properties of the provided `fig` object according to a certain zoom level. All three space direction will be equal and match the maximum of the ranges needed to display all objects, including their paths. Parameters ---------- - animation_time: float, default = 3 - Sets the animation duration - - animation_fps: float, default = 30 - This sets the maximum allowed frame rate. In case of path positions needed to be displayed - exceeds the `animation_fps` the path position will be downsampled to be lower or equal - the `animation_fps`. This is mainly depending on the pc/browser performance and is set to - 50 by default to avoid hanging the animation process. - - animation_slider: bool, default = False - if True, an interactive slider will be displayed and stay in sync with the animation + ranges: array of dimension=(3,2) + min and max graph range - title: str, default = "3D-Paths Animation" + zoom: float, default = 1 When zoom=0 all objects are just inside the 3D-axes. - colorsequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - Returns ------- - None: NoneTyp + None: NoneType """ - # make sure the number of frames does not exceed the max frames and max frame rate - # downsample if necessary - path_lengths = [] - for obj in objs: - subobjs = [obj] - if getattr(obj, "_object_type", None) == "Collection": - subobjs.extend(obj.children) - for subobj in subobjs: - path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] - path_lengths.append(path_len) - - max_pl = max(path_lengths) - if animation_fps > animation_maxfps: - warnings.warn( - f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" - f" {animation_maxfps}. `animation_fps` will be set to {animation_maxfps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxfps`" - ) - animation_fps = animation_maxfps - - maxpos = min(animation_time * animation_fps, animation_maxframes) - - if max_pl <= maxpos: - path_indices = np.arange(max_pl) - else: - round_step = max_pl / (maxpos - 1) - ar = np.linspace(0, max_pl, max_pl, endpoint=False) - path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( - int - ) # downsampled indices - path_indices[-1] = ( - max_pl - 1 - ) # make sure the last frame is the last path position - - # calculate exponent of last frame index to avoid digit shift in - # frame number display during animation - exp = ( - np.log10(path_indices.max()).astype(int) + 1 - if path_indices.ndim != 0 and path_indices.max() > 0 - else 1 + fig.update_scenes( + **{ + f"{k}axis": dict(range=ranges[i], autorange=False, title=f"{k} [mm]") + for i, k in enumerate("xyz") + }, + aspectratio={k: 1 for k in "xyz"}, + aspectmode="manual", + camera_eye={"x": 1, "y": -1.5, "z": 1.4}, ) - frame_duration = int(animation_time * 1000 / path_indices.shape[0]) - new_fps = int(1000 / frame_duration) - if max_pl > animation_maxframes: - warnings.warn( - f"The number of frames ({max_pl}) is greater than the max allowed " - f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxframes`" - ) +def animate_path( + fig, + frames, + path_indices, + frame_duration, + animation_slider=False, +): + """This is a helper function which attaches plotly frames to the provided `fig` object + according to a certain zoom level. All three space direction will be equal and match the + maximum of the ranges needed to display all objects, including their paths. + """ + fps = int(1000 / frame_duration) if animation_slider: sliders_dict = { "active": 0, @@ -137,7 +67,7 @@ def animate_path( "font": {"size": 10}, "xanchor": "left", "currentvalue": { - "prefix": f"Fps={new_fps}, Path index: ", + "prefix": f"Fps={fps}, Path index: ", "visible": True, "xanchor": "right", }, @@ -178,30 +108,7 @@ def animate_path( "yanchor": "top", } - # create frame for each path index or downsampled path index - frames = [] - autosize = "return" - for i, ind in enumerate(path_indices): - kwargs["style_path_frames"] = [ind] - frame = draw_frame( - objs, - colorsequence, - zoom, - autosize=autosize, - output="list", - **kwargs, - ) - if i == 0: # get the dipoles and sensors autosize from first frame - traces, autosize = frame - else: - traces = frame - frames.append( - go.Frame( - data=[generic_trace_to_plotly(trace) for trace in traces], - name=str(ind + 1), - layout=dict(title=f"""{title} - path index: {ind+1:0{exp}d}"""), - ) - ) + for ind in path_indices: if animation_slider: slider_step = { "args": [ @@ -218,14 +125,15 @@ def animate_path( # update fig fig.frames = frames - fig.add_traces(frames[0].data) + frame0 = fig.frames[0] + fig.add_traces(frame0.data) + title = frame0.layout.title.text fig.update_layout( height=None, title=title, updatemenus=[buttons_dict], sliders=[sliders_dict] if animation_slider else None, ) - apply_fig_ranges(fig, zoom=zoom) def generic_trace_to_plotly(trace): @@ -285,8 +193,8 @@ def display_plotly( 'pdf', 'browser', 'firefox', 'chrome', 'chromium', 'iframe', 'iframe_connected', 'sphinx_gallery', 'sphinx_gallery_png'] - title: str, default = "3D-Paths Animation" - When zoom=0 all objects are just inside the 3D-axes. + title: str, default=None + Plot title. colorsequence: list or array_like, iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', @@ -306,70 +214,41 @@ def display_plotly( None: NoneType """ - flat_obj_list = format_obj_input(obj_list) - show_canvas = False if canvas is None: show_canvas = True canvas = go.Figure() - # set animation and animation_time - if isinstance(animation, numbers.Number) and not isinstance(animation, bool): - kwargs["animation_time"] = animation - animation = True - if ( - not any( - getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list - ) - and animation is not False - ): # check if some path exist for any object - animation = False - warnings.warn("No path to be animated detected, displaying standard plot") - - animation_kwargs = { - k: v for k, v in kwargs.items() if k.split("_")[0] == "animation" - } - if animation is False: - kwargs = {k: v for k, v in kwargs.items() if k not in animation_kwargs} - else: - for k, v in Config.display.animation.as_dict().items(): - anim_key = f"animation_{k}" - if kwargs.get(anim_key, None) is None: - kwargs[anim_key] = v - - if obj_list: - style = getattr(obj_list[0], "style", None) - label = getattr(style, "label", None) - title = label if len(obj_list) == 1 else None - else: - title = "No objects to be displayed" - if markers is not None and markers: obj_list = list(obj_list) + [MagpyMarkers(*markers)] if colorsequence is None: colorsequence = Config.display.colorsequence + data = get_frames( + objs=obj_list, + colorsequence=colorsequence, + zoom=zoom, + animation=animation, + **kwargs, + ) + frames = data["frames"] + for fr in frames: + for tr in fr["data"]: + tr = generic_trace_to_plotly(tr) with canvas.batch_update(): - if animation is not False: - title = "3D-Paths Animation" if title is None else title - animate_path( - fig=canvas, - objs=obj_list, - colorsequence=colorsequence, - zoom=zoom, - title=title, - **kwargs, - ) + if len(frames) == 1: + canvas.add_traces(frames[0]["data"]) else: - generic_traces = draw_frame( - obj_list, colorsequence, zoom, output="list", **kwargs + animation_slider = data.get("animation_slider", False) + animate_path( + canvas, + frames, + data["path_indices"], + data["frame_duration"], + animation_slider=animation_slider, ) - traces = [generic_trace_to_plotly(trace) for trace in generic_traces] - canvas.add_traces(traces) - canvas.update_layout(title_text=title) - apply_fig_ranges(canvas, zoom=zoom) - clean_legendgroups(canvas) + apply_fig_ranges(canvas, data["ranges"]) canvas.update_layout(legend_itemsizing="constant") if show_canvas: canvas.show(renderer=renderer) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index b0890111c..a481d6bc9 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -1,6 +1,8 @@ """Generic trace drawing functionalities""" # pylint: disable=C0302 # pylint: disable=too-many-branches +import numbers +import warnings from itertools import combinations from typing import Tuple @@ -31,6 +33,7 @@ from magpylib._src.display.traces_utility import place_and_orient_model3d from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style +from magpylib._src.utility import format_obj_input from magpylib._src.utility import unit_prefix @@ -870,38 +873,6 @@ def subdivide_mesh_by_facecolor(trace): return subtraces -def apply_fig_ranges(fig, ranges=None, zoom=None): - """This is a helper function which applies the ranges properties of the provided `fig` object - according to a certain zoom level. All three space direction will be equal and match the - maximum of the ranges needed to display all objects, including their paths. - - Parameters - ---------- - ranges: array of dimension=(3,2) - min and max graph range - - zoom: float, default = 1 - When zoom=0 all objects are just inside the 3D-axes. - - Returns - ------- - None: NoneType - """ - if ranges is None: - frames = fig.frames if fig.frames else [fig] - traces = [t for frame in frames for t in frame.data] - ranges = get_scene_ranges(*traces, zoom=zoom) - fig.update_scenes( - **{ - f"{k}axis": dict(range=ranges[i], autorange=False, title=f"{k} [mm]") - for i, k in enumerate("xyz") - }, - aspectratio={k: 1 for k in "xyz"}, - aspectmode="manual", - camera_eye={"x": 1, "y": -1.5, "z": 1.4}, - ) - - def get_scene_ranges(*traces, zoom=1) -> np.ndarray: """ Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be @@ -926,3 +897,186 @@ def get_scene_ranges(*traces, zoom=1) -> np.ndarray: else: ranges = np.array([[-1.0, 1.0]] * 3) return ranges + + +def process_animation_kwargs(obj_list, animation=False, **kwargs): + """Update animation kwargs""" + markers = [o for o in obj_list if isinstance(o, MagpyMarkers)] + flat_obj_list = format_obj_input([o for o in obj_list if o not in markers]) + flat_obj_list.extend(markers) + # set animation and animation_time + if isinstance(animation, numbers.Number) and not isinstance(animation, bool): + kwargs["animation_time"] = animation + animation = True + if ( + not any( + getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list + ) + and animation is not False + ): # check if some path exist for any object + animation = False + warnings.warn("No path to be animated detected, displaying standard plot") + + anim_def = Config.display.animation.copy() + anim_def.update(kwargs) + animation_kwargs = {f"animation_{k}": v for k, v in anim_def.as_dict().items()} + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")} + return kwargs, animation, animation_kwargs + + +def clean_legendgroups(frames): + """removes legend duplicates for a plotly figure""" + for fr in frames: + legendgroups = [] + for tr in fr["data"]: + lg = tr.get("legendgroup", None) + if lg is not None and lg not in legendgroups: + legendgroups.append(lg) + elif lg is not None: # and tr.legendgrouptitle.text is None: + tr["showlegend"] = False + + +def extract_animation_properties( + objs, + *, + animation_maxfps, + animation_time, + animation_fps, + animation_maxframes, + # pylint: disable=unused-argument + animation_slider, +): + """Exctract animation properties""" + path_lengths = [] + for obj in objs: + subobjs = [obj] + if getattr(obj, "_object_type", None) == "Collection": + subobjs.extend(obj.children) + for subobj in subobjs: + path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] + path_lengths.append(path_len) + + max_pl = max(path_lengths) + if animation_fps > animation_maxfps: + warnings.warn( + f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" + f" {animation_maxfps}. `animation_fps` will be set to" + f" {animation_maxfps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxfps`" + ) + animation_fps = animation_maxfps + + maxpos = min(animation_time * animation_fps, animation_maxframes) + + if max_pl <= maxpos: + path_indices = np.arange(max_pl) + else: + round_step = max_pl / (maxpos - 1) + ar = np.linspace(0, max_pl, max_pl, endpoint=False) + path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( + int + ) # downsampled indices + path_indices[-1] = ( + max_pl - 1 + ) # make sure the last frame is the last path position + + # calculate exponent of last frame index to avoid digit shift in + # frame number display during animation + exp = ( + np.log10(path_indices.max()).astype(int) + 1 + if path_indices.ndim != 0 and path_indices.max() > 0 + else 1 + ) + + frame_duration = int(animation_time * 1000 / path_indices.shape[0]) + new_fps = int(1000 / frame_duration) + if max_pl > animation_maxframes: + warnings.warn( + f"The number of frames ({max_pl}) is greater than the max allowed " + f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxframes`" + ) + + return path_indices, exp, frame_duration + + +def get_frames( + objs, + colorsequence=None, + zoom=1, + title=None, + animation=False, + mag_arrows=False, + **kwargs, +): + """This is a helper function which generates frames with generic traces to be provided to + the chosen backend. According to a certain zoom level, all three space direction will be equal + and match the maximum of the ranges needed to display all objects, including their paths. + """ + # infer title if necessary + if objs: + style = getattr(objs[0], "style", None) + label = getattr(style, "label", None) + title = label if len(objs) == 1 else None + else: + title = "No objects to be displayed" + + # make sure the number of frames does not exceed the max frames and max frame rate + # downsample if necessary + kwargs, animation, animation_kwargs = process_animation_kwargs( + objs, animation=animation, **kwargs + ) + path_indices = [-1] + if animation: + path_indices, exp, frame_duration = extract_animation_properties( + objs, **animation_kwargs + ) + + # create frame for each path index or downsampled path index + frames = [] + autosize = "return" + title_str = title + for i, ind in enumerate(path_indices): + if animation: + kwargs["style_path_frames"] = [ind] + title = "Animation 3D - " if title is None else title + title_str = f"""{title}path index: {ind+1:0{exp}d}""" + frame = draw_frame( + objs, + colorsequence, + zoom, + autosize=autosize, + output="list", + mag_arrows=mag_arrows, + **kwargs, + ) + if i == 0: # get the dipoles and sensors autosize from first frame + traces, autosize = frame + else: + traces = frame + frames.append( + dict( + data=traces, + name=str(ind + 1), + layout=dict(title=title_str), + ) + ) + + clean_legendgroups(frames) + traces = [t for frame in frames for t in frame["data"]] + ranges = get_scene_ranges(*traces, zoom=zoom) + out = { + "frames": frames, + "ranges": ranges, + } + if animation: + out.update( + { + "frame_duration": frame_duration, + "path_indices": path_indices, + "animation_slider": animation_kwargs["animation_slider"], + } + ) + return out diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index f921bd2c9..0e202f267 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -645,18 +645,3 @@ def getColorscale( (1.0, color_north), ) return colorscale - - -def clean_legendgroups(fig): - """removes legend duplicates for a plotly figure""" - frames = [fig.data] - if fig.frames: - data_list = [f["data"] for f in fig.frames] - frames.extend(data_list) - for f in frames: - legendgroups = [] - for t in f: - if t.legendgroup not in legendgroups and t.legendgroup is not None: - legendgroups.append(t.legendgroup) - elif t.legendgroup is not None and t.legendgrouptitle.text is None: - t.showlegend = False From 6e8381d167aacd2a3e3fa3670122f35d6e54bf9c Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 21 Jun 2022 16:38:10 +0200 Subject: [PATCH 130/207] handle markers in main show function --- magpylib/_src/display/backend_matplotlib.py | 10 ++++++---- magpylib/_src/display/backend_matplotlib_auto.py | 12 ++++-------- magpylib/_src/display/backend_plotly.py | 12 +----------- magpylib/_src/display/backend_pyvista.py | 8 -------- magpylib/_src/display/display.py | 6 +++++- 5 files changed, 16 insertions(+), 32 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 69eda1177..e812065a0 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -367,6 +367,8 @@ def display_matplotlib( points = [] dipoles = [] sensors = [] + markers_list = [o for o in obj_list_semi_flat if isinstance(o, MagpyMarkers)] + obj_list_semi_flat = [o for o in obj_list_semi_flat if o not in markers_list] flat_objs_props = get_flatten_objects_properties( *obj_list_semi_flat, colorsequence=colorsequence ) @@ -461,10 +463,10 @@ def display_matplotlib( ) # markers ------------------------------------------------------- - if markers is not None and markers: - m = MagpyMarkers() - style = get_style(m, Config, **kwargs) - markers = np.array(markers) + if markers_list: + markers_instance = markers_list[0] + style = get_style(markers_instance, Config, **kwargs) + markers = np.array(markers_instance.markers) s = style.marker draw_markers(markers, ax, s.color, s.symbol, s.size) points += [markers] diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py index 1f2f9d5a5..a421454c1 100644 --- a/magpylib/_src/display/backend_matplotlib_auto.py +++ b/magpylib/_src/display/backend_matplotlib_auto.py @@ -5,7 +5,6 @@ from magpylib._src.display.traces_generic import draw_frame from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor -from magpylib._src.display.traces_utility import MagpyMarkers # from magpylib._src.utility import format_obj_input @@ -32,6 +31,7 @@ def generic_trace_to_matplotlib(trace): traces_mpl.append(trace_mpl) elif trace["type"] == "scatter3d": x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) + mode = trace.get("mode", None) props = { k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None)) for k, v in { @@ -44,6 +44,9 @@ def generic_trace_to_matplotlib(trace): "ms": ("marker", "size"), }.items() } + if mode is not None and "lines" not in mode: + props["ls"] = "" + trace_mpl = { "constructor": "plot", "args": (x, y, z), @@ -62,7 +65,6 @@ def generic_trace_to_matplotlib(trace): def display_matplotlib_auto( *obj_list, - markers=None, zoom=1, canvas=None, animation=False, @@ -78,9 +80,6 @@ def display_matplotlib_auto( objects: sources, collections or sensors Objects to be displayed. - markers: array_like, None, shape (N,3), default=None - Display position markers in the global CS. By default no marker is displayed. - zoom: float, default = 1 Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. @@ -120,9 +119,6 @@ def display_matplotlib_auto( canvas = fig.add_subplot(111, projection="3d") canvas.set_box_aspect((1, 1, 1)) - if markers is not None and markers: - obj_list = list(obj_list) + [MagpyMarkers(*markers)] - generic_traces, ranges = draw_frame( obj_list, colorsequence, diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index 0426ce3bc..b6c1bfb3f 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -11,10 +11,7 @@ ) from missing_module from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.traces_generic import ( - get_frames, - MagpyMarkers, -) +from magpylib._src.display.traces_generic import get_frames from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY @@ -154,7 +151,6 @@ def generic_trace_to_plotly(trace): def display_plotly( *obj_list, - markers=None, zoom=1, canvas=None, renderer=None, @@ -171,9 +167,6 @@ def display_plotly( objects: sources, collections or sensors Objects to be displayed. - markers: array_like, None, shape (N,3), default=None - Display position markers in the global CS. By default no marker is displayed. - zoom: float, default = 1 Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. @@ -219,9 +212,6 @@ def display_plotly( show_canvas = True canvas = go.Figure() - if markers is not None and markers: - obj_list = list(obj_list) + [MagpyMarkers(*markers)] - if colorsequence is None: colorsequence = Config.display.colorsequence diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index f91342f30..bc85c339c 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -14,7 +14,6 @@ from pyvista.plotting.colors import Color from matplotlib.colors import LinearSegmentedColormap from magpylib._src.display.traces_generic import draw_frame -from magpylib._src.display.traces_utility import MagpyMarkers # from magpylib._src.utility import format_obj_input @@ -105,7 +104,6 @@ def generic_trace_to_pyvista(trace): def display_pyvista( *obj_list, - markers=None, zoom=1, canvas=None, animation=False, @@ -121,9 +119,6 @@ def display_pyvista( objects: sources, collections or sensors Objects to be displayed. - markers: array_like, None, shape (N,3), default=None - Display position markers in the global CS. By default no marker is displayed. - zoom: float, default = 1 Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. @@ -161,9 +156,6 @@ def display_pyvista( show_canvas = True canvas = pv.Plotter() - if markers is not None and markers: - obj_list = list(obj_list) + [MagpyMarkers(*markers)] - generic_traces = draw_frame(obj_list, colorsequence, zoom, output="list", **kwargs) for tr0 in generic_traces: for tr1 in generic_trace_to_pyvista(tr0): diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index a6c749a1e..8becc1540 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -1,6 +1,7 @@ """ Display function codes""" from importlib import import_module +from magpylib._src.display.traces_generic import MagpyMarkers from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_backend @@ -125,9 +126,12 @@ def show( display_func = getattr( import_module(f"magpylib._src.display.backend_{backend}"), f"display_{backend}" ) + + if markers is not None and markers: + obj_list_semi_flat = list(obj_list_semi_flat) + [MagpyMarkers(*markers)] + display_func( *obj_list_semi_flat, - markers=markers, zoom=zoom, canvas=canvas, animation=animation, From 1f487a8312de7f13c49cdc53c4804688b45c54cd Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 21 Jun 2022 16:56:14 +0200 Subject: [PATCH 131/207] pylint --- magpylib/_src/display/backend_plotly.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index b6c1bfb3f..fce25ea60 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -17,7 +17,7 @@ from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY -def apply_fig_ranges(fig, ranges, zoom=None): +def apply_fig_ranges(fig, ranges): """This is a helper function which applies the ranges properties of the provided `fig` object according to a certain zoom level. All three space direction will be equal and match the maximum of the ranges needed to display all objects, including their paths. From d325e1ef6cad6e60a8052da5344c737d8458a828 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 21 Jun 2022 17:42:06 +0200 Subject: [PATCH 132/207] draft matplotlib_auto animation --- .../_src/display/backend_matplotlib_auto.py | 75 +++++++++++-------- 1 file changed, 45 insertions(+), 30 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py index a421454c1..38226c526 100644 --- a/magpylib/_src/display/backend_matplotlib_auto.py +++ b/magpylib/_src/display/backend_matplotlib_auto.py @@ -1,9 +1,8 @@ -import warnings - import matplotlib.pyplot as plt import numpy as np +from matplotlib.animation import FuncAnimation -from magpylib._src.display.traces_generic import draw_frame +from magpylib._src.display.traces_generic import get_frames from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor # from magpylib._src.utility import format_obj_input @@ -68,6 +67,7 @@ def display_matplotlib_auto( zoom=1, canvas=None, animation=False, + repeat=False, colorsequence=None, **kwargs, ): @@ -103,15 +103,20 @@ def display_matplotlib_auto( - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - A named CSS color """ + data = get_frames( + objs=obj_list, + colorsequence=colorsequence, + zoom=zoom, + animation=animation, + **kwargs, + ) + frames = data["frames"] + ranges = data["ranges"] - if animation is not False: - msg = "The matplotlib backend does not support animation at the moment.\n" - msg += "Use `backend=plotly` instead." - warnings.warn(msg) - # animation = False - - # flat_obj_list = format_obj_input(obj_list) - + for fr in frames: + fr["data"] = [ + tr0 for tr1 in fr["data"] for tr0 in generic_trace_to_matplotlib(tr1) + ] show_canvas = False if canvas is None: show_canvas = True @@ -119,25 +124,35 @@ def display_matplotlib_auto( canvas = fig.add_subplot(111, projection="3d") canvas.set_box_aspect((1, 1, 1)) - generic_traces, ranges = draw_frame( - obj_list, - colorsequence, - zoom, - output="list", - return_ranges=True, - mag_arrows=True, - **kwargs, - ) - for tr in generic_traces: - for tr1 in generic_trace_to_matplotlib(tr): - constructor = tr1["constructor"] - args = tr1["args"] - kwargs = tr1["kwargs"] + def draw_frame(ind): + for tr in frames[ind]["data"]: + constructor = tr["constructor"] + args = tr["args"] + kwargs = tr["kwargs"] getattr(canvas, constructor)(*args, **kwargs) - canvas.set( - **{f"{k}label": f"{k} [mm]" for k in "xyz"}, - **{f"{k}lim": r for k, r in zip("xyz", ranges)}, - ) - # apply_fig_ranges(canvas, zoom=zoom) + canvas.set( + **{f"{k}label": f"{k} [mm]" for k in "xyz"}, + **{f"{k}lim": r for k, r in zip("xyz", ranges)}, + ) + + def animate(ind): + plt.cla() + draw_frame(ind) + return [canvas] + + if len(frames) == 1: + draw_frame(0) + else: + # call the animator. blit=True means only re-draw the parts that have changed. + + anim = FuncAnimation( # pylint: disable=unused-variable + fig, + animate, + frames=range(len(frames)), + interval=100, + blit=False, + repeat=repeat, + ) + if show_canvas: plt.show() From ee338487d4681a2ff4a3c4df17ea4da2f0017cf3 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 22 Jun 2022 11:15:31 +0200 Subject: [PATCH 133/207] add return animation object option --- magpylib/_src/display/backend_matplotlib_auto.py | 11 ++++++----- magpylib/_src/display/display.py | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py index 38226c526..140fbe136 100644 --- a/magpylib/_src/display/backend_matplotlib_auto.py +++ b/magpylib/_src/display/backend_matplotlib_auto.py @@ -69,6 +69,7 @@ def display_matplotlib_auto( animation=False, repeat=False, colorsequence=None, + return_animation=False, **kwargs, ): @@ -108,6 +109,7 @@ def display_matplotlib_auto( colorsequence=colorsequence, zoom=zoom, animation=animation, + mag_arrows=True, **kwargs, ) frames = data["frames"] @@ -143,9 +145,7 @@ def animate(ind): if len(frames) == 1: draw_frame(0) else: - # call the animator. blit=True means only re-draw the parts that have changed. - - anim = FuncAnimation( # pylint: disable=unused-variable + anim = FuncAnimation( fig, animate, frames=range(len(frames)), @@ -153,6 +153,7 @@ def animate(ind): blit=False, repeat=repeat, ) - - if show_canvas: + if return_animation and len(frames) != 1: + return anim + elif show_canvas: plt.show() diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 8becc1540..c313923f0 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -130,7 +130,7 @@ def show( if markers is not None and markers: obj_list_semi_flat = list(obj_list_semi_flat) + [MagpyMarkers(*markers)] - display_func( + return display_func( *obj_list_semi_flat, zoom=zoom, canvas=canvas, From 2c72917017b5dd2376372d7efa8e32efa5e7814e Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 22 Jun 2022 16:20:45 +0200 Subject: [PATCH 134/207] archive matplotlib old --- magpylib/_src/defaults/defaults_utility.py | 2 +- magpylib/_src/display/backend_matplotlib.py | 607 +++----------- .../_src/display/backend_matplotlib_auto.py | 159 ---- .../_src/display/backend_matplotlib_old.py | 756 ++++++++++++++++++ magpylib/_src/display/backend_plotly.py | 48 +- magpylib/_src/display/backend_pyvista.py | 32 +- magpylib/_src/display/traces_utility.py | 259 ------ 7 files changed, 872 insertions(+), 991 deletions(-) delete mode 100644 magpylib/_src/display/backend_matplotlib_auto.py create mode 100644 magpylib/_src/display/backend_matplotlib_old.py diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index c8fc06ba9..05b1fa6cb 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -5,7 +5,7 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista", "matplotlib_auto") +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista", "matplotlib_old") MAGPYLIB_FAMILIES = { "Line": ("current",), diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index e812065a0..fcf9122cd 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -1,510 +1,129 @@ -""" matplotlib draw-functionalities""" -import warnings - import matplotlib.pyplot as plt import numpy as np -from mpl_toolkits.mplot3d.art3d import Poly3DCollection - -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.traces_utility import draw_arrow_from_vertices -from magpylib._src.display.traces_utility import draw_arrowed_circle -from magpylib._src.display.traces_utility import faces_cuboid -from magpylib._src.display.traces_utility import faces_cylinder -from magpylib._src.display.traces_utility import faces_cylinder_segment -from magpylib._src.display.traces_utility import faces_sphere -from magpylib._src.display.traces_utility import get_flatten_objects_properties -from magpylib._src.display.traces_utility import get_rot_pos_from_path -from magpylib._src.display.traces_utility import MagpyMarkers -from magpylib._src.display.traces_utility import place_and_orient_model3d -from magpylib._src.display.traces_utility import system_size -from magpylib._src.input_checks import check_excitations -from magpylib._src.style import get_style - - -def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): - """draw direction of magnetization of faced magnets - - Parameters - ---------- - - faced_objects(list of src objects): with magnetization vector to be drawn - - colors: colors of faced_objects - - ax(Pyplot 3D axis): to draw in - - show_path(bool or int): draw on every position where object is displayed - """ - # pylint: disable=protected-access - # pylint: disable=too-many-branches - points = [] - for col, obj in zip(colors, faced_objects): - - # add src attributes position and orientation depending on show_path - rots, poss, inds = get_rot_pos_from_path(obj, show_path) - - # vector length, color and magnetization - if obj._object_type in ("Cuboid", "Cylinder"): - length = 1.8 * np.amax(obj.dimension) - elif obj._object_type == "CylinderSegment": - length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h - else: - length = 1.8 * obj.diameter # Sphere - mag = obj.magnetization - - # collect all draw positions and directions - draw_pos, draw_direc = [], [] - for rot, pos, ind in zip(rots, poss, inds): - if obj._object_type == "CylinderSegment": - # change cylinder_tile draw_pos to barycenter - pos = obj._barycenter[ind] - draw_pos += [pos] - direc = mag / (np.linalg.norm(mag) + 1e-6) - draw_direc += [rot.apply(direc)] - draw_pos = np.array(draw_pos) - draw_direc = np.array(draw_direc) - - # use quiver() separately for each object to easier control - # color and vector length - ax.quiver( - draw_pos[:, 0], - draw_pos[:, 1], - draw_pos[:, 2], - draw_direc[:, 0], - draw_direc[:, 1], - draw_direc[:, 2], - length=length * size_direction, - color=col, - ) - arrow_tip_pos = ((draw_direc * length * size_direction) + draw_pos)[0] - points.append(arrow_tip_pos) - return points - - -def draw_markers(markers, ax, color, symbol, size): - """draws magpylib markers""" - ax.plot( - markers[:, 0], - markers[:, 1], - markers[:, 2], - color=color, - ls="", - marker=symbol, - ms=size, - ) - - -def draw_path( - obj, col, marker_symbol, marker_size, marker_color, line_style, line_width, ax -): - """draw path in given color and return list of path-points""" - # pylint: disable=protected-access - path = obj._position - if len(path) > 1: - ax.plot( - path[:, 0], - path[:, 1], - path[:, 2], - ls=line_style, - lw=line_width, - color=col, - marker=marker_symbol, - mfc=marker_color, - mec=marker_color, - ms=marker_size, - ) - ax.plot( - [path[0, 0]], [path[0, 1]], [path[0, 2]], marker="o", ms=4, mfc=col, mec="k" - ) - return list(path) - - -def draw_faces(faces, col, lw, alpha, ax): - """draw faces in respective color and return list of vertex-points""" - cuboid_faces = Poly3DCollection( - faces, - facecolors=col, - linewidths=lw, - edgecolors="k", - alpha=alpha, - ) - ax.add_collection3d(cuboid_faces) - return faces - - -def draw_pixel(sensors, ax, col, pixel_col, pixel_size, pixel_symb, show_path): - """draw pixels and return a list of pixel-points in global CS""" - # pylint: disable=protected-access - - # collect sensor and pixel positions in global CS - pos_sens, pos_pixel = [], [] - for sens in sensors: - rots, poss, _ = get_rot_pos_from_path(sens, show_path) - - pos_pixel_flat = np.reshape(sens.pixel, (-1, 3)) - - for rot, pos in zip(rots, poss): - pos_sens += [pos] - - for pix in pos_pixel_flat: - pos_pixel += [pos + rot.apply(pix)] - - pos_all = pos_sens + pos_pixel - pos_pixel = np.array(pos_pixel) - - # display pixel positions - ax.plot( - pos_pixel[:, 0], - pos_pixel[:, 1], - pos_pixel[:, 2], - marker=pixel_symb, - mfc=pixel_col, - mew=pixel_size, - mec=col, - ms=pixel_size * 4, - ls="", - ) - - # return all positions for system size evaluation - return list(pos_all) - - -def draw_sensors(sensors, ax, sys_size, show_path, size, arrows_style): - """draw sensor cross""" - # pylint: disable=protected-access - arrowlength = sys_size * size / Config.display.autosizefactor - - # collect plot data - possis, exs, eys, ezs = [], [], [], [] - for sens in sensors: - rots, poss, _ = get_rot_pos_from_path(sens, show_path) - - for rot, pos in zip(rots, poss): - possis += [pos] - exs += [rot.apply((1, 0, 0))] - eys += [rot.apply((0, 1, 0))] - ezs += [rot.apply((0, 0, 1))] - - possis = np.array(possis) - coords = np.array([exs, eys, ezs]) - - # quiver plot of basis vectors - arrow_colors = ( - arrows_style.x.color, - arrows_style.y.color, - arrows_style.z.color, +from matplotlib.animation import FuncAnimation + +from magpylib._src.display.traces_generic import get_frames +from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor + +# from magpylib._src.utility import format_obj_input + + +def generic_trace_to_matplotlib(trace): + """Transform a generic trace into a matplotlib trace""" + traces_mpl = [] + if trace["type"] == "mesh3d": + subtraces = [trace] + if trace.get("facecolor", None) is not None: + subtraces = subdivide_mesh_by_facecolor(trace) + for subtrace in subtraces: + x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) + triangles = np.array([subtrace[k] for k in "ijk"]).T + trace_mpl = { + "constructor": "plot_trisurf", + "args": (x, y, z), + "kwargs": { + "triangles": triangles, + "alpha": subtrace.get("opacity", None), + "color": subtrace.get("color", None), + }, + } + traces_mpl.append(trace_mpl) + elif trace["type"] == "scatter3d": + x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) + mode = trace.get("mode", None) + props = { + k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None)) + for k, v in { + "ls": ("line", "dash"), + "lw": ("line", "width"), + "color": ("line", "color"), + "marker": ("marker", "symbol"), + "mfc": ("marker", "color"), + "mec": ("marker", "color"), + "ms": ("marker", "size"), + }.items() + } + if mode is not None and "lines" not in mode: + props["ls"] = "" + + trace_mpl = { + "constructor": "plot", + "args": (x, y, z), + "kwargs": { + **{k: v for k, v in props.items() if v is not None}, + "alpha": trace.get("opacity", 1), + }, + } + traces_mpl.append(trace_mpl) + else: + raise ValueError( + f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" ) - arrow_show = (arrows_style.x.show, arrows_style.y.show, arrows_style.z.show) - for acol, ashow, es in zip(arrow_colors, arrow_show, coords): - if ashow: - ax.quiver( - possis[:, 0], - possis[:, 1], - possis[:, 2], - es[:, 0], - es[:, 1], - es[:, 2], - color=acol, - length=arrowlength, - ) - - -def draw_dipoles(dipoles, ax, sys_size, show_path, size, color, pivot): - """draw dipoles""" - # pylint: disable=protected-access - - # collect plot data - possis, moms = [], [] - for dip in dipoles: - rots, poss, _ = get_rot_pos_from_path(dip, show_path) - - mom = dip.moment / np.linalg.norm(dip.moment) - - for rot, pos in zip(rots, poss): - possis += [pos] - moms += [rot.apply(mom)] - - possis = np.array(possis) - moms = np.array(moms) - - # quiver plot of basis vectors - arrowlength = sys_size * size / Config.display.autosizefactor - ax.quiver( - possis[:, 0], - possis[:, 1], - possis[:, 2], - moms[:, 0], - moms[:, 1], - moms[:, 2], - color=color, - length=arrowlength, - pivot=pivot, # {'tail', 'middle', 'tip'}, - ) - - -def draw_circular(circulars, show_path, col, size, width, ax): - """draw circulars and return a list of positions""" - # pylint: disable=protected-access - - # graphical settings - discret = 72 + 1 - lw = width - - draw_pos = [] # line positions - for circ in circulars: - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(circ, show_path) - - # init orientation line positions - vertices = draw_arrowed_circle(circ.current, circ.diameter, size, discret).T - # apply pos and rot, draw, store line positions - for rot, pos in zip(rots, poss): - possis1 = rot.apply(vertices) + pos - ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) - draw_pos += list(possis1) - - return draw_pos - - -def draw_line(lines, show_path, col, size, width, ax) -> list: - """draw lines and return a list of positions""" - # pylint: disable=protected-access - - # graphical settings - lw = width - - draw_pos = [] # line positions - for line in lines: - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(line, show_path) - - # init orientation line positions - if size != 0: - vertices = draw_arrow_from_vertices(line.vertices, line.current, size) - else: - vertices = np.array(line.vertices).T - # apply pos and rot, draw, store line positions - for rot, pos in zip(rots, poss): - possis1 = rot.apply(vertices.T) + pos - ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) - draw_pos += list(possis1) - - return draw_pos - - -def draw_model3d_extra(obj, style, show_path, ax, color): - """positions, orients and draws extra 3d model including path positions - returns True if at least one the traces is now new default""" - extra_model3d_traces = style.model3d.data if style.model3d.data is not None else [] - points = [] - rots, poss, _ = get_rot_pos_from_path(obj, show_path) - for orient, pos in zip(rots, poss): - for extr in extra_model3d_traces: - if extr.show: - extr.update(extr.updatefunc()) - if extr.backend == "matplotlib": - kwargs = extr.kwargs() if callable(extr.kwargs) else extr.kwargs - args = extr.args() if callable(extr.args) else extr.args - kwargs, args, vertices = place_and_orient_model3d( - model_kwargs=kwargs, - model_args=args, - orientation=orient, - position=pos, - coordsargs=extr.coordsargs, - scale=extr.scale, - return_vertices=True, - return_model_args=True, - ) - points.append(vertices.T) - if "color" not in kwargs or kwargs["color"] is None: - kwargs.update(color=color) - getattr(ax, extr.constructor)(*args, **kwargs) - return points + return traces_mpl def display_matplotlib( - *obj_list_semi_flat, + *obj_list, + zoom=1, canvas=None, - markers=None, - zoom=0, - colorsequence=None, animation=False, + repeat=False, + colorsequence=None, + return_animation=False, **kwargs, ): - """ - Display objects and paths graphically with the matplotlib backend. - - - canvas: matplotlib axis3d object - - markers: list of marker positions - - path: bool / int / list of ints - - zoom: zoom level, 0=tight boundaries - - colorsequence: list of colors for object coloring - """ - # pylint: disable=protected-access - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - - # apply config default values if None - # create or set plotting axis - - if animation is not False: - msg = "The matplotlib backend does not support animation at the moment.\n" - msg += "Use `backend=plotly` instead." - warnings.warn(msg) - # animation = False - - axis = canvas - if axis is None: - fig = plt.figure(dpi=80, figsize=(8, 8)) - ax = fig.add_subplot(111, projection="3d") - ax.set_box_aspect((1, 1, 1)) - generate_output = True - else: - ax = axis - generate_output = False - - # draw objects and evaluate system size -------------------------------------- - # draw faced objects and store vertices - points = [] - dipoles = [] - sensors = [] - markers_list = [o for o in obj_list_semi_flat if isinstance(o, MagpyMarkers)] - obj_list_semi_flat = [o for o in obj_list_semi_flat if o not in markers_list] - flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, colorsequence=colorsequence + """Display objects and paths graphically using the matplotlib library.""" + data = get_frames( + objs=obj_list, + colorsequence=colorsequence, + zoom=zoom, + animation=animation, + mag_arrows=True, + **kwargs, ) - for obj, props in flat_objs_props.items(): - color = props["color"] - style = get_style(obj, Config, **kwargs) - path_frames = style.path.frames - if path_frames is None: - path_frames = True - obj_color = style.color if style.color is not None else color - lw = 0.25 - faces = None - if obj.style.model3d.data: - pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color) - points += pts - if obj.style.model3d.showdefault: - if obj._object_type == "Cuboid": - lw = 0.5 - faces = faces_cuboid(obj, path_frames) - elif obj._object_type == "Cylinder": - faces = faces_cylinder(obj, path_frames) - elif obj._object_type == "CylinderSegment": - faces = faces_cylinder_segment(obj, path_frames) - elif obj._object_type == "Sphere": - faces = faces_sphere(obj, path_frames) - elif obj._object_type == "Line": - if style.arrow.show: - check_excitations([obj]) - arrow_size = style.arrow.size if style.arrow.show else 0 - arrow_width = style.arrow.width - points += draw_line( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Loop": - if style.arrow.show: - check_excitations([obj]) - arrow_width = style.arrow.width - arrow_size = style.arrow.size if style.arrow.show else 0 - points += draw_circular( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Sensor": - sensors.append((obj, obj_color)) - points += draw_pixel( - [obj], - ax, - obj_color, - style.pixel.color, - style.pixel.size, - style.pixel.symbol, - path_frames, - ) - elif obj._object_type == "Dipole": - dipoles.append((obj, obj_color)) - points += [obj.position] - elif obj._object_type == "CustomSource": - draw_markers( - np.array([obj.position]), ax, obj_color, symbol="*", size=10 - ) - label = ( - obj.style.label - if obj.style.label is not None - else str(type(obj).__name__) - ) - ax.text(*obj.position, label, horizontalalignment="center") - points += [obj.position] - if faces is not None: - alpha = style.opacity - pts = draw_faces(faces, obj_color, lw, alpha, ax) - points += [np.vstack(pts).reshape(-1, 3)] - if style.magnetization.show: - check_excitations([obj]) - pts = draw_directs_faced( - [obj], - [obj_color], - ax, - path_frames, - style.magnetization.size, - ) - points += pts - if style.path.show: - marker, line = style.path.marker, style.path.line - points += draw_path( - obj, - obj_color, - marker.symbol, - marker.size, - marker.color, - line.style, - line.width, - ax, - ) - - # markers ------------------------------------------------------- - if markers_list: - markers_instance = markers_list[0] - style = get_style(markers_instance, Config, **kwargs) - markers = np.array(markers_instance.markers) - s = style.marker - draw_markers(markers, ax, s.color, s.symbol, s.size) - points += [markers] - - # draw direction arrows (based on src size) ------------------------- - # objects with faces - - # determine system size ----------------------------------------- - limx1, limx0, limy1, limy0, limz1, limz0 = system_size(points) - - # make sure ranges are not null - limits = np.array([[limx0, limx1], [limy0, limy1], [limz0, limz1]]) - limits[np.squeeze(np.diff(limits)) == 0] += np.array([-1, 1]) - sys_size = np.max(np.diff(limits)) - c = limits.mean(axis=1) - m = sys_size.max() / 2 - ranges = np.array([c - m * (1 + zoom), c + m * (1 + zoom)]).T - - # draw all system sized based quantities ------------------------- - - # not optimal for loop if many sensors/dipoles - for sens in sensors: - sensor, color = sens - style = get_style(sensor, Config, **kwargs) - draw_sensors([sensor], ax, sys_size, path_frames, style.size, style.arrows) - for dip in dipoles: - dipole, color = dip - style = get_style(dipole, Config, **kwargs) - draw_dipoles( - [dipole], ax, sys_size, path_frames, style.size, color, style.pivot + frames = data["frames"] + ranges = data["ranges"] + + for fr in frames: + fr["data"] = [ + tr0 for tr1 in fr["data"] for tr0 in generic_trace_to_matplotlib(tr1) + ] + show_canvas = False + if canvas is None: + show_canvas = True + fig = plt.figure(dpi=80, figsize=(8, 8)) + canvas = fig.add_subplot(111, projection="3d") + canvas.set_box_aspect((1, 1, 1)) + + def draw_frame(ind): + for tr in frames[ind]["data"]: + constructor = tr["constructor"] + args = tr["args"] + kwargs = tr["kwargs"] + getattr(canvas, constructor)(*args, **kwargs) + canvas.set( + **{f"{k}label": f"{k} [mm]" for k in "xyz"}, + **{f"{k}lim": r for k, r in zip("xyz", ranges)}, ) - # plot styling -------------------------------------------------- - ax.set( - **{f"{k}label": f"{k} [mm]" for k in "xyz"}, - **{f"{k}lim": r for k, r in zip("xyz", ranges)}, - ) + def animate(ind): + plt.cla() + draw_frame(ind) + return [canvas] - # generate output ------------------------------------------------ - if generate_output: + if len(frames) == 1: + draw_frame(0) + else: + anim = FuncAnimation( + fig, + animate, + frames=range(len(frames)), + interval=100, + blit=False, + repeat=repeat, + ) + if return_animation and len(frames) != 1: + return anim + elif show_canvas: plt.show() diff --git a/magpylib/_src/display/backend_matplotlib_auto.py b/magpylib/_src/display/backend_matplotlib_auto.py deleted file mode 100644 index 140fbe136..000000000 --- a/magpylib/_src/display/backend_matplotlib_auto.py +++ /dev/null @@ -1,159 +0,0 @@ -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.animation import FuncAnimation - -from magpylib._src.display.traces_generic import get_frames -from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor - -# from magpylib._src.utility import format_obj_input - - -def generic_trace_to_matplotlib(trace): - """Transform a generic trace into a matplotlib trace""" - traces_mpl = [] - if trace["type"] == "mesh3d": - subtraces = [trace] - if trace.get("facecolor", None) is not None: - subtraces = subdivide_mesh_by_facecolor(trace) - for subtrace in subtraces: - x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) - triangles = np.array([subtrace[k] for k in "ijk"]).T - trace_mpl = { - "constructor": "plot_trisurf", - "args": (x, y, z), - "kwargs": { - "triangles": triangles, - "alpha": subtrace.get("opacity", None), - "color": subtrace.get("color", None), - }, - } - traces_mpl.append(trace_mpl) - elif trace["type"] == "scatter3d": - x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) - mode = trace.get("mode", None) - props = { - k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None)) - for k, v in { - "ls": ("line", "dash"), - "lw": ("line", "width"), - "color": ("line", "color"), - "marker": ("marker", "symbol"), - "mfc": ("marker", "color"), - "mec": ("marker", "color"), - "ms": ("marker", "size"), - }.items() - } - if mode is not None and "lines" not in mode: - props["ls"] = "" - - trace_mpl = { - "constructor": "plot", - "args": (x, y, z), - "kwargs": { - **{k: v for k, v in props.items() if v is not None}, - "alpha": trace.get("opacity", 1), - }, - } - traces_mpl.append(trace_mpl) - else: - raise ValueError( - f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" - ) - return traces_mpl - - -def display_matplotlib_auto( - *obj_list, - zoom=1, - canvas=None, - animation=False, - repeat=False, - colorsequence=None, - return_animation=False, - **kwargs, -): - - """ - Display objects and paths graphically using the matplotlib library. - - Parameters - ---------- - objects: sources, collections or sensors - Objects to be displayed. - - zoom: float, default = 1 - Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. - - canvas: `matplotlib.axes._subplots.AxesSubplot` with `projection=3d, default=None - Display graphical output in a given canvas - By default a new `Figure` is created and displayed. - - title: str, default = "3D-Paths Animation" - When zoom=0 all objects are just inside the 3D-axes. - - colorsequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - """ - data = get_frames( - objs=obj_list, - colorsequence=colorsequence, - zoom=zoom, - animation=animation, - mag_arrows=True, - **kwargs, - ) - frames = data["frames"] - ranges = data["ranges"] - - for fr in frames: - fr["data"] = [ - tr0 for tr1 in fr["data"] for tr0 in generic_trace_to_matplotlib(tr1) - ] - show_canvas = False - if canvas is None: - show_canvas = True - fig = plt.figure(dpi=80, figsize=(8, 8)) - canvas = fig.add_subplot(111, projection="3d") - canvas.set_box_aspect((1, 1, 1)) - - def draw_frame(ind): - for tr in frames[ind]["data"]: - constructor = tr["constructor"] - args = tr["args"] - kwargs = tr["kwargs"] - getattr(canvas, constructor)(*args, **kwargs) - canvas.set( - **{f"{k}label": f"{k} [mm]" for k in "xyz"}, - **{f"{k}lim": r for k, r in zip("xyz", ranges)}, - ) - - def animate(ind): - plt.cla() - draw_frame(ind) - return [canvas] - - if len(frames) == 1: - draw_frame(0) - else: - anim = FuncAnimation( - fig, - animate, - frames=range(len(frames)), - interval=100, - blit=False, - repeat=repeat, - ) - if return_animation and len(frames) != 1: - return anim - elif show_canvas: - plt.show() diff --git a/magpylib/_src/display/backend_matplotlib_old.py b/magpylib/_src/display/backend_matplotlib_old.py new file mode 100644 index 000000000..144681143 --- /dev/null +++ b/magpylib/_src/display/backend_matplotlib_old.py @@ -0,0 +1,756 @@ +""" matplotlib draw-functionalities""" +import warnings + +import matplotlib.pyplot as plt +import numpy as np +from mpl_toolkits.mplot3d.art3d import Poly3DCollection + +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.display.traces_utility import draw_arrow_from_vertices +from magpylib._src.display.traces_utility import draw_arrowed_circle +from magpylib._src.display.traces_utility import get_flatten_objects_properties +from magpylib._src.display.traces_utility import get_rot_pos_from_path +from magpylib._src.display.traces_utility import MagpyMarkers +from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.input_checks import check_excitations +from magpylib._src.style import get_style + + +def faces_cuboid(src, show_path): + """ + compute vertices and faces of Cuboid input for plotting + takes Cuboid source + returns vert, faces + returns all faces when show_path=all + """ + # pylint: disable=protected-access + a, b, c = src.dimension + vert0 = np.array( + ( + (0, 0, 0), + (a, 0, 0), + (0, b, 0), + (0, 0, c), + (a, b, 0), + (a, 0, c), + (0, b, c), + (a, b, c), + ) + ) + vert0 = vert0 - src.dimension / 2 + + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + faces = [] + for rot, pos in zip(rots, poss): + vert = rot.apply(vert0) + pos + faces += [ + [vert[0], vert[1], vert[4], vert[2]], + [vert[0], vert[1], vert[5], vert[3]], + [vert[0], vert[2], vert[6], vert[3]], + [vert[7], vert[6], vert[2], vert[4]], + [vert[7], vert[6], vert[3], vert[5]], + [vert[7], vert[5], vert[1], vert[4]], + ] + return faces + + +def faces_cylinder(src, show_path): + """ + Compute vertices and faces of Cylinder input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder faces + r, h2 = src.dimension / 2 + hs = np.array([-h2, h2]) + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + faces = [ + np.array( + [ + (r * np.cos(p1), r * np.sin(p1), h2), + (r * np.cos(p1), r * np.sin(p1), -h2), + (r * np.cos(p2), r * np.sin(p2), -h2), + (r * np.cos(p2), r * np.sin(p2), h2), + ] + ) + for p1, p2 in zip(phis, phis2) + ] + faces += [ + np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_cylinder_segment(src, show_path): + """ + Compute vertices and faces of CylinderSegment for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder segment faces + r1, r2, h, phi1, phi2 = src.dimension + res_tile = ( + int((phi2 - phi1) / 360 * 2 * res) + 2 + ) # resolution used for tile curved surface + phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi + phis2 = np.roll(phis, 1) + faces = [ + np.array( + [ # inner curved surface + (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), + (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # outer curved surface + (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), + (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # sides + (r1 * np.cos(p), r1 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), -h / 2), + (r1 * np.cos(p), r1 * np.sin(p), -h / 2), + ] + ) + for p in [phis[0], phis[-1]] + ] + faces += [ + np.array( # top surface + [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] + ) + ] + faces += [ + np.array( # bottom surface + [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] + ) + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_sphere(src, show_path): + """ + Compute vertices and faces of Sphere input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate sphere faces + r = src.diameter / 2 + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + ths = np.linspace(0, np.pi, res) + faces = [ + r + * np.array( + [ + (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), + (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), + ] + ) + for p, p2 in zip(phis, phis2) + for t1, t2 in zip(ths[1:-2], ths[2:-1]) + ] + faces += [ + r + * np.array( + [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] + ) + for th in [ths[1], ths[-2]] + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def system_size(points): + """compute system size for display""" + # determine min/max from all to generate aspect=1 plot + if points: + + # bring (n,m,3) point dimensions (e.g. from plot_surface body) + # to correct (n,3) shape + for i, p in enumerate(points): + if p.ndim == 3: + points[i] = np.reshape(p, (-1, 3)) + + pts = np.vstack(points) + xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] + ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] + zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] + + xsize = xs[1] - xs[0] + ysize = ys[1] - ys[0] + zsize = zs[1] - zs[0] + + xcenter = (xs[1] + xs[0]) / 2 + ycenter = (ys[1] + ys[0]) / 2 + zcenter = (zs[1] + zs[0]) / 2 + + size = max([xsize, ysize, zsize]) + + limx0 = xcenter + size / 2 + limx1 = xcenter - size / 2 + limy0 = ycenter + size / 2 + limy1 = ycenter - size / 2 + limz0 = zcenter + size / 2 + limz1 = zcenter - size / 2 + else: + limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 + return limx0, limx1, limy0, limy1, limz0, limz1 + + +def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): + """draw direction of magnetization of faced magnets + + Parameters + ---------- + - faced_objects(list of src objects): with magnetization vector to be drawn + - colors: colors of faced_objects + - ax(Pyplot 3D axis): to draw in + - show_path(bool or int): draw on every position where object is displayed + """ + # pylint: disable=protected-access + # pylint: disable=too-many-branches + points = [] + for col, obj in zip(colors, faced_objects): + + # add src attributes position and orientation depending on show_path + rots, poss, inds = get_rot_pos_from_path(obj, show_path) + + # vector length, color and magnetization + if obj._object_type in ("Cuboid", "Cylinder"): + length = 1.8 * np.amax(obj.dimension) + elif obj._object_type == "CylinderSegment": + length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h + else: + length = 1.8 * obj.diameter # Sphere + mag = obj.magnetization + + # collect all draw positions and directions + draw_pos, draw_direc = [], [] + for rot, pos, ind in zip(rots, poss, inds): + if obj._object_type == "CylinderSegment": + # change cylinder_tile draw_pos to barycenter + pos = obj._barycenter[ind] + draw_pos += [pos] + direc = mag / (np.linalg.norm(mag) + 1e-6) + draw_direc += [rot.apply(direc)] + draw_pos = np.array(draw_pos) + draw_direc = np.array(draw_direc) + + # use quiver() separately for each object to easier control + # color and vector length + ax.quiver( + draw_pos[:, 0], + draw_pos[:, 1], + draw_pos[:, 2], + draw_direc[:, 0], + draw_direc[:, 1], + draw_direc[:, 2], + length=length * size_direction, + color=col, + ) + arrow_tip_pos = ((draw_direc * length * size_direction) + draw_pos)[0] + points.append(arrow_tip_pos) + return points + + +def draw_markers(markers, ax, color, symbol, size): + """draws magpylib markers""" + ax.plot( + markers[:, 0], + markers[:, 1], + markers[:, 2], + color=color, + ls="", + marker=symbol, + ms=size, + ) + + +def draw_path( + obj, col, marker_symbol, marker_size, marker_color, line_style, line_width, ax +): + """draw path in given color and return list of path-points""" + # pylint: disable=protected-access + path = obj._position + if len(path) > 1: + ax.plot( + path[:, 0], + path[:, 1], + path[:, 2], + ls=line_style, + lw=line_width, + color=col, + marker=marker_symbol, + mfc=marker_color, + mec=marker_color, + ms=marker_size, + ) + ax.plot( + [path[0, 0]], [path[0, 1]], [path[0, 2]], marker="o", ms=4, mfc=col, mec="k" + ) + return list(path) + + +def draw_faces(faces, col, lw, alpha, ax): + """draw faces in respective color and return list of vertex-points""" + cuboid_faces = Poly3DCollection( + faces, + facecolors=col, + linewidths=lw, + edgecolors="k", + alpha=alpha, + ) + ax.add_collection3d(cuboid_faces) + return faces + + +def draw_pixel(sensors, ax, col, pixel_col, pixel_size, pixel_symb, show_path): + """draw pixels and return a list of pixel-points in global CS""" + # pylint: disable=protected-access + + # collect sensor and pixel positions in global CS + pos_sens, pos_pixel = [], [] + for sens in sensors: + rots, poss, _ = get_rot_pos_from_path(sens, show_path) + + pos_pixel_flat = np.reshape(sens.pixel, (-1, 3)) + + for rot, pos in zip(rots, poss): + pos_sens += [pos] + + for pix in pos_pixel_flat: + pos_pixel += [pos + rot.apply(pix)] + + pos_all = pos_sens + pos_pixel + pos_pixel = np.array(pos_pixel) + + # display pixel positions + ax.plot( + pos_pixel[:, 0], + pos_pixel[:, 1], + pos_pixel[:, 2], + marker=pixel_symb, + mfc=pixel_col, + mew=pixel_size, + mec=col, + ms=pixel_size * 4, + ls="", + ) + + # return all positions for system size evaluation + return list(pos_all) + + +def draw_sensors(sensors, ax, sys_size, show_path, size, arrows_style): + """draw sensor cross""" + # pylint: disable=protected-access + arrowlength = sys_size * size / Config.display.autosizefactor + + # collect plot data + possis, exs, eys, ezs = [], [], [], [] + for sens in sensors: + rots, poss, _ = get_rot_pos_from_path(sens, show_path) + + for rot, pos in zip(rots, poss): + possis += [pos] + exs += [rot.apply((1, 0, 0))] + eys += [rot.apply((0, 1, 0))] + ezs += [rot.apply((0, 0, 1))] + + possis = np.array(possis) + coords = np.array([exs, eys, ezs]) + + # quiver plot of basis vectors + arrow_colors = ( + arrows_style.x.color, + arrows_style.y.color, + arrows_style.z.color, + ) + arrow_show = (arrows_style.x.show, arrows_style.y.show, arrows_style.z.show) + for acol, ashow, es in zip(arrow_colors, arrow_show, coords): + if ashow: + ax.quiver( + possis[:, 0], + possis[:, 1], + possis[:, 2], + es[:, 0], + es[:, 1], + es[:, 2], + color=acol, + length=arrowlength, + ) + + +def draw_dipoles(dipoles, ax, sys_size, show_path, size, color, pivot): + """draw dipoles""" + # pylint: disable=protected-access + + # collect plot data + possis, moms = [], [] + for dip in dipoles: + rots, poss, _ = get_rot_pos_from_path(dip, show_path) + + mom = dip.moment / np.linalg.norm(dip.moment) + + for rot, pos in zip(rots, poss): + possis += [pos] + moms += [rot.apply(mom)] + + possis = np.array(possis) + moms = np.array(moms) + + # quiver plot of basis vectors + arrowlength = sys_size * size / Config.display.autosizefactor + ax.quiver( + possis[:, 0], + possis[:, 1], + possis[:, 2], + moms[:, 0], + moms[:, 1], + moms[:, 2], + color=color, + length=arrowlength, + pivot=pivot, # {'tail', 'middle', 'tip'}, + ) + + +def draw_circular(circulars, show_path, col, size, width, ax): + """draw circulars and return a list of positions""" + # pylint: disable=protected-access + + # graphical settings + discret = 72 + 1 + lw = width + + draw_pos = [] # line positions + for circ in circulars: + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(circ, show_path) + + # init orientation line positions + vertices = draw_arrowed_circle(circ.current, circ.diameter, size, discret).T + # apply pos and rot, draw, store line positions + for rot, pos in zip(rots, poss): + possis1 = rot.apply(vertices) + pos + ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) + draw_pos += list(possis1) + + return draw_pos + + +def draw_line(lines, show_path, col, size, width, ax) -> list: + """draw lines and return a list of positions""" + # pylint: disable=protected-access + + # graphical settings + lw = width + + draw_pos = [] # line positions + for line in lines: + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(line, show_path) + + # init orientation line positions + if size != 0: + vertices = draw_arrow_from_vertices(line.vertices, line.current, size) + else: + vertices = np.array(line.vertices).T + # apply pos and rot, draw, store line positions + for rot, pos in zip(rots, poss): + possis1 = rot.apply(vertices.T) + pos + ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) + draw_pos += list(possis1) + + return draw_pos + + +def draw_model3d_extra(obj, style, show_path, ax, color): + """positions, orients and draws extra 3d model including path positions + returns True if at least one the traces is now new default""" + extra_model3d_traces = style.model3d.data if style.model3d.data is not None else [] + points = [] + rots, poss, _ = get_rot_pos_from_path(obj, show_path) + for orient, pos in zip(rots, poss): + for extr in extra_model3d_traces: + if extr.show: + extr.update(extr.updatefunc()) + if extr.backend == "matplotlib": + kwargs = extr.kwargs() if callable(extr.kwargs) else extr.kwargs + args = extr.args() if callable(extr.args) else extr.args + kwargs, args, vertices = place_and_orient_model3d( + model_kwargs=kwargs, + model_args=args, + orientation=orient, + position=pos, + coordsargs=extr.coordsargs, + scale=extr.scale, + return_vertices=True, + return_model_args=True, + ) + points.append(vertices.T) + if "color" not in kwargs or kwargs["color"] is None: + kwargs.update(color=color) + getattr(ax, extr.constructor)(*args, **kwargs) + return points + + +def display_matplotlib_old( + *obj_list_semi_flat, + canvas=None, + markers=None, + zoom=0, + colorsequence=None, + animation=False, + **kwargs, +): + """Display objects and paths graphically with the matplotlib backend.""" + # pylint: disable=protected-access + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + + # apply config default values if None + # create or set plotting axis + + if animation is not False: + msg = "The matplotlib backend does not support animation at the moment.\n" + msg += "Use `backend=plotly` instead." + warnings.warn(msg) + # animation = False + + axis = canvas + if axis is None: + fig = plt.figure(dpi=80, figsize=(8, 8)) + ax = fig.add_subplot(111, projection="3d") + ax.set_box_aspect((1, 1, 1)) + generate_output = True + else: + ax = axis + generate_output = False + + # draw objects and evaluate system size -------------------------------------- + + # draw faced objects and store vertices + points = [] + dipoles = [] + sensors = [] + markers_list = [o for o in obj_list_semi_flat if isinstance(o, MagpyMarkers)] + obj_list_semi_flat = [o for o in obj_list_semi_flat if o not in markers_list] + flat_objs_props = get_flatten_objects_properties( + *obj_list_semi_flat, colorsequence=colorsequence + ) + for obj, props in flat_objs_props.items(): + color = props["color"] + style = get_style(obj, Config, **kwargs) + path_frames = style.path.frames + if path_frames is None: + path_frames = True + obj_color = style.color if style.color is not None else color + lw = 0.25 + faces = None + if obj.style.model3d.data: + pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color) + points += pts + if obj.style.model3d.showdefault: + if obj._object_type == "Cuboid": + lw = 0.5 + faces = faces_cuboid(obj, path_frames) + elif obj._object_type == "Cylinder": + faces = faces_cylinder(obj, path_frames) + elif obj._object_type == "CylinderSegment": + faces = faces_cylinder_segment(obj, path_frames) + elif obj._object_type == "Sphere": + faces = faces_sphere(obj, path_frames) + elif obj._object_type == "Line": + if style.arrow.show: + check_excitations([obj]) + arrow_size = style.arrow.size if style.arrow.show else 0 + arrow_width = style.arrow.width + points += draw_line( + [obj], path_frames, obj_color, arrow_size, arrow_width, ax + ) + elif obj._object_type == "Loop": + if style.arrow.show: + check_excitations([obj]) + arrow_width = style.arrow.width + arrow_size = style.arrow.size if style.arrow.show else 0 + points += draw_circular( + [obj], path_frames, obj_color, arrow_size, arrow_width, ax + ) + elif obj._object_type == "Sensor": + sensors.append((obj, obj_color)) + points += draw_pixel( + [obj], + ax, + obj_color, + style.pixel.color, + style.pixel.size, + style.pixel.symbol, + path_frames, + ) + elif obj._object_type == "Dipole": + dipoles.append((obj, obj_color)) + points += [obj.position] + elif obj._object_type == "CustomSource": + draw_markers( + np.array([obj.position]), ax, obj_color, symbol="*", size=10 + ) + label = ( + obj.style.label + if obj.style.label is not None + else str(type(obj).__name__) + ) + ax.text(*obj.position, label, horizontalalignment="center") + points += [obj.position] + if faces is not None: + alpha = style.opacity + pts = draw_faces(faces, obj_color, lw, alpha, ax) + points += [np.vstack(pts).reshape(-1, 3)] + if style.magnetization.show: + check_excitations([obj]) + pts = draw_directs_faced( + [obj], + [obj_color], + ax, + path_frames, + style.magnetization.size, + ) + points += pts + if style.path.show: + marker, line = style.path.marker, style.path.line + points += draw_path( + obj, + obj_color, + marker.symbol, + marker.size, + marker.color, + line.style, + line.width, + ax, + ) + + # markers ------------------------------------------------------- + if markers_list: + markers_instance = markers_list[0] + style = get_style(markers_instance, Config, **kwargs) + markers = np.array(markers_instance.markers) + s = style.marker + draw_markers(markers, ax, s.color, s.symbol, s.size) + points += [markers] + + # draw direction arrows (based on src size) ------------------------- + # objects with faces + + # determine system size ----------------------------------------- + limx1, limx0, limy1, limy0, limz1, limz0 = system_size(points) + + # make sure ranges are not null + limits = np.array([[limx0, limx1], [limy0, limy1], [limz0, limz1]]) + limits[np.squeeze(np.diff(limits)) == 0] += np.array([-1, 1]) + sys_size = np.max(np.diff(limits)) + c = limits.mean(axis=1) + m = sys_size.max() / 2 + ranges = np.array([c - m * (1 + zoom), c + m * (1 + zoom)]).T + + # draw all system sized based quantities ------------------------- + + # not optimal for loop if many sensors/dipoles + for sens in sensors: + sensor, color = sens + style = get_style(sensor, Config, **kwargs) + draw_sensors([sensor], ax, sys_size, path_frames, style.size, style.arrows) + for dip in dipoles: + dipole, color = dip + style = get_style(dipole, Config, **kwargs) + draw_dipoles( + [dipole], ax, sys_size, path_frames, style.size, color, style.pivot + ) + + # plot styling -------------------------------------------------- + ax.set( + **{f"{k}label": f"{k} [mm]" for k in "xyz"}, + **{f"{k}lim": r for k, r in zip("xyz", ranges)}, + ) + + # generate output ------------------------------------------------ + if generate_output: + plt.show() diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index fce25ea60..c8c31160f 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -159,53 +159,7 @@ def display_plotly( **kwargs, ): - """ - Display objects and paths graphically using the plotly library. - - Parameters - ---------- - objects: sources, collections or sensors - Objects to be displayed. - - zoom: float, default = 1 - Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. - - fig: plotly Figure, default=None - Display graphical output in a given figure: - - plotly.graph_objects.Figure - - plotly.graph_objects.FigureWidget - By default a new `Figure` is created and displayed. - - renderer: str. default=None, - The renderers framework is a flexible approach for displaying plotly.py figures in a variety - of contexts. - Available renderers are: - ['plotly_mimetype', 'jupyterlab', 'nteract', 'vscode', - 'notebook', 'notebook_connected', 'kaggle', 'azure', 'colab', - 'cocalc', 'databricks', 'json', 'png', 'jpeg', 'jpg', 'svg', - 'pdf', 'browser', 'firefox', 'chrome', 'chromium', 'iframe', - 'iframe_connected', 'sphinx_gallery', 'sphinx_gallery_png'] - - title: str, default=None - Plot title. - - colorsequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - - Returns - ------- - None: NoneType - """ + """Display objects and paths graphically using the plotly library.""" show_canvas = False if canvas is None: diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py index bc85c339c..eb0860972 100644 --- a/magpylib/_src/display/backend_pyvista.py +++ b/magpylib/_src/display/backend_pyvista.py @@ -111,37 +111,7 @@ def display_pyvista( **kwargs, ): - """ - Display objects and paths graphically using the pyvista library. - - Parameters - ---------- - objects: sources, collections or sensors - Objects to be displayed. - - zoom: float, default = 1 - Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. - - canvas: pyvista Plotter, default=None - Display graphical output in a given canvas - By default a new `Figure` is created and displayed. - - title: str, default = "3D-Paths Animation" - When zoom=0 all objects are just inside the 3D-axes. - - colorsequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - """ + """Display objects and paths graphically using the pyvista library.""" if animation is not False: msg = "The pyvista backend does not support animation at the moment.\n" diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index 0e202f267..2bded1e30 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -214,265 +214,6 @@ def get_rot_pos_from_path(obj, show_path=None): return rots, poss, inds -def faces_cuboid(src, show_path): - """ - compute vertices and faces of Cuboid input for plotting - takes Cuboid source - returns vert, faces - returns all faces when show_path=all - """ - # pylint: disable=protected-access - a, b, c = src.dimension - vert0 = np.array( - ( - (0, 0, 0), - (a, 0, 0), - (0, b, 0), - (0, 0, c), - (a, b, 0), - (a, 0, c), - (0, b, c), - (a, b, c), - ) - ) - vert0 = vert0 - src.dimension / 2 - - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - faces = [] - for rot, pos in zip(rots, poss): - vert = rot.apply(vert0) + pos - faces += [ - [vert[0], vert[1], vert[4], vert[2]], - [vert[0], vert[1], vert[5], vert[3]], - [vert[0], vert[2], vert[6], vert[3]], - [vert[7], vert[6], vert[2], vert[4]], - [vert[7], vert[6], vert[3], vert[5]], - [vert[7], vert[5], vert[1], vert[4]], - ] - return faces - - -def faces_cylinder(src, show_path): - """ - Compute vertices and faces of Cylinder input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder faces - r, h2 = src.dimension / 2 - hs = np.array([-h2, h2]) - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - faces = [ - np.array( - [ - (r * np.cos(p1), r * np.sin(p1), h2), - (r * np.cos(p1), r * np.sin(p1), -h2), - (r * np.cos(p2), r * np.sin(p2), -h2), - (r * np.cos(p2), r * np.sin(p2), h2), - ] - ) - for p1, p2 in zip(phis, phis2) - ] - faces += [ - np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_cylinder_segment(src, show_path): - """ - Compute vertices and faces of CylinderSegment for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder segment faces - r1, r2, h, phi1, phi2 = src.dimension - res_tile = ( - int((phi2 - phi1) / 360 * 2 * res) + 2 - ) # resolution used for tile curved surface - phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi - phis2 = np.roll(phis, 1) - faces = [ - np.array( - [ # inner curved surface - (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), - (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # outer curved surface - (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), - (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # sides - (r1 * np.cos(p), r1 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), -h / 2), - (r1 * np.cos(p), r1 * np.sin(p), -h / 2), - ] - ) - for p in [phis[0], phis[-1]] - ] - faces += [ - np.array( # top surface - [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] - ) - ] - faces += [ - np.array( # bottom surface - [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] - ) - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_sphere(src, show_path): - """ - Compute vertices and faces of Sphere input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate sphere faces - r = src.diameter / 2 - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - ths = np.linspace(0, np.pi, res) - faces = [ - r - * np.array( - [ - (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), - (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), - ] - ) - for p, p2 in zip(phis, phis2) - for t1, t2 in zip(ths[1:-2], ths[2:-1]) - ] - faces += [ - r - * np.array( - [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] - ) - for th in [ths[1], ths[-2]] - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def system_size(points): - """compute system size for display""" - # determine min/max from all to generate aspect=1 plot - if points: - - # bring (n,m,3) point dimensions (e.g. from plot_surface body) - # to correct (n,3) shape - for i, p in enumerate(points): - if p.ndim == 3: - points[i] = np.reshape(p, (-1, 3)) - - pts = np.vstack(points) - xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] - ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] - zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] - - xsize = xs[1] - xs[0] - ysize = ys[1] - ys[0] - zsize = zs[1] - zs[0] - - xcenter = (xs[1] + xs[0]) / 2 - ycenter = (ys[1] + ys[0]) / 2 - zcenter = (zs[1] + zs[0]) / 2 - - size = max([xsize, ysize, zsize]) - - limx0 = xcenter + size / 2 - limx1 = xcenter - size / 2 - limy0 = ycenter + size / 2 - limy1 = ycenter - size / 2 - limz0 = zcenter + size / 2 - limz1 = zcenter - size / 2 - else: - limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 - return limx0, limx1, limy0, limy1, limz0, limz1 - - def get_flatten_objects_properties( *obj_list_semi_flat, colorsequence=None, From 2e1e9415154da3a04b7058c0fc2f0e7a2e7f10c4 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 22 Jun 2022 17:01:45 +0200 Subject: [PATCH 135/207] make extra trace generic --- magpylib/_src/display/traces_generic.py | 23 ++++++++++++++--------- magpylib/_src/style.py | 7 ++++--- 2 files changed, 18 insertions(+), 12 deletions(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index a481d6bc9..a97ec1392 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -658,22 +658,27 @@ def get_generic_traces( for extr in extra_model3d_traces: if extr.show: extr.update(extr.updatefunc()) - if extr.backend == "plotly": - trace3d = {} + if extr.backend == "generic": + trace3d = {"opacity": kwargs["opacity"]} ttype = extr.constructor.lower() obj_extr_trace = ( extr.kwargs() if callable(extr.kwargs) else extr.kwargs ) obj_extr_trace = {"type": ttype, **obj_extr_trace} - if ttype == "mesh3d": - trace3d["showscale"] = False + if ttype == "scatter3d": + for k in ("marker", "line"): + trace3d["{k}_color"] = trace3d.get( + f"{k}_color", kwargs["color"] + ) + elif ttype == "mesh3d": + trace3d["showscale"] = trace3d.get("showscale", False) if "facecolor" in obj_extr_trace: ttype = "mesh3d_facecolor" - if ttype == "scatter3d": - trace3d["marker_color"] = kwargs["color"] - trace3d["line_color"] = kwargs["color"] + trace3d["color"] = trace3d.get("color", kwargs["color"]) else: - trace3d["color"] = kwargs["color"] + raise ValueError( + f"{ttype} is not supported, only 'scatter3d' and 'mesh3d' are" + ) trace3d.update( linearize_dict( place_and_orient_model3d( @@ -918,7 +923,7 @@ def process_animation_kwargs(obj_list, animation=False, **kwargs): warnings.warn("No path to be animated detected, displaying standard plot") anim_def = Config.display.animation.copy() - anim_def.update(kwargs) + anim_def.update({k[10:]: v for k, v in kwargs.items()}) animation_kwargs = {f"animation_{k}": v for k, v in anim_def.as_dict().items()} kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")} return kwargs, animation, animation_kwargs diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index e7c642a71..b685a8f68 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -292,7 +292,7 @@ def add_trace(self, trace=None, **kwargs): backend: str Plotting backend corresponding to the trace. Can be one of - `['matplotlib', 'plotly', 'pyvista']`. + `['generic', 'matplotlib', 'plotly', 'pyvista']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -495,9 +495,10 @@ def backend(self): @backend.setter def backend(self, val): - assert val is None or val in SUPPORTED_PLOTTING_BACKENDS, ( + backends = ["generic"] + list(SUPPORTED_PLOTTING_BACKENDS) + assert val is None or val in backends, ( f"The `backend` property of {type(self).__name__} must be one of" - f"{SUPPORTED_PLOTTING_BACKENDS},\n" + f"{backends},\n" f"but received {repr(val)} instead." ) self._backend = val From 735d9053c3b9e194ce41f237b0eb54bb1cbf53f5 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 23 Jun 2022 15:13:17 +0200 Subject: [PATCH 136/207] remove pyvista (now generic branch) --- magpylib/_src/defaults/defaults_classes.py | 4 +- magpylib/_src/defaults/defaults_utility.py | 2 +- magpylib/_src/display/backend_pyvista.py | 140 --------------------- magpylib/_src/display/display.py | 3 +- magpylib/_src/style.py | 6 +- tests/test_defaults.py | 2 +- 6 files changed, 8 insertions(+), 149 deletions(-) delete mode 100644 magpylib/_src/display/backend_pyvista.py diff --git a/magpylib/_src/defaults/defaults_classes.py b/magpylib/_src/defaults/defaults_classes.py index d2dfd0b65..6730d90d8 100644 --- a/magpylib/_src/defaults/defaults_classes.py +++ b/magpylib/_src/defaults/defaults_classes.py @@ -50,7 +50,7 @@ class Display(MagicProperties): ---------- backend: str, default='matplotlib' Defines the plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly', 'pyvista']` + function. Can be one of `['matplotlib', 'plotly']` colorsequence: iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', @@ -80,7 +80,7 @@ class Display(MagicProperties): @property def backend(self): """plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly', 'pyvista']`""" + function. Can be one of `['matplotlib', 'plotly']`""" return self._backend @backend.setter diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index 05b1fa6cb..ebe4d5baf 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -5,7 +5,7 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "pyvista", "matplotlib_old") +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "matplotlib_old") MAGPYLIB_FAMILIES = { "Line": ("current",), diff --git a/magpylib/_src/display/backend_pyvista.py b/magpylib/_src/display/backend_pyvista.py deleted file mode 100644 index eb0860972..000000000 --- a/magpylib/_src/display/backend_pyvista.py +++ /dev/null @@ -1,140 +0,0 @@ -import warnings -from functools import lru_cache - -import numpy as np - -try: - import pyvista as pv -except ImportError as missing_module: # pragma: no cover - raise ModuleNotFoundError( - """In order to use the pyvista plotting backend, you need to install pyvista via pip or - conda, see https://docs.pyvista.org/getting-started/installation.html""" - ) from missing_module - -from pyvista.plotting.colors import Color -from matplotlib.colors import LinearSegmentedColormap -from magpylib._src.display.traces_generic import draw_frame - -# from magpylib._src.utility import format_obj_input - - -@lru_cache(maxsize=32) -def colormap_from_colorscale(colorscale, name="plotly_to_mpl", N=256, gamma=1.0): - """Create matplotlib colormap from plotly colorscale""" - - cs_rgb = [(v[0], Color(v[1]).float_rgb) for v in colorscale] - cdict = { - rgb_col: [ - ( - v[0], - *[cs_rgb[i][1][rgb_ind]] * 2, - ) - for i, v in enumerate(cs_rgb) - ] - for rgb_ind, rgb_col in enumerate(("red", "green", "blue")) - } - return LinearSegmentedColormap(name, cdict, N, gamma) - - -def generic_trace_to_pyvista(trace): - """Transform a generic trace into a pyvista trace""" - traces_pv = [] - if trace["type"] == "mesh3d": - vertices = np.array([trace[k] for k in "xyz"], dtype=float).T - faces = np.array([trace[k] for k in "ijk"]).T.flatten() - faces = np.insert(faces, range(0, len(faces), 3), 3) - colorscale = trace.get("colorscale", None) - mesh = pv.PolyData(vertices, faces) - facecolor = trace.get("facecolor", None) - trace_pv = { - "mesh": mesh, - "opacity": trace.get("opacity", None), - "color": trace.get("color", None), - "scalars": trace.get("intensity", None), - } - if facecolor is not None: - # pylint: disable=unsupported-assignment-operation - mesh.cell_data["colors"] = [ - Color(c, default_color=(0, 0, 0)).int_rgb for c in facecolor - ] - trace_pv.update( - { - "scalars": "colors", - "rgb": True, - "preference": "cell", - } - ) - traces_pv.append(trace_pv) - if colorscale is not None: - if colorscale is not None: - # ipygany does not support custom colorsequences - if pv.global_theme.jupyter_backend == "ipygany": - trace_pv["cmap"] = "PiYG" - else: - trace_pv["cmap"] = colormap_from_colorscale(colorscale) - elif trace["type"] == "scatter3d": - points = np.array([trace[k] for k in "xyz"], dtype=float).T - line = trace.get("line", {}) - line_color = line.get("color", trace.get("line_color", None)) - line_width = line.get("width", trace.get("line_width", None)) - trace_pv_line = { - "mesh": pv.lines_from_points(points), - "opacity": trace.get("opacity", None), - "color": line_color, - "line_width": line_width, - } - traces_pv.append(trace_pv_line) - marker = trace.get("marker", {}) - marker_color = marker.get("color", trace.get("marker_color", None)) - # marker_symbol = marker.get("symbol", trace.get("marker_symbol", None)) - marker_size = marker.get("size", trace.get("marker_size", None)) - trace_pv_marker = { - "mesh": pv.PolyData(points), - "opacity": trace.get("opacity", None), - "color": marker_color, - "point_size": 1 if marker_size is None else marker_size, - } - traces_pv.append(trace_pv_marker) - else: - raise ValueError( - f"Trace type {trace['type']!r} cannot be transformed into pyvista trace" - ) - return traces_pv - - -def display_pyvista( - *obj_list, - zoom=1, - canvas=None, - animation=False, - colorsequence=None, - **kwargs, -): - - """Display objects and paths graphically using the pyvista library.""" - - if animation is not False: - msg = "The pyvista backend does not support animation at the moment.\n" - msg += "Use `backend=plotly` instead." - warnings.warn(msg) - # animation = False - - # flat_obj_list = format_obj_input(obj_list) - - show_canvas = False - if canvas is None: - show_canvas = True - canvas = pv.Plotter() - - generic_traces = draw_frame(obj_list, colorsequence, zoom, output="list", **kwargs) - for tr0 in generic_traces: - for tr1 in generic_trace_to_pyvista(tr0): - canvas.add_mesh(**tr1) - - # apply_fig_ranges(canvas, zoom=zoom) - try: - canvas.remove_scalar_bar() - except IndexError: - pass - if show_canvas: - canvas.show() diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index c313923f0..8d5ad2aca 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -43,7 +43,7 @@ def show( Display position markers in the global coordinate system. backend: string, default=`None` - Define plotting backend. Must be one of `'matplotlib'`, `'plotly'` or `'pyvista'`. If not + Define plotting backend. Must be one of `'matplotlib'`, `'plotly'`. If not set, parameter will default to `magpylib.defaults.display.backend` which is `'matplotlib'` by installation default. @@ -51,7 +51,6 @@ def show( Display graphical output on a given canvas: - with matplotlib: `matplotlib.axes._subplots.AxesSubplot` with `projection=3d. - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. - - with pyvista: `pyvista.Plotter`. By default a new canvas is created and immediately displayed. Returns diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index b685a8f68..6ea7e6afc 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -292,7 +292,7 @@ def add_trace(self, trace=None, **kwargs): backend: str Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly', 'pyvista']`. + `['generic', 'matplotlib', 'plotly']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -336,7 +336,7 @@ class Trace3d(MagicProperties): ---------- backend: str Plotting backend corresponding to the trace. Can be one of - `['matplotlib', 'plotly', 'pyvista']`. + `['matplotlib', 'plotly']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -490,7 +490,7 @@ def coordsargs(self, val): @property def backend(self): """Plotting backend corresponding to the trace. Can be one of - `['matplotlib', 'plotly', 'pyvista']`.""" + `['matplotlib', 'plotly']`.""" return self._backend @backend.setter diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 91f421ae0..84d88529c 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -98,7 +98,7 @@ def test_defaults_bad_inputs(key, value, expected_errortype): "display_animation_time": (10,), # int>0 "display_animation_maxframes": (200,), # int>0 "display_animation_slider": (True, False), # bool - "display_backend": ("matplotlib", "plotly", "pyvista"), # str typo + "display_backend": ("matplotlib", "plotly"), # str typo "display_colorsequence": ( ["#2E91E5", "#0D2A63"], ["blue", "red"], From 8a84eca101e4e54ef21b7bd7cbbfbd3076848bde Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 23 Jun 2022 23:30:32 +0200 Subject: [PATCH 137/207] animation kwargs handling bug fix --- magpylib/_src/display/traces_generic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index a97ec1392..cfa9d5d2c 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -923,7 +923,7 @@ def process_animation_kwargs(obj_list, animation=False, **kwargs): warnings.warn("No path to be animated detected, displaying standard plot") anim_def = Config.display.animation.copy() - anim_def.update({k[10:]: v for k, v in kwargs.items()}) + anim_def.update({k[10:]: v for k, v in kwargs.items()}, _match_properties=False) animation_kwargs = {f"animation_{k}": v for k, v in anim_def.as_dict().items()} kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")} return kwargs, animation, animation_kwargs From 0463120550121c2a05b7908f5aeb37381c6331aa Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 24 Jun 2022 08:37:54 +0200 Subject: [PATCH 138/207] simplify structure and avoid redundancy --- magpylib/__init__.py | 2 +- magpylib/_src/obj_classes/__init__.py | 25 +------------------------ magpylib/current/__init__.py | 3 ++- magpylib/magnet/__init__.py | 6 +++++- magpylib/misc/__init__.py | 3 ++- 5 files changed, 11 insertions(+), 28 deletions(-) diff --git a/magpylib/__init__.py b/magpylib/__init__.py index 1d8839f43..69d9ce4ea 100644 --- a/magpylib/__init__.py +++ b/magpylib/__init__.py @@ -52,6 +52,6 @@ from magpylib import magnet, current, misc, core, graphics from magpylib._src.defaults.defaults_classes import default_settings as defaults from magpylib._src.fields import getB, getH -from magpylib._src.obj_classes import Sensor +from magpylib._src.obj_classes.class_Sensor import Sensor from magpylib._src.obj_classes.class_Collection import Collection from magpylib._src.display.display import show diff --git a/magpylib/_src/obj_classes/__init__.py b/magpylib/_src/obj_classes/__init__.py index e5c57e5bb..c83c77939 100644 --- a/magpylib/_src/obj_classes/__init__.py +++ b/magpylib/_src/obj_classes/__init__.py @@ -1,24 +1 @@ -"""_src.obj_classes""" - -__all__ = [ - "Cuboid", - "Cylinder", - "Sphere", - "Sensor", - "Dipole", - "Loop", - "Line", - "CylinderSegment", - "CustomSource", -] - -# create interface to outside of package -from magpylib._src.obj_classes.class_mag_Cuboid import Cuboid -from magpylib._src.obj_classes.class_mag_Cylinder import Cylinder -from magpylib._src.obj_classes.class_Sensor import Sensor -from magpylib._src.obj_classes.class_mag_Sphere import Sphere -from magpylib._src.obj_classes.class_misc_Dipole import Dipole -from magpylib._src.obj_classes.class_current_Loop import Loop -from magpylib._src.obj_classes.class_current_Line import Line -from magpylib._src.obj_classes.class_mag_CylinderSegment import CylinderSegment -from magpylib._src.obj_classes.class_misc_Custom import CustomSource +"""_src.obj_classes""" \ No newline at end of file diff --git a/magpylib/current/__init__.py b/magpylib/current/__init__.py index 137e8369a..4f87fde1e 100644 --- a/magpylib/current/__init__.py +++ b/magpylib/current/__init__.py @@ -4,4 +4,5 @@ __all__ = ["Loop", "Line"] -from magpylib._src.obj_classes import Loop, Line +from magpylib._src.obj_classes.class_current_Loop import Loop +from magpylib._src.obj_classes.class_current_Line import Line diff --git a/magpylib/magnet/__init__.py b/magpylib/magnet/__init__.py index c9affa98c..dd51579ac 100644 --- a/magpylib/magnet/__init__.py +++ b/magpylib/magnet/__init__.py @@ -4,4 +4,8 @@ __all__ = ["Cuboid", "Cylinder", "Sphere", "CylinderSegment"] -from magpylib._src.obj_classes import Cuboid, Cylinder, Sphere, CylinderSegment + +from magpylib._src.obj_classes.class_mag_Cuboid import Cuboid +from magpylib._src.obj_classes.class_mag_Cylinder import Cylinder +from magpylib._src.obj_classes.class_mag_Sphere import Sphere +from magpylib._src.obj_classes.class_mag_CylinderSegment import CylinderSegment \ No newline at end of file diff --git a/magpylib/misc/__init__.py b/magpylib/misc/__init__.py index 2f42b35de..4e336393e 100644 --- a/magpylib/misc/__init__.py +++ b/magpylib/misc/__init__.py @@ -4,4 +4,5 @@ __all__ = ["Dipole", "CustomSource"] -from magpylib._src.obj_classes import Dipole, CustomSource +from magpylib._src.obj_classes.class_misc_Dipole import Dipole +from magpylib._src.obj_classes.class_misc_Custom import CustomSource From 0856e58d890dc0cb6625393284c13b6873789610 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 24 Jun 2022 08:58:55 +0200 Subject: [PATCH 139/207] fix import paths --- .../_src/display/plotly/plotly_display.py | 20 +++++++++---------- magpylib/_src/input_checks.py | 4 ++-- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py index 1a06a5427..2fca4be88 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -508,14 +508,14 @@ def get_plotly_traces( # pylint: disable=too-many-statements # pylint: disable=too-many-nested-blocks - Sensor = _src.obj_classes.Sensor - Cuboid = _src.obj_classes.Cuboid - Cylinder = _src.obj_classes.Cylinder - CylinderSegment = _src.obj_classes.CylinderSegment - Sphere = _src.obj_classes.Sphere - Dipole = _src.obj_classes.Dipole - Loop = _src.obj_classes.Loop - Line = _src.obj_classes.Line + Sensor = _src.obj_classes.class_Sensor.Sensor + Cuboid = _src.obj_classes.class_mag_Cuboid.Cuboid + Cylinder = _src.obj_classes.class_mag_Cylinder.Cylinder + CylinderSegment = _src.obj_classes.class_mag_CylinderSegment.CylinderSegment + Sphere = _src.obj_classes.class_mag_Sphere.Sphere + Dipole = _src.obj_classes.class_misc_Dipole.Dipole + Loop = _src.obj_classes.class_current_Loop.Loop + Line = _src.obj_classes.class_current_Line.Line # parse kwargs into style and non style args style = get_style(input_obj, Config, **kwargs) @@ -743,8 +743,8 @@ def draw_frame( """ # pylint: disable=protected-access return_autosize = False - Sensor = _src.obj_classes.Sensor - Dipole = _src.obj_classes.Dipole + Sensor = _src.obj_classes.class_Sensor.Sensor + Dipole = _src.obj_classes.class_misc_Dipole.Dipole traces_out = {} # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. # autosize is calculated from the other traces overall scene range diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 3af777a4b..4565a08dd 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -448,7 +448,7 @@ def check_format_input_observers(inp, pixel_agg=None): try: # try if input is just a pos_vec inp = np.array(inp, dtype=float) pix_shapes = [(1, 3) if inp.shape == (3,) else inp.shape] - return [_src.obj_classes.Sensor(pixel=inp)], pix_shapes + return [_src.obj_classes.class_Sensor.Sensor(pixel=inp)], pix_shapes except (TypeError, ValueError): # if not, it must be [pos_vec, sens, coll] sensors = [] for obj in inp: @@ -462,7 +462,7 @@ def check_format_input_observers(inp, pixel_agg=None): else: # if its not a Sensor or a Collection it can only be a pos_vec try: obj = np.array(obj, dtype=float) - sensors.append(_src.obj_classes.Sensor(pixel=obj)) + sensors.append(_src.obj_classes.class_Sensor.Sensor(pixel=obj)) except Exception: # or some unwanted crap raise MagpylibBadUserInput(wrong_obj_msg(obj, allow="observers")) From 03dc2f0dabcf031ca9a61cc2f9e8ba18f42919c3 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 25 Jun 2022 22:14:16 +0200 Subject: [PATCH 140/207] remove superfluous nan value in scatter mag arrow --- magpylib/_src/display/traces_generic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index bbaa16868..6dcfadb27 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -496,7 +496,8 @@ def make_mag_arrows(obj, style, legendgroup, kwargs): # insert empty point to avoid connecting line between arrows points = np.array(points) points = np.insert(points, points.shape[-1], np.nan, axis=2) - x, y, z = np.concatenate(points.swapaxes(1, 2)).T + # remove last nan after insert with [:-1] + x, y, z = np.concatenate(points.swapaxes(1, 2))[:-1].T trace = { "type": "scatter3d", "mode": "lines", From eb5113e7d686a7376e3b1dc9cb0b6748d1d19e2e Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 25 Jun 2022 23:02:03 +0200 Subject: [PATCH 141/207] pylint --- magpylib/_src/obj_classes/__init__.py | 2 +- magpylib/magnet/__init__.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/obj_classes/__init__.py b/magpylib/_src/obj_classes/__init__.py index c83c77939..5b95f68d0 100644 --- a/magpylib/_src/obj_classes/__init__.py +++ b/magpylib/_src/obj_classes/__init__.py @@ -1 +1 @@ -"""_src.obj_classes""" \ No newline at end of file +"""_src.obj_classes""" diff --git a/magpylib/magnet/__init__.py b/magpylib/magnet/__init__.py index dd51579ac..32a595551 100644 --- a/magpylib/magnet/__init__.py +++ b/magpylib/magnet/__init__.py @@ -8,4 +8,4 @@ from magpylib._src.obj_classes.class_mag_Cuboid import Cuboid from magpylib._src.obj_classes.class_mag_Cylinder import Cylinder from magpylib._src.obj_classes.class_mag_Sphere import Sphere -from magpylib._src.obj_classes.class_mag_CylinderSegment import CylinderSegment \ No newline at end of file +from magpylib._src.obj_classes.class_mag_CylinderSegment import CylinderSegment From c143164a72cdf7b5acf5f248d98510dae10bb4c6 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 25 Jun 2022 23:09:59 +0200 Subject: [PATCH 142/207] lgtm --- magpylib/_src/display/traces_generic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 6dcfadb27..f34d76f1a 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -861,7 +861,7 @@ def subdivide_mesh_by_facecolor(trace): facecolor = trace["facecolor"] subtraces = [] # pylint: disable=singleton-comparison - facecolor[facecolor == None] = "black" + facecolor[facecolor == np.array(None)] = "black" for color in np.unique(facecolor): mask = facecolor == color new_trace = trace.copy() From beaddc07d1b6ddc348796d91a26d93a78dcafbbc Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 26 Jun 2022 22:42:41 +0200 Subject: [PATCH 143/207] add symb and line style translation for mpl --- magpylib/_src/display/backend_matplotlib.py | 23 ++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index fcf9122cd..b1ddd65dc 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -7,6 +7,17 @@ # from magpylib._src.utility import format_obj_input +SYMBOLS = {"circle": "o", "cross": "+", "diamond": "d", "square": "s", "x": "x"} + +LINE_STYLES = { + "solid": "-", + "dash": "--", + "dashdot": "-.", + "dot": (0, (1, 1)), + "longdash": "loosely dotted", + "longdashdot": "loosely dashdotted", +} + def generic_trace_to_matplotlib(trace): """Transform a generic trace into a matplotlib trace""" @@ -43,9 +54,15 @@ def generic_trace_to_matplotlib(trace): "ms": ("marker", "size"), }.items() } - if mode is not None and "lines" not in mode: - props["ls"] = "" - + if "ls" in props: + props["ls"] = LINE_STYLES.get(props["ls"], "solid") + if "marker" in props: + props["marker"] = SYMBOLS.get(props["marker"], "x") + if mode is not None: + if "lines" not in mode: + props["ls"] = "" + if "markers" not in mode: + props["marker"] = None trace_mpl = { "constructor": "plot", "args": (x, y, z), From f716d6671c39a8544768b544b7d6b0f98e589b7b Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 26 Jun 2022 22:57:34 +0200 Subject: [PATCH 144/207] fix animation with user axes --- magpylib/_src/display/backend_matplotlib.py | 13 ++++++++----- tests/test_display_matplotlib.py | 9 --------- 2 files changed, 8 insertions(+), 14 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index b1ddd65dc..118183ca1 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -110,16 +110,19 @@ def display_matplotlib( if canvas is None: show_canvas = True fig = plt.figure(dpi=80, figsize=(8, 8)) - canvas = fig.add_subplot(111, projection="3d") - canvas.set_box_aspect((1, 1, 1)) + ax = fig.add_subplot(111, projection="3d") + ax.set_box_aspect((1, 1, 1)) + else: + ax = canvas + fig = ax.get_figure() def draw_frame(ind): for tr in frames[ind]["data"]: constructor = tr["constructor"] args = tr["args"] kwargs = tr["kwargs"] - getattr(canvas, constructor)(*args, **kwargs) - canvas.set( + getattr(ax, constructor)(*args, **kwargs) + ax.set( **{f"{k}label": f"{k} [mm]" for k in "xyz"}, **{f"{k}lim": r for k, r in zip("xyz", ranges)}, ) @@ -127,7 +130,7 @@ def draw_frame(ind): def animate(ind): plt.cla() draw_frame(ind) - return [canvas] + return [ax] if len(frames) == 1: draw_frame(0) diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py index 436595ad3..ca8e258a8 100644 --- a/tests/test_display_matplotlib.py +++ b/tests/test_display_matplotlib.py @@ -159,15 +159,6 @@ def test_circular_line_display(): assert x is None, "display test fail" -def test_matplotlib_animation_warning(): - """animation=True with matplotlib should raise UserWarning""" - ax = plt.subplot(projection="3d") - sens = magpy.Sensor(pixel=[(1, 2, 3), (2, 3, 4)]) - sens.move(np.linspace((0.4, 0.4, 0.4), (12.4, 12.4, 12.4), 33), start=-1) - with pytest.warns(UserWarning): - sens.show(canvas=ax, animation=True) - - def test_matplotlib_model3d_extra(): """test display extra model3d""" From bddecf51f88aa3b67f747fd790a264b2b809589a Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 26 Jun 2022 23:42:50 +0200 Subject: [PATCH 145/207] fix output dataframe ignored --- magpylib/_src/fields/field_wrap_BH_level2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 8635897eb..f5d4eee2c 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -323,7 +323,7 @@ def getBH_level2( if sumup: B = np.sum(B, axis=0, keepdims=True) - output = check_getBH_output_type(kwargs.get("output", "ndarray")) + output = check_getBH_output_type(output) if output == "dataframe": # pylint: disable=import-outside-toplevel From d4a3b0c8406166f25587d0183f3300a07b4821fe Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 10:56:45 +0200 Subject: [PATCH 146/207] add tests --- tests/test_getBH_interfaces.py | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index bbc6c0a26..ce9e1fda9 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -1,3 +1,8 @@ + +import sys +from unittest import mock + +import pytest import numpy as np import magpylib as magpy @@ -172,3 +177,39 @@ def test_getH_interfaces3(): H_test = src.getH([sens, sens]) np.testing.assert_allclose(H3, H_test) + +def test_dataframe_ouptut(): + """test pandas dataframe output""" + max_path_len =20 + num_of_pix = 2 + + sources = [ + magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)).move( + np.linspace((-4, 0, 0), (4, 0, 0), max_path_len), start=0 + ), + magpy.magnet.Cylinder((0, 1000, 0), (1, 1), style_label="Cylinder1").move( + np.linspace((0, -4, 0), (0, 4, 0), max_path_len), start=0 + ), + ] + pixel = np.linspace((0, 0, 0), (0, 3, 0), num_of_pix) + sens1 = magpy.Sensor(position=(0, 0, 1), pixel=pixel, style_label="sens1") + sens2 = sens1.copy(position=(0, 0, 3), style_label="sens2") + sens_col = magpy.Collection(sens1, sens2) + + for field in 'BH': + cols = [f"{field}{k}" for k in "xyz"] + df = getattr(magpy,f"get{field}")(sources, sens_col, sumup=False, output='dataframe') + BH = getattr(magpy,f"get{field}")(sources, sens_col, sumup=False, squeeze=False) + for i in range(2): + np.testing.assert_array_equal(BH[i].reshape(-1,3), df[df['source']==df['source'].unique()[i]][cols]) + np.testing.assert_array_equal(BH[:,i].reshape(-1,3), df[df['path']==df['path'].unique()[i]][cols]) + np.testing.assert_array_equal(BH[:,:,i].reshape(-1,3), df[df['sensor']==df['sensor'].unique()[i]][cols]) + np.testing.assert_array_equal(BH[:,:,:,i].reshape(-1,3), df[df['pixel']==df['pixel'].unique()[i]][cols]) + + +def test_dataframe_output_missing_pandas(): + """test if pandas is installed when using dataframe output in `getBH`""" + src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) + with mock.patch.dict(sys.modules, {"pandas": None}): + with pytest.raises(ModuleNotFoundError): + src.getB((0,0,0), output='dataframe') \ No newline at end of file From f29eadca3a6af393e0af1448460e56303d2b98b3 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 11:08:58 +0200 Subject: [PATCH 147/207] add changelog entry --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 27939f8b5..1d5199ca8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ All notable changes to magpylib are documented here. # Releases +# Unreleased +* Field computation `getB`/`getH` now supports 2D [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe) in addition to the `numpy.ndarray` as output type. ([#523](https://github.com/magpylib/magpylib/pull/523)) ## [4.0.4] - 2022-06-09 @@ -378,6 +380,7 @@ The first official release of the magpylib library. --- +[Unreleased]:https://github.com/magpylib/magpylib/compare/4.0.4...HEAD [4.0.4]:https://github.com/magpylib/magpylib/compare/4.0.3...4.0.4 [4.0.3]:https://github.com/magpylib/magpylib/compare/4.0.2...4.0.3 [4.0.2]:https://github.com/magpylib/magpylib/compare/4.0.1...4.0.2 From 6e44c7309d60b46ed959744c5a55ca9b6de996da Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 11:44:45 +0200 Subject: [PATCH 148/207] add example --- docs/_pages/page_01_introduction.md | 36 +++++++++++++++++++++++++---- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/docs/_pages/page_01_introduction.md b/docs/_pages/page_01_introduction.md index 7c1a3bba9..29fca825c 100644 --- a/docs/_pages/page_01_introduction.md +++ b/docs/_pages/page_01_introduction.md @@ -399,19 +399,45 @@ fig.show() import magpylib as magpy # 3 sources, one with length 11 path -source1 = magpy.misc.Dipole(moment=(0,0,100), position=[(1,1,1)]*11) -source2 = magpy.current.Loop(current=1, diameter=3) +pos_path = [(i/5,0,1) for i in range(-5,5)] +source1 = magpy.misc.Dipole(moment=(0,0,100), position=pos_path) +source2 = magpy.current.Loop(current=10, diameter=3) source3 = source1 + source2 # 2 observers, each with 4x5 pixel -sensor1 = magpy.Sensor(pixel=[[(1,2,3)]*4]*5) -sensor2 = sensor1.copy() +pixel = [[[(i/10,j/10,0)] for i in range(4)] for j in range(5)] +sensor1 = magpy.Sensor(pixel=pixel, position=(-1,0,-1)) +sensor2 = sensor1.copy().move((2,0,0)) +sources = [source1, source2, source3] +sensors = [sensor1, sensor2] # compute field -B = magpy.getB([source1, source2, source3], [sensor1, sensor2]) +B = magpy.getB(sources, sensors) print(B.shape) ``` +For convenience, the result can also be outputted as [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe). + +```{code-cell} ipython3 +B_as_df = magpy.getB(sources, sensors, output='dataframe') +B_as_df +``` + +Plotting libraries such as [plotly](https://plotly.com/python/plotly-express/) or [seaborn](https://seaborn.pydata.org/introduction.html) can take nice advantage over this feature, as they can deal with `dataframes` directly. + +```{code-cell} ipython3 +import plotly.express as px + +px.line( + B_as_df, + x="path", + y="Bx", + color="pixel", + line_group="source", + facet_col="source", + symbol="sensor", +) +``` In terms of **performance** it must be noted that Magpylib automatically vectorizes all computations when `getB` and `getH` are called. This reduces the computation time dramatically for large inputs. For maximal performance try to make all field computations with as few calls to `getB` and `getH` as possible. (intro-direct-interface)= From a1e32b9ac76b545299151dc9e1815a3d5104af82 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 13:49:44 +0200 Subject: [PATCH 149/207] black --- tests/test_getBH_interfaces.py | 34 ++++++++++++++++++++++++---------- 1 file changed, 24 insertions(+), 10 deletions(-) diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index ce9e1fda9..691453617 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -1,4 +1,3 @@ - import sys from unittest import mock @@ -178,9 +177,10 @@ def test_getH_interfaces3(): H_test = src.getH([sens, sens]) np.testing.assert_allclose(H3, H_test) + def test_dataframe_ouptut(): """test pandas dataframe output""" - max_path_len =20 + max_path_len = 20 num_of_pix = 2 sources = [ @@ -196,15 +196,29 @@ def test_dataframe_ouptut(): sens2 = sens1.copy(position=(0, 0, 3), style_label="sens2") sens_col = magpy.Collection(sens1, sens2) - for field in 'BH': + for field in "BH": cols = [f"{field}{k}" for k in "xyz"] - df = getattr(magpy,f"get{field}")(sources, sens_col, sumup=False, output='dataframe') - BH = getattr(magpy,f"get{field}")(sources, sens_col, sumup=False, squeeze=False) + df = getattr(magpy, f"get{field}")( + sources, sens_col, sumup=False, output="dataframe" + ) + BH = getattr(magpy, f"get{field}")( + sources, sens_col, sumup=False, squeeze=False + ) for i in range(2): - np.testing.assert_array_equal(BH[i].reshape(-1,3), df[df['source']==df['source'].unique()[i]][cols]) - np.testing.assert_array_equal(BH[:,i].reshape(-1,3), df[df['path']==df['path'].unique()[i]][cols]) - np.testing.assert_array_equal(BH[:,:,i].reshape(-1,3), df[df['sensor']==df['sensor'].unique()[i]][cols]) - np.testing.assert_array_equal(BH[:,:,:,i].reshape(-1,3), df[df['pixel']==df['pixel'].unique()[i]][cols]) + np.testing.assert_array_equal( + BH[i].reshape(-1, 3), df[df["source"] == df["source"].unique()[i]][cols] + ) + np.testing.assert_array_equal( + BH[:, i].reshape(-1, 3), df[df["path"] == df["path"].unique()[i]][cols] + ) + np.testing.assert_array_equal( + BH[:, :, i].reshape(-1, 3), + df[df["sensor"] == df["sensor"].unique()[i]][cols], + ) + np.testing.assert_array_equal( + BH[:, :, :, i].reshape(-1, 3), + df[df["pixel"] == df["pixel"].unique()[i]][cols], + ) def test_dataframe_output_missing_pandas(): @@ -212,4 +226,4 @@ def test_dataframe_output_missing_pandas(): src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) with mock.patch.dict(sys.modules, {"pandas": None}): with pytest.raises(ModuleNotFoundError): - src.getB((0,0,0), output='dataframe') \ No newline at end of file + src.getB((0, 0, 0), output="dataframe") From d26d129fab020302f485485fce7aeeab973c09a6 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 13:59:38 +0200 Subject: [PATCH 150/207] test cov --- magpylib/_src/input_checks.py | 2 +- tests/test_getBH_interfaces.py | 16 ++++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 3af777a4b..b0d3f959e 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -623,7 +623,7 @@ def check_getBH_output_type(output): """check if getBH output is acceptable""" acceptable = ("ndarray", "dataframe") if output not in acceptable: - raise AttributeError( + raise ValueError( "The `output` argument must be one of {acceptable}." f"\nInstead received {output}." ) diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index 691453617..753df31ba 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -221,9 +221,25 @@ def test_dataframe_ouptut(): ) +def test_dataframe_ouptut_sumup(): + """test pandas dataframe output when sumup is True""" + sources = [ + magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)), + magpy.magnet.Cylinder((0, 1000, 0), (1, 1)) + ] + magpy.getB(sources, (0,0,0), sumup=True, output="dataframe") + + def test_dataframe_output_missing_pandas(): """test if pandas is installed when using dataframe output in `getBH`""" src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) with mock.patch.dict(sys.modules, {"pandas": None}): with pytest.raises(ModuleNotFoundError): src.getB((0, 0, 0), output="dataframe") + + +def test_getBH_bad_output_type(): + """test bad output in `getBH`""" + src = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) + with pytest.raises(ValueError): + src.getB((0, 0, 0), output="bad_output_type") From 6a876dc4e570ff186450793754fa6eca62af4ec5 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 14:38:36 +0200 Subject: [PATCH 151/207] pylint --- magpylib/_src/display/backend_matplotlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 118183ca1..3e3ca6dc6 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -145,5 +145,5 @@ def animate(ind): ) if return_animation and len(frames) != 1: return anim - elif show_canvas: + if show_canvas: plt.show() From 2fdfaecc1fd38a7658c9916864278c7d306dde31 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 15:08:12 +0200 Subject: [PATCH 152/207] fix traces grouping --- magpylib/_src/display/traces_generic.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index f34d76f1a..bffd4bb86 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -831,9 +831,24 @@ def group_traces(*traces): browser rendering performance when displaying a lot of mesh3d objects.""" mesh_groups = {} common_keys = ["legendgroup", "opacity"] - # TODO grouping does not dectect line_width vs line=dict(with=...) - spec_keys = {"mesh3d": ["colorscale"], "scatter3d": ["marker", "line", "mode"]} + spec_keys = { + "mesh3d": ["colorscale"], + "scatter3d": [ + "marker", + "line_dash", + "line_color", + "line_width", + "marker_color", + "marker_symbol", + "marker_size", + "mode", + ], + } for tr in traces: + tr = linearize_dict( + tr, + separator="_", + ) gr = [tr["type"]] for k in common_keys + spec_keys[tr["type"]]: try: From 160508c641a6d62a498f737a257dd2e1cdb837bd Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 27 Jun 2022 17:42:19 +0200 Subject: [PATCH 153/207] draft extra model3d compatibility --- magpylib/_src/display/backend_matplotlib.py | 32 +++++++- magpylib/_src/display/backend_plotly.py | 38 +++++++++- magpylib/_src/display/traces_generic.py | 84 ++++++++++++++++----- 3 files changed, 133 insertions(+), 21 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 3e3ca6dc6..0473e921d 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -2,6 +2,7 @@ import numpy as np from matplotlib.animation import FuncAnimation +from magpylib._src.display.traces_utility import place_and_orient_model3d from magpylib._src.display.traces_generic import get_frames from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor @@ -79,6 +80,26 @@ def generic_trace_to_matplotlib(trace): return traces_mpl +def process_extra_trace(model): + "process extra trace attached to some magpylib object" + extr = model["model3d"] + model_kwargs = {"color": model["kwargs"]["color"]} + model_kwargs.update(extr.kwargs() if callable(extr.kwargs) else extr.kwargs) + model_args = extr.args() if callable(extr.args) else extr.args + trace3d = {"constructor": extr.constructor, "kwargs":model_kwargs, "args":model_args} + kwargs, args, = place_and_orient_model3d( + model_kwargs=model_kwargs, + model_args=model_args, + orientation=model["orientation"], + position=model["position"], + coordsargs=extr.coordsargs, + scale=extr.scale, + return_model_args=True, + ) + trace3d["kwargs"].update(kwargs) + trace3d["args"] = args + return trace3d + def display_matplotlib( *obj_list, zoom=1, @@ -97,15 +118,20 @@ def display_matplotlib( zoom=zoom, animation=animation, mag_arrows=True, + return_extra_backend_traces="matplotlib", **kwargs, ) frames = data["frames"] ranges = data["ranges"] for fr in frames: - fr["data"] = [ - tr0 for tr1 in fr["data"] for tr0 in generic_trace_to_matplotlib(tr1) - ] + new_data = [] + for tr in fr["data"]: + new_data.extend(generic_trace_to_matplotlib(tr)) + for model in fr["extra_backend_traces"]: + new_data.append(process_extra_trace(model)) + fr["data"] = new_data + show_canvas = False if canvas is None: show_canvas = True diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index c8c31160f..ff58b65c2 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -12,6 +12,8 @@ from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.display.traces_generic import get_frames +from magpylib._src.defaults.defaults_utility import linearize_dict +from magpylib._src.display.traces_utility import place_and_orient_model3d from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY @@ -149,6 +151,35 @@ def generic_trace_to_plotly(trace): return trace +def process_extra_trace(model): + "process extra trace attached to some magpylib object" + extr = model["model3d"] + kwargs = model["kwargs"] + trace3d = {**kwargs} + ttype = extr.constructor.lower() + trace_kwargs = extr.kwargs() if callable(extr.kwargs) else extr.kwargs + trace3d.update({"type": ttype, **trace_kwargs}) + if ttype == "scatter3d": + for k in ("marker", "line"): + trace3d["{k}_color"] = trace3d.get(f"{k}_color", kwargs["color"]) + trace3d.pop("color", None) + elif ttype == "mesh3d": + trace3d["showscale"] = trace3d.get("showscale", False) + trace3d["color"] = trace3d.get("color", kwargs["color"]) + trace3d.update( + linearize_dict( + place_and_orient_model3d( + model_kwargs=trace3d, + orientation=model["orientation"], + position=model["position"], + scale=extr.scale, + ), + separator="_", + ) + ) + return trace3d + + def display_plotly( *obj_list, zoom=1, @@ -174,12 +205,17 @@ def display_plotly( colorsequence=colorsequence, zoom=zoom, animation=animation, + return_extra_backend_traces="plotly", **kwargs, ) frames = data["frames"] for fr in frames: + new_data = [] for tr in fr["data"]: - tr = generic_trace_to_plotly(tr) + new_data.append(generic_trace_to_plotly(tr)) + for model in fr["extra_backend_traces"]: + new_data.append(process_extra_trace(model)) + fr["data"] = new_data with canvas.batch_update(): if len(frames) == 1: canvas.add_traces(frames[0]["data"]) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index bffd4bb86..a9af475dd 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -520,6 +520,7 @@ def get_generic_traces( showlegend=None, legendtext=None, mag_arrows=False, + return_extra_backend_traces=False, **kwargs, ) -> list: """ @@ -645,13 +646,19 @@ def get_generic_traces( kwargs.update(obj=input_obj) make_func = make_DefaultTrace + label = ( + input_obj.style.label + if input_obj.style.label is not None + else str(type(input_obj).__name__) + ) path_traces = [] - path_traces_extra = {} + path_traces_extra_generic = {} + path_traces_extra_specific_backend = [] extra_model3d_traces = ( style.model3d.data if style.model3d.data is not None else [] ) rots, poss, _ = get_rot_pos_from_path(input_obj, style.path.frames) - for orient, pos in zip(rots, poss): + for pos_orient_ind, (orient, pos) in enumerate(zip(rots, poss)): if style.model3d.showdefault and make_func is not None: path_traces.append( make_func(position=pos, orientation=orient, **kwargs) @@ -691,17 +698,32 @@ def get_generic_traces( separator="_", ) ) - if ttype not in path_traces_extra: - path_traces_extra[ttype] = [] - path_traces_extra[ttype].append(trace3d) + if ttype not in path_traces_extra_generic: + path_traces_extra_generic[ttype] = [] + path_traces_extra_generic[ttype].append(trace3d) + elif extr.backend == return_extra_backend_traces: + showleg = ( + showlegend + and pos_orient_ind == 0 + and not style.model3d.showdefault + ) + showleg = True if showleg is None else showleg + trace3d = { + "model3d": extr, + "position": pos, + "orientation": orient, + "kwargs": { + "opacity": kwargs["opacity"], + "color": kwargs["color"], + "legendgroup": legendgroup, + "name": label, + "showlegend": showleg, + }, + } + path_traces_extra_specific_backend.append(trace3d) trace = merge_traces(*path_traces) - for ind, traces_extra in enumerate(path_traces_extra.values()): + for ind, traces_extra in enumerate(path_traces_extra_generic.values()): extra_model3d_trace = merge_traces(*traces_extra) - label = ( - input_obj.style.label - if input_obj.style.label is not None - else str(type(input_obj).__name__) - ) extra_model3d_trace.update( { "legendgroup": legendgroup, @@ -728,8 +750,10 @@ def get_generic_traces( if mag_arrows and getattr(input_obj, "magnetization", None) is not None: traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) - - return traces + out = (traces,) + if return_extra_backend_traces is not False: + out += (path_traces_extra_specific_backend,) + return out[0] if len(out) == 1 else out def make_path(input_obj, style, legendgroup, kwargs): @@ -771,6 +795,7 @@ def draw_frame( output="dict", return_ranges=False, mag_arrows=False, + return_extra_backend_traces=False, **kwargs, ) -> Tuple: """ @@ -785,7 +810,7 @@ def draw_frame( # pylint: disable=protected-access if colorsequence is None: colorsequence = Config.display.colorsequence - + extra_backend_traces = [] return_autosize = False Sensor = _src.obj_classes.class_Sensor.Sensor Dipole = _src.obj_classes.class_misc_Dipole.Dipole @@ -804,7 +829,16 @@ def draw_frame( x, y, z = obj._position.T traces_out[obj] = [dict(x=x, y=y, z=z)] else: - traces_out[obj] = get_generic_traces(obj, mag_arrows=mag_arrows, **params) + out_traces = get_generic_traces( + obj, + mag_arrows=mag_arrows, + return_extra_backend_traces=return_extra_backend_traces, + **params, + ) + if return_extra_backend_traces is not False: + out_traces, ebt = out_traces + extra_backend_traces.extend(ebt) + traces_out[obj] = out_traces traces = [t for tr in traces_out.values() for t in tr] ranges = get_scene_ranges(*traces, zoom=zoom) if autosize is None or autosize == "return": @@ -812,9 +846,17 @@ def draw_frame( return_autosize = True autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor for obj, params in traces_to_resize.items(): - traces_out[obj] = get_generic_traces( - obj, autosize=autosize, mag_arrows=mag_arrows, **params + out_traces = get_generic_traces( + obj, + autosize=autosize, + mag_arrows=mag_arrows, + return_extra_backend_traces=return_extra_backend_traces, + **params, ) + if return_extra_backend_traces is not False: + out_traces, ebt = out_traces + extra_backend_traces.extend(ebt) + traces_out[obj] = out_traces if output == "list": traces = [t for tr in traces_out.values() for t in tr] traces_out = group_traces(*traces) @@ -823,6 +865,8 @@ def draw_frame( res += (autosize,) if return_ranges: res += (ranges,) + if return_extra_backend_traces: + res += (extra_backend_traces, ) return res[0] if len(res) == 1 else res @@ -1030,6 +1074,7 @@ def get_frames( title=None, animation=False, mag_arrows=False, + return_extra_backend_traces=False, **kwargs, ): """This is a helper function which generates frames with generic traces to be provided to @@ -1060,6 +1105,7 @@ def get_frames( autosize = "return" title_str = title for i, ind in enumerate(path_indices): + extra_backend_traces = [] if animation: kwargs["style_path_frames"] = [ind] title = "Animation 3D - " if title is None else title @@ -1071,8 +1117,11 @@ def get_frames( autosize=autosize, output="list", mag_arrows=mag_arrows, + return_extra_backend_traces=return_extra_backend_traces, **kwargs, ) + if return_extra_backend_traces is not False: + *frame, extra_backend_traces = frame if i == 0: # get the dipoles and sensors autosize from first frame traces, autosize = frame else: @@ -1082,6 +1131,7 @@ def get_frames( data=traces, name=str(ind + 1), layout=dict(title=title_str), + extra_backend_traces=extra_backend_traces, ) ) From ac31716b2c0eddb5b55a2e338867416cba0d60fa Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 27 Jun 2022 23:49:27 +0200 Subject: [PATCH 154/207] fix tests --- magpylib/_src/display/backend_matplotlib.py | 27 ++++++----- magpylib/_src/display/backend_plotly.py | 5 ++- magpylib/_src/display/traces_generic.py | 50 +++++++-------------- 3 files changed, 35 insertions(+), 47 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 0473e921d..1a0c51bf7 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -2,9 +2,9 @@ import numpy as np from matplotlib.animation import FuncAnimation -from magpylib._src.display.traces_utility import place_and_orient_model3d from magpylib._src.display.traces_generic import get_frames from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor +from magpylib._src.display.traces_utility import place_and_orient_model3d # from magpylib._src.utility import format_obj_input @@ -86,20 +86,25 @@ def process_extra_trace(model): model_kwargs = {"color": model["kwargs"]["color"]} model_kwargs.update(extr.kwargs() if callable(extr.kwargs) else extr.kwargs) model_args = extr.args() if callable(extr.args) else extr.args - trace3d = {"constructor": extr.constructor, "kwargs":model_kwargs, "args":model_args} + trace3d = { + "constructor": extr.constructor, + "kwargs": model_kwargs, + "args": model_args, + } kwargs, args, = place_and_orient_model3d( - model_kwargs=model_kwargs, - model_args=model_args, - orientation=model["orientation"], - position=model["position"], - coordsargs=extr.coordsargs, - scale=extr.scale, - return_model_args=True, - ) + model_kwargs=model_kwargs, + model_args=model_args, + orientation=model["orientation"], + position=model["position"], + coordsargs=extr.coordsargs, + scale=extr.scale, + return_model_args=True, + ) trace3d["kwargs"].update(kwargs) trace3d["args"] = args return trace3d + def display_matplotlib( *obj_list, zoom=1, @@ -118,7 +123,7 @@ def display_matplotlib( zoom=zoom, animation=animation, mag_arrows=True, - return_extra_backend_traces="matplotlib", + extra_backend="matplotlib", **kwargs, ) frames = data["frames"] diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index ff58b65c2..bdbb04bd3 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -161,7 +161,7 @@ def process_extra_trace(model): trace3d.update({"type": ttype, **trace_kwargs}) if ttype == "scatter3d": for k in ("marker", "line"): - trace3d["{k}_color"] = trace3d.get(f"{k}_color", kwargs["color"]) + trace3d[f"{k}_color"] = trace3d.get(f"{k}_color", kwargs["color"]) trace3d.pop("color", None) elif ttype == "mesh3d": trace3d["showscale"] = trace3d.get("showscale", False) @@ -205,7 +205,7 @@ def display_plotly( colorsequence=colorsequence, zoom=zoom, animation=animation, - return_extra_backend_traces="plotly", + extra_backend="plotly", **kwargs, ) frames = data["frames"] @@ -216,6 +216,7 @@ def display_plotly( for model in fr["extra_backend_traces"]: new_data.append(process_extra_trace(model)) fr["data"] = new_data + fr.pop("extra_backend_traces", None) with canvas.batch_update(): if len(frames) == 1: canvas.add_traces(frames[0]["data"]) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index a9af475dd..9eba8274a 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -520,7 +520,7 @@ def get_generic_traces( showlegend=None, legendtext=None, mag_arrows=False, - return_extra_backend_traces=False, + extra_backend=False, **kwargs, ) -> list: """ @@ -568,6 +568,7 @@ def get_generic_traces( check_excitations([input_obj]) traces = [] + path_traces_extra_specific_backend = [] if isinstance(input_obj, MagpyMarkers): x, y, z = input_obj.markers.T marker = style.marker.as_dict() @@ -646,14 +647,10 @@ def get_generic_traces( kwargs.update(obj=input_obj) make_func = make_DefaultTrace - label = ( - input_obj.style.label - if input_obj.style.label is not None - else str(type(input_obj).__name__) - ) + label = getattr(getattr(input_obj, "style", None), "label", None) + label = label if label is not None else str(type(input_obj).__name__) path_traces = [] path_traces_extra_generic = {} - path_traces_extra_specific_backend = [] extra_model3d_traces = ( style.model3d.data if style.model3d.data is not None else [] ) @@ -701,7 +698,7 @@ def get_generic_traces( if ttype not in path_traces_extra_generic: path_traces_extra_generic[ttype] = [] path_traces_extra_generic[ttype].append(trace3d) - elif extr.backend == return_extra_backend_traces: + elif extr.backend == extra_backend: showleg = ( showlegend and pos_orient_ind == 0 @@ -751,7 +748,7 @@ def get_generic_traces( if mag_arrows and getattr(input_obj, "magnetization", None) is not None: traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) out = (traces,) - if return_extra_backend_traces is not False: + if extra_backend is not False: out += (path_traces_extra_specific_backend,) return out[0] if len(out) == 1 else out @@ -793,9 +790,8 @@ def draw_frame( zoom=0.0, autosize=None, output="dict", - return_ranges=False, mag_arrows=False, - return_extra_backend_traces=False, + extra_backend=False, **kwargs, ) -> Tuple: """ @@ -811,7 +807,6 @@ def draw_frame( if colorsequence is None: colorsequence = Config.display.colorsequence extra_backend_traces = [] - return_autosize = False Sensor = _src.obj_classes.class_Sensor.Sensor Dipole = _src.obj_classes.class_misc_Dipole.Dipole traces_out = {} @@ -832,42 +827,33 @@ def draw_frame( out_traces = get_generic_traces( obj, mag_arrows=mag_arrows, - return_extra_backend_traces=return_extra_backend_traces, + extra_backend=extra_backend, **params, ) - if return_extra_backend_traces is not False: + if extra_backend is not False: out_traces, ebt = out_traces extra_backend_traces.extend(ebt) traces_out[obj] = out_traces traces = [t for tr in traces_out.values() for t in tr] ranges = get_scene_ranges(*traces, zoom=zoom) if autosize is None or autosize == "return": - if autosize == "return": - return_autosize = True autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor for obj, params in traces_to_resize.items(): out_traces = get_generic_traces( obj, autosize=autosize, mag_arrows=mag_arrows, - return_extra_backend_traces=return_extra_backend_traces, + extra_backend=extra_backend, **params, ) - if return_extra_backend_traces is not False: + if extra_backend is not False: out_traces, ebt = out_traces extra_backend_traces.extend(ebt) traces_out[obj] = out_traces if output == "list": traces = [t for tr in traces_out.values() for t in tr] traces_out = group_traces(*traces) - res = (traces_out,) - if return_autosize: - res += (autosize,) - if return_ranges: - res += (ranges,) - if return_extra_backend_traces: - res += (extra_backend_traces, ) - return res[0] if len(res) == 1 else res + return traces_out, autosize, ranges, extra_backend_traces def group_traces(*traces): @@ -1074,7 +1060,7 @@ def get_frames( title=None, animation=False, mag_arrows=False, - return_extra_backend_traces=False, + extra_backend=False, **kwargs, ): """This is a helper function which generates frames with generic traces to be provided to @@ -1110,22 +1096,18 @@ def get_frames( kwargs["style_path_frames"] = [ind] title = "Animation 3D - " if title is None else title title_str = f"""{title}path index: {ind+1:0{exp}d}""" - frame = draw_frame( + traces, autosize_init, ranges, extra_backend_traces = draw_frame( objs, colorsequence, zoom, autosize=autosize, output="list", mag_arrows=mag_arrows, - return_extra_backend_traces=return_extra_backend_traces, + extra_backend=extra_backend, **kwargs, ) - if return_extra_backend_traces is not False: - *frame, extra_backend_traces = frame if i == 0: # get the dipoles and sensors autosize from first frame - traces, autosize = frame - else: - traces = frame + autosize = autosize_init frames.append( dict( data=traces, From cd543361f83e756093ef21996032d5ecf2da2db0 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 28 Jun 2022 10:30:53 +0200 Subject: [PATCH 155/207] modify for generic backend --- magpylib/_src/display/traces_utility.py | 26 +++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index 2bded1e30..5894e3d11 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -386,3 +386,29 @@ def getColorscale( (1.0, color_north), ) return colorscale + + +def get_scene_ranges(*traces, zoom=1) -> np.ndarray: + """ + Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be + any plotly trace object or a dict, with x,y,z numbered parameters. + """ + if traces: + ranges = {k: [] for k in "xyz"} + for t in traces: + for k, v in ranges.items(): + v.extend( + [ + np.nanmin(np.array(t[k], dtype=float)), + np.nanmax(np.array(t[k], dtype=float)), + ] + ) + r = np.array([[np.nanmin(v), np.nanmax(v)] for v in ranges.values()]) + size = np.diff(r, axis=1) + size[size == 0] = 1 + m = size.max() / 2 + center = r.mean(axis=1) + ranges = np.array([center - m * (1 + zoom), center + m * (1 + zoom)]).T + else: + ranges = np.array([[-1.0, 1.0]] * 3) + return ranges \ No newline at end of file From d4106be92a04276d28592da3c9cf81be599c0a91 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 28 Jun 2022 10:33:19 +0200 Subject: [PATCH 156/207] document generic backend traces --- docs/examples/examples_13_3d_models.md | 89 ++++++++++--------------- magpylib/_src/display/backend_plotly.py | 8 ++- magpylib/_src/display/traces_base.py | 12 ++-- magpylib/_src/display/traces_generic.py | 29 +------- magpylib/_src/display/traces_utility.py | 2 +- 5 files changed, 52 insertions(+), 88 deletions(-) diff --git a/docs/examples/examples_13_3d_models.md b/docs/examples/examples_13_3d_models.md index f111317eb..39ee4664e 100644 --- a/docs/examples/examples_13_3d_models.md +++ b/docs/examples/examples_13_3d_models.md @@ -18,20 +18,20 @@ kernelspec: (examples-own-3d-models)= ## Custom 3D models -Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. +Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. When using the `'generic'` backend, custom traces are automatically translated into any other backend. If a specific backend is used, it will only show when called with the corresponding backend. The input `trace` is a dictionary which includes all necessary information for plotting or a `magpylib.graphics.Trace3d` object. A `trace` dictionary has the following keys: -1. `'backend'`: `'matplotlib'` or `'plotly'` +1. `'backend'`: `'generic'`, `'matplotlib'` or `'plotly'` 2. `'constructor'`: name of the plotting constructor from the respective backend, e.g. plotly `'Mesh3d'` or matplotlib `'plot_surface'` 3. `'args'`: default `None`, positional arguments handed to constructor 4. `'kwargs'`: default `None`, keyword arguments handed to constructor -5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the Plotly backend and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend. +5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the `'generic'` backend and Plotly backend, and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend. 6. `'show'`: default `True`, toggle if this trace should be displayed 7. `'scale'`: default 1, object geometric scaling factor 8. `'updatefunc'`: default `None`, updates the trace parameters when `show` is called. Used to generate dynamic traces. -The following example shows how a **Plotly** trace is constructed with `Mesh3d` and `Scatter3d`: +The following example shows how a **generic** trace is constructed with `Mesh3d` and `Scatter3d`: ```{code-cell} ipython3 import numpy as np @@ -40,7 +40,7 @@ import magpylib as magpy # Mesh3d trace ######################### trace_mesh3d = { - 'backend': 'plotly', + 'backend': 'generic', 'constructor': 'Mesh3d', 'kwargs': { 'x': (1, 0, -1, 0), @@ -59,7 +59,7 @@ coll.style.model3d.add_trace(trace_mesh3d) ts = np.linspace(0, 2*np.pi, 30) trace_scatter3d = { - 'backend': 'plotly', + 'backend': 'generic', 'constructor': 'Scatter3d', 'kwargs': { 'x': np.cos(ts), @@ -68,7 +68,7 @@ trace_scatter3d = { 'mode': 'lines', } } -dipole = magpy.misc.Dipole(moment=(0,0,1), style_label="'Scatter3d' trace") +dipole = magpy.misc.Dipole(moment=(0,0,1), style_label="'Scatter3d' trace", style_size=6) dipole.style.model3d.add_trace(trace_scatter3d) magpy.show(coll, dipole, backend='plotly') @@ -94,7 +94,7 @@ trace3.kwargs['z'] = np.cos(ts) dipole.style.model3d.add_trace(trace3) -dipole.show(dipole, backend='plotly') +dipole.show(dipole, backend='matplotlib') ``` **Matplotlib** plotting functions often use positional arguments for $(x,y,z)$ input, that are handed over from `args=(x,y,z)` in `trace`. The following examples show how to construct traces with `plot`, `plot_surface` and `plot_trisurf`: @@ -160,12 +160,12 @@ trace_trisurf = { mobius = magpy.misc.CustomSource(style_model3d_showdefault=False, position=(3,0,0)) mobius.style.model3d.add_trace(trace_trisurf) -magpy.show(magnet, ball, mobius) +magpy.show(magnet, ball, mobius, zoom=2) ``` ## Pre-defined 3D models -Automatic trace generators are provided for several 3D models in `magpylib.graphics.model3d`. They can be used as follows, +Automatic trace generators are provided for several basic 3D models in `magpylib.graphics.model3d`. If no backend is specified, it defaults back to `'generic'`. They can be used as follows, ```{code-cell} ipython3 import magpylib as magpy @@ -173,7 +173,6 @@ from magpylib.graphics import model3d # prism trace ################################### trace_prism = model3d.make_Prism( - backend='plotly', base=6, diameter=2, height=1, @@ -184,7 +183,6 @@ obj0.style.model3d.add_trace(trace_prism) # pyramid trace ################################# trace_pyramid = model3d.make_Pyramid( - backend='plotly', base=30, diameter=2, height=1, @@ -195,7 +193,6 @@ obj1.style.model3d.add_trace(trace_pyramid) # cuboid trace ################################## trace_cuboid = model3d.make_Cuboid( - backend='plotly', dimension=(2,2,2), position=(0,3,0), ) @@ -204,7 +201,6 @@ obj2.style.model3d.add_trace(trace_cuboid) # cylinder segment trace ######################## trace_cylinder_segment = model3d.make_CylinderSegment( - backend='plotly', dimension=(1, 2, 1, 140, 220), position=(1,0,-3), ) @@ -213,7 +209,6 @@ obj3.style.model3d.add_trace(trace_cylinder_segment) # ellipsoid trace ############################### trace_ellipsoid = model3d.make_Ellipsoid( - backend='plotly', dimension=(2,2,2), position=(0,0,3), ) @@ -222,7 +217,6 @@ obj4.style.model3d.add_trace(trace_ellipsoid) # arrow trace ################################### trace_arrow = model3d.make_Arrow( - backend='plotly', base=30, diameter=0.6, height=2, @@ -238,7 +232,7 @@ magpy.show(obj0, obj1, obj2, obj3, obj4, obj5, backend='plotly') ## Adding a CAD model -As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. +As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a generic Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. ```{note} The code below requires installation of the `numpy-stl` package. @@ -251,18 +245,20 @@ import requests import numpy as np from stl import mesh # requires installation of numpy-stl import magpylib as magpy +from matplotlib.colors import to_hex -def get_stl_color(x): - """ transform stl_mesh attr to plotly color""" +def bin_color_to_hex(x): + """ transform binary rgb into hex color""" sb = f"{x:015b}"[::-1] - r = int(255 / 31 * int(sb[:5], base=2)) - g = int(255 / 31 * int(sb[5:10], base=2)) - b = int(255 / 31 * int(sb[10:15], base=2)) - return f"rgb({r},{g},{b})" + r = int(sb[:5], base=2)/31 + g = int(sb[5:10], base=2)/31 + b = int(sb[10:15], base=2)/31 + return to_hex((r,g,b)) + -def trace_from_stl(stl_file, backend='matplotlib'): +def trace_from_stl(stl_file): """ Generates a Magpylib 3D model trace dictionary from an *.stl file. backend: 'matplotlib' or 'plotly' @@ -278,26 +274,14 @@ def trace_from_stl(stl_file, backend='matplotlib'): k = np.take(ixr, [3 * k + 2 for k in range(p)]) x, y, z = vertices.T - # generate and return Magpylib traces - if backend == 'matplotlib': - triangles = np.array([i, j, k]).T - trace = { - 'backend': 'matplotlib', - 'constructor': 'plot_trisurf', - 'args': (x, y, z), - 'kwargs': {'triangles': triangles}, - } - elif backend == 'plotly': - colors = stl_mesh.attr.flatten() - facecolor = np.array([get_stl_color(c) for c in colors]).T - trace = { - 'backend': 'plotly', - 'constructor': 'Mesh3d', - 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), - } - else: - raise ValueError("Backend must be one of ['matplotlib', 'plotly'].") - + # generate and return a generic trace which can be translated into any backend + colors = stl_mesh.attr.flatten() + facecolor = np.array([bin_color_to_hex(c) for c in colors]).T + trace = { + 'backend': 'generic', + 'constructor': 'mesh3d', + 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), + } return trace @@ -311,21 +295,20 @@ with tempfile.TemporaryDirectory() as temp: f.write(response.content) # create traces for both backends - trace_mpl = trace_from_stl(fn, backend='matplotlib') - trace_ply = trace_from_stl(fn, backend='plotly') + trace = trace_from_stl(fn) # create sensor and add CAD model sensor = magpy.Sensor(style_label='PG-SSO-3 package') -sensor.style.model3d.add_trace(trace_mpl) -sensor.style.model3d.add_trace(trace_ply) +sensor.style.model3d.add_trace(trace) # create magnet and sensor path magnet = magpy.magnet.Cylinder(magnetization=(0,0,100), dimension=(15,20)) sensor.position = np.linspace((-15,0,8), (-15,0,-4), 21) -sensor.rotate_from_angax(np.linspace(0, 200, 21), 'z', anchor=0, start=0) +sensor.rotate_from_angax(np.linspace(0, 180, 21), 'z', anchor=0, start=0) -# display with both backends -magpy.show(sensor, magnet, style_path_frames=5, style_magnetization_show=False) -magpy.show(sensor, magnet, style_path_frames=5, backend="plotly") +# display with matplotlib and plotly backends +args = (sensor, magnet) +kwargs = dict(style_path_frames=5) +magpy.show(args, **kwargs, backend="matplotlib") +magpy.show(args, **kwargs, backend="plotly") ``` - diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index bdbb04bd3..a6e795afb 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -14,6 +14,7 @@ from magpylib._src.display.traces_generic import get_frames from magpylib._src.defaults.defaults_utility import linearize_dict from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.traces_utility import get_scene_ranges from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY @@ -193,6 +194,7 @@ def display_plotly( """Display objects and paths graphically using the plotly library.""" show_canvas = False + extra_data = False if canvas is None: show_canvas = True canvas = go.Figure() @@ -214,6 +216,7 @@ def display_plotly( for tr in fr["data"]: new_data.append(generic_trace_to_plotly(tr)) for model in fr["extra_backend_traces"]: + extra_data = True new_data.append(process_extra_trace(model)) fr["data"] = new_data fr.pop("extra_backend_traces", None) @@ -229,7 +232,10 @@ def display_plotly( data["frame_duration"], animation_slider=animation_slider, ) - apply_fig_ranges(canvas, data["ranges"]) + ranges = data["ranges"] + if extra_data: + ranges = get_scene_ranges(*frames[0]["data"], zoom=zoom) + apply_fig_ranges(canvas, ranges) canvas.update_layout(legend_itemsizing="constant") if show_canvas: canvas.show(renderer=renderer) diff --git a/magpylib/_src/display/traces_base.py b/magpylib/_src/display/traces_base.py index f8a7ecf96..a04530747 100644 --- a/magpylib/_src/display/traces_base.py +++ b/magpylib/_src/display/traces_base.py @@ -40,7 +40,7 @@ def get_model(trace, *, backend, show, scale, kwargs): def make_Cuboid( - backend, + backend='generic', dimension=(1.0, 1.0, 1.0), position=None, orientation=None, @@ -97,7 +97,7 @@ def make_Cuboid( def make_Prism( - backend, + backend='generic', base=3, diameter=1.0, height=1.0, @@ -186,7 +186,7 @@ def make_Prism( def make_Ellipsoid( - backend, + backend='generic', dimension=(1.0, 1.0, 1.0), vert=15, position=None, @@ -266,7 +266,7 @@ def make_Ellipsoid( def make_CylinderSegment( - backend, + backend='generic', dimension=(1.0, 2.0, 1.0, 0.0, 90.0), vert=50, position=None, @@ -365,7 +365,7 @@ def make_CylinderSegment( def make_Pyramid( - backend, + backend='generic', base=3, diameter=1, height=1, @@ -445,7 +445,7 @@ def make_Pyramid( def make_Arrow( - backend, + backend='generic', base=3, diameter=0.3, height=1, diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 9eba8274a..6423fe86f 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -31,6 +31,7 @@ from magpylib._src.display.traces_utility import merge_mesh3d from magpylib._src.display.traces_utility import merge_traces from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.traces_utility import get_scene_ranges from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style from magpylib._src.utility import format_obj_input @@ -672,7 +673,7 @@ def get_generic_traces( obj_extr_trace = {"type": ttype, **obj_extr_trace} if ttype == "scatter3d": for k in ("marker", "line"): - trace3d["{k}_color"] = trace3d.get( + trace3d[f"{k}_color"] = trace3d.get( f"{k}_color", kwargs["color"] ) elif ttype == "mesh3d": @@ -924,32 +925,6 @@ def subdivide_mesh_by_facecolor(trace): return subtraces -def get_scene_ranges(*traces, zoom=1) -> np.ndarray: - """ - Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be - any plotly trace object or a dict, with x,y,z numbered parameters. - """ - if traces: - ranges = {k: [] for k in "xyz"} - for t in traces: - for k, v in ranges.items(): - v.extend( - [ - np.nanmin(np.array(t[k], dtype=float)), - np.nanmax(np.array(t[k], dtype=float)), - ] - ) - r = np.array([[np.nanmin(v), np.nanmax(v)] for v in ranges.values()]) - size = np.diff(r, axis=1) - size[size == 0] = 1 - m = size.max() / 2 - center = r.mean(axis=1) - ranges = np.array([center - m * (1 + zoom), center + m * (1 + zoom)]).T - else: - ranges = np.array([[-1.0, 1.0]] * 3) - return ranges - - def process_animation_kwargs(obj_list, animation=False, **kwargs): """Update animation kwargs""" markers = [o for o in obj_list if isinstance(o, MagpyMarkers)] diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index 5894e3d11..600ffff50 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -411,4 +411,4 @@ def get_scene_ranges(*traces, zoom=1) -> np.ndarray: ranges = np.array([center - m * (1 + zoom), center + m * (1 + zoom)]).T else: ranges = np.array([[-1.0, 1.0]] * 3) - return ranges \ No newline at end of file + return ranges From b115aa4431eafa62099d314922298f4a4ff05a5a Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Wed, 29 Jun 2022 00:09:48 +0200 Subject: [PATCH 157/207] fix bad arrow orientation with (0,0-1) --- magpylib/_src/display/traces_generic.py | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 6423fe86f..6d5699c8e 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -25,13 +25,13 @@ from magpylib._src.display.traces_utility import draw_arrowed_line from magpylib._src.display.traces_utility import get_flatten_objects_properties from magpylib._src.display.traces_utility import get_rot_pos_from_path +from magpylib._src.display.traces_utility import get_scene_ranges from magpylib._src.display.traces_utility import getColorscale from magpylib._src.display.traces_utility import getIntensity from magpylib._src.display.traces_utility import MagpyMarkers from magpylib._src.display.traces_utility import merge_mesh3d from magpylib._src.display.traces_utility import merge_traces from magpylib._src.display.traces_utility import place_and_orient_model3d -from magpylib._src.display.traces_utility import get_scene_ranges from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style from magpylib._src.utility import format_obj_input @@ -180,10 +180,13 @@ def make_Dipole( nvec = np.array(moment) / moment_mag zaxis = np.array([0, 0, 1]) cross = np.cross(nvec, zaxis) - dot = np.dot(nvec, zaxis) n = np.linalg.norm(cross) + if n == 0: + n = 1 + cross = np.array([-np.sign(nvec[-1]), 0, 0]) + dot = np.dot(nvec, zaxis) t = np.arccos(dot) - vec = -t * cross / n if n != 0 else (0, 0, 0) + vec = -t * cross / n mag_orient = RotScipy.from_rotvec(vec) orientation = orientation * mag_orient mag = np.array((0, 0, 1)) From 07bd8a2b58bf2f0c5d673832c9d2db3a60b3cafb Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Thu, 30 Jun 2022 08:49:34 +0200 Subject: [PATCH 158/207] minibug f-string fix --- magpylib/_src/input_checks.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index b0d3f959e..eee0f59a3 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -624,7 +624,7 @@ def check_getBH_output_type(output): acceptable = ("ndarray", "dataframe") if output not in acceptable: raise ValueError( - "The `output` argument must be one of {acceptable}." + f"The `output` argument must be one of {acceptable}." f"\nInstead received {output}." ) if output == "dataframe": From f750d2b098ce1383bdb3e6a21182f3ec386db6ac Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Thu, 30 Jun 2022 09:05:15 +0200 Subject: [PATCH 159/207] docstrings minifix --- magpylib/_src/fields/field_wrap_BH_level3.py | 14 ++++++-------- magpylib/_src/obj_classes/class_Collection.py | 14 ++++++-------- magpylib/_src/obj_classes/class_Sensor.py | 14 ++++++-------- 3 files changed, 18 insertions(+), 24 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level3.py b/magpylib/_src/fields/field_wrap_BH_level3.py index 031c21239..3fe29b959 100644 --- a/magpylib/_src/fields/field_wrap_BH_level3.py +++ b/magpylib/_src/fields/field_wrap_BH_level3.py @@ -48,10 +48,9 @@ def getB( With this option, observers input with different (pixel) shapes is allowed. output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). Other Parameters (Direct interface) ----------------------------------- @@ -218,10 +217,9 @@ def getH( With this option, observer inputs with different (pixel) shapes are allowed. output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). Other Parameters (Direct interface) ----------------------------------- diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 09881e447..fa11d4cac 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -538,10 +538,9 @@ def getB(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"): With this option, observers input with different (pixel) shapes is allowed. output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). Returns ------- @@ -610,10 +609,9 @@ def getH(self, *inputs, squeeze=True, pixel_agg=None, output="ndarray"): With this option, observers input with different (pixel) shapes is allowed. output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). Returns ------- diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 592f9371d..8a8065286 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -136,10 +136,9 @@ def getB( With this option, observers input with different (pixel) shapes is allowed. output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). Returns ------- @@ -212,10 +211,9 @@ def getH( With this option, observers input with different (pixel) shapes is allowed. output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). Returns ------- From c12a9a8a3c1f38b8a595ea7acc7235845a0264e6 Mon Sep 17 00:00:00 2001 From: Michael Ortner Date: Thu, 30 Jun 2022 09:10:04 +0200 Subject: [PATCH 160/207] modify introduction --- docs/_pages/page_01_introduction.md | 44 +++++++++++++++++++++++------ 1 file changed, 35 insertions(+), 9 deletions(-) diff --git a/docs/_pages/page_01_introduction.md b/docs/_pages/page_01_introduction.md index 29fca825c..4224058c8 100644 --- a/docs/_pages/page_01_introduction.md +++ b/docs/_pages/page_01_introduction.md @@ -313,7 +313,7 @@ Magnetic field computation in Magpylib is achieved through: The argument `sources` can be any Magpylib **source object** or a flat list thereof. The argument `observers` can be an array_like of position vectors with shape $(n_1,n_2,n_3,...,3)$, any Magpylib **observer object** or a flat list thereof. `getB` and `getH` return the field for all combinations of sources, observers and paths. -The output of the most general field computation `getB(sources, observers)` is an ndarray of shape `(l, m, k, n1, n2, n3, ..., 3)` where `l` is the number of input sources, `m` the (maximal) object path length, `k` the number of sensors, `n1,n2,n3,...` the sensor pixel shape or the shape of the observer position vector input and `3` the three magnetic field components $(B_x, B_y, B_z)$. +The output of a field computation `getB(sources, observers)` is a Numpy ndarray (alternatively a Pandas DataFrame, see below) of shape `(l, m, k, n1, n2, n3, ..., 3)` where `l` is the number of input sources, `m` the (maximal) object path length, `k` the number of sensors, `n1,n2,n3,...` the sensor pixel shape or the shape of the observer position vector input and `3` the three magnetic field components $(B_x, B_y, B_z)$. **Example 1:** As expressed by the old v2 slogan *"The magnetic field is only three lines of code away"*, this example demonstrates the most fundamental field computation: @@ -399,13 +399,13 @@ fig.show() import magpylib as magpy # 3 sources, one with length 11 path -pos_path = [(i/5,0,1) for i in range(-5,5)] +pos_path = [(i,0,1) for i in range(-1,1)] source1 = magpy.misc.Dipole(moment=(0,0,100), position=pos_path) source2 = magpy.current.Loop(current=10, diameter=3) source3 = source1 + source2 # 2 observers, each with 4x5 pixel -pixel = [[[(i/10,j/10,0)] for i in range(4)] for j in range(5)] +pixel = [[[(i,j,0)] for i in range(4)] for j in range(5)] sensor1 = magpy.Sensor(pixel=pixel, position=(-1,0,-1)) sensor2 = sensor1.copy().move((2,0,0)) @@ -416,19 +416,43 @@ B = magpy.getB(sources, sensors) print(B.shape) ``` -For convenience, the result can also be outputted as [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe). +Instead of a Numpy `ndarray`, the field computation can also return a [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe) using the `output='dataframe'` kwarg. ```{code-cell} ipython3 -B_as_df = magpy.getB(sources, sensors, output='dataframe') -B_as_df +import numpy as np +import magpylib as magpy + +cube = magpy.magnet.Cuboid( + magnetization=(0, 0, 1000), + dimension=(1, 1, 1), + style_label='cube' +) +loop = magpy.current.Loop( + current=200, + diameter=2, + style_label='loop', +) +sens1 = magpy.Sensor( + pixel=[(0,0,0), (.5,0,0)], + position=np.linspace((-4, 0, 2), (4, 0, 2), 30), + style_label='sens1' +) +sens2 = sens1.copy(style_label='sens2').move((0,0,1)) + +B_as_df = magpy.getB( + [cube, loop], + [sens1, sens2], + output='dataframe', +) + +print(B_as_df) ``` -Plotting libraries such as [plotly](https://plotly.com/python/plotly-express/) or [seaborn](https://seaborn.pydata.org/introduction.html) can take nice advantage over this feature, as they can deal with `dataframes` directly. +Plotting libraries such as [plotly](https://plotly.com/python/plotly-express/) or [seaborn](https://seaborn.pydata.org/introduction.html) can take advantage of this feature, as they can deal with `dataframes` directly. ```{code-cell} ipython3 import plotly.express as px - -px.line( +fig = px.line( B_as_df, x="path", y="Bx", @@ -437,7 +461,9 @@ px.line( facet_col="source", symbol="sensor", ) +fig.show() ``` + In terms of **performance** it must be noted that Magpylib automatically vectorizes all computations when `getB` and `getH` are called. This reduces the computation time dramatically for large inputs. For maximal performance try to make all field computations with as few calls to `getB` and `getH` as possible. (intro-direct-interface)= From a5ef24d345be612182d31ca2ba7b9301c55153ca Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Thu, 30 Jun 2022 12:31:04 +0200 Subject: [PATCH 161/207] fix dataframe output with pixel_agg --- magpylib/_src/fields/field_wrap_BH_level2.py | 2 +- tests/test_getBH_interfaces.py | 26 ++++++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index c08472662..daf6b8d6a 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -323,7 +323,7 @@ def getBH_level2(sources, observers, **kwargs) -> np.ndarray: else: src_ids = [s.style.label if s.style.label else f"{s}" for s in sources] sens_ids = [s.style.label if s.style.label else f"{s}" for s in sensors] - num_of_pixels = np.prod(pix_shapes[0][:-1]) + num_of_pixels = np.prod(pix_shapes[0][:-1]) if pixel_agg is None else 1 df = pd.DataFrame( data=product(src_ids, range(max_path_len), sens_ids, range(num_of_pixels)), columns=["source", "path", "sensor", "pixel"], diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index 753df31ba..ffa09d4dc 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -225,9 +225,31 @@ def test_dataframe_ouptut_sumup(): """test pandas dataframe output when sumup is True""" sources = [ magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)), - magpy.magnet.Cylinder((0, 1000, 0), (1, 1)) + magpy.magnet.Cylinder((0, 1000, 0), (1, 1)), ] - magpy.getB(sources, (0,0,0), sumup=True, output="dataframe") + df = magpy.getB(sources, (0, 0, 0), sumup=True, output="dataframe") + np.testing.assert_allclose( + df[["Bx", "By", "Bz"]].values, + np.array([[-2.16489014e-14, 6.46446609e02, 6.66666667e02]]), + ) + + +def test_dataframe_ouptut_pixel_agg(): + """test pandas dataframe output when sumup is True""" + src1 = magpy.magnet.Cuboid((0, 0, 1000), (1, 1, 1)) + sens1 = magpy.Sensor(position=(0, 0, 1), pixel=np.zeros((4, 5, 3))) + sens2 = sens1.copy(position=(0, 0, 2)) + sens3 = sens1.copy(position=(0, 0, 3)) + + sources = (src1,) + sensors = sens1, sens2, sens3 + df = magpy.getB(sources, sensors, pixel_agg="mean", output="dataframe") + np.testing.assert_allclose( + df[["Bx", "By", "Bz"]].values, + np.array( + [[0.0, 0.0, 134.78238624], [0.0, 0.0, 19.63857207], [0.0, 0.0, 5.87908614]] + ), + ) def test_dataframe_output_missing_pandas(): From a8bd864db86eb5c7b199c440356eb75637993d04 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Sat, 2 Jul 2022 02:27:06 +0200 Subject: [PATCH 162/207] remove unused test --- tests/test_display_plotly.py | 32 -------------------------------- 1 file changed, 32 deletions(-) diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index bb069d7f1..e17460ad8 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -164,38 +164,6 @@ def test_display_bad_style_kwargs(): magpy.show(canvas=fig, markers=[(1, 2, 3)], style_bad_style_kwarg=None) -def test_draw_unsupported_obj(): - """test if a object which is not directly supported by magpylib can be plotted""" - magpy.defaults.display.backend = "plotly" - - class UnkwnownNoPosition: - """Dummy Class""" - - class Unkwnown1DPosition: - """Dummy Class""" - - position = [0, 0, 0] - - class Unkwnown2DPosition: - """Dummy Class""" - - position = [[0, 0, 0]] - orientation = None - - with pytest.raises(AttributeError): - get_generic_traces(UnkwnownNoPosition()) - - traces = get_generic_traces(Unkwnown1DPosition) - assert ( - traces[0]["type"] == "scatter3d" - ), "make trace has failed, should be 'scatter3d'" - - traces = get_generic_traces(Unkwnown2DPosition) - assert ( - traces[0]["type"] == "scatter3d" - ), "make trace has failed, should be 'scatter3d'" - - def test_extra_model3d(): """test diplay when object has an extra model object attached""" magpy.defaults.display.backend = "plotly" From 2cd82151ddaec8f7310a29db68e59b29ec7c16fd Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Sat, 2 Jul 2022 03:21:38 +0200 Subject: [PATCH 163/207] refactoring --- magpylib/_src/display/backend_matplotlib.py | 2 +- magpylib/_src/display/traces_base.py | 12 +- magpylib/_src/display/traces_generic.py | 1019 ++++++++----------- magpylib/_src/display/traces_utility.py | 69 ++ tests/test_getBH_interfaces.py | 2 +- 5 files changed, 517 insertions(+), 587 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 1a0c51bf7..38729a3b5 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -3,8 +3,8 @@ from matplotlib.animation import FuncAnimation from magpylib._src.display.traces_generic import get_frames -from magpylib._src.display.traces_generic import subdivide_mesh_by_facecolor from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.traces_utility import subdivide_mesh_by_facecolor # from magpylib._src.utility import format_obj_input diff --git a/magpylib/_src/display/traces_base.py b/magpylib/_src/display/traces_base.py index a04530747..a6e6f94fa 100644 --- a/magpylib/_src/display/traces_base.py +++ b/magpylib/_src/display/traces_base.py @@ -40,7 +40,7 @@ def get_model(trace, *, backend, show, scale, kwargs): def make_Cuboid( - backend='generic', + backend="generic", dimension=(1.0, 1.0, 1.0), position=None, orientation=None, @@ -97,7 +97,7 @@ def make_Cuboid( def make_Prism( - backend='generic', + backend="generic", base=3, diameter=1.0, height=1.0, @@ -186,7 +186,7 @@ def make_Prism( def make_Ellipsoid( - backend='generic', + backend="generic", dimension=(1.0, 1.0, 1.0), vert=15, position=None, @@ -266,7 +266,7 @@ def make_Ellipsoid( def make_CylinderSegment( - backend='generic', + backend="generic", dimension=(1.0, 2.0, 1.0, 0.0, 90.0), vert=50, position=None, @@ -365,7 +365,7 @@ def make_CylinderSegment( def make_Pyramid( - backend='generic', + backend="generic", base=3, diameter=1, height=1, @@ -445,7 +445,7 @@ def make_Pyramid( def make_Arrow( - backend='generic', + backend="generic", base=3, diameter=0.3, height=1, diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 6d5699c8e..3f65ec4e5 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -16,7 +16,7 @@ from magpylib._src.display.traces_base import make_Arrow as make_BaseArrow from magpylib._src.display.traces_base import make_Cuboid as make_BaseCuboid from magpylib._src.display.traces_base import ( - make_CylinderSegment as make_BaseCylinderSegment, + make_CylindserSegment as make_BaseCylinderSegment, ) from magpylib._src.display.traces_base import make_Ellipsoid as make_BaseEllipsoid from magpylib._src.display.traces_base import make_Prism as make_BasePrism @@ -28,6 +28,7 @@ from magpylib._src.display.traces_utility import get_scene_ranges from magpylib._src.display.traces_utility import getColorscale from magpylib._src.display.traces_utility import getIntensity +from magpylib._src.display.traces_utility import group_traces from magpylib._src.display.traces_utility import MagpyMarkers from magpylib._src.display.traces_utility import merge_mesh3d from magpylib._src.display.traces_utility import merge_traces @@ -37,10 +38,42 @@ from magpylib._src.utility import format_obj_input from magpylib._src.utility import unit_prefix +AUTOSIZE_OBJECTS = ("Sensor", "Dipole") + + +def make_DefaultTrace( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly scatter3d parameters for an object with no specifically supported + representation. The object will be represented by a scatter point and text above with object + name. + """ + style = obj.style if style is None else style + trace = dict( + type="scatter3d", + x=[0.0], + y=[0.0], + z=[0.0], + mode="markers+text", + marker_size=10, + marker_color=color, + marker_symbol="diamond", + ) + update_trace_name(trace, f"{type(obj).__name__}", "", style) + trace["text"] = trace["name"] + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + def make_Line( - current=0.0, - vertices=((-1.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + obj, position=(0.0, 0.0, 0.0), orientation=None, color=None, @@ -49,134 +82,106 @@ def make_Line( ) -> dict: """ Creates the plotly scatter3d parameters for a Line current in a dictionary based on the - provided arguments + provided arguments. """ - default_suffix = ( - f" ({unit_prefix(current)}A)" - if current is not None - else " (Current not initialized)" - ) - name, name_suffix = get_name_and_suffix("Line", default_suffix, style) + style = obj.style if style is None else style + current = obj.current + vertices = obj.vertices show_arrows = style.arrow.show arrow_size = style.arrow.size if show_arrows: vertices = draw_arrow_from_vertices(vertices, current, arrow_size) else: vertices = np.array(vertices).T - if orientation is not None: - vertices = orientation.apply(vertices.T).T - x, y, z = (vertices.T + position).T - line_width = style.arrow.width - line = dict( + x, y, z = vertices + trace = dict( type="scatter3d", x=x, y=y, z=z, - name=f"""{name}{name_suffix}""", mode="lines", - line_width=line_width, + line_width=style.arrow.width, line_color=color, ) - return {**line, **kwargs} - - -def make_Loop( - current=0.0, - diameter=1.0, - position=(0.0, 0.0, 0.0), - vert=50, - orientation=None, - color=None, - style=None, - **kwargs, -): - """ - Creates the plotly scatter3d parameters for a Loop current in a dictionary based on the - provided arguments - """ default_suffix = ( f" ({unit_prefix(current)}A)" if current is not None else " (Current not initialized)" ) - name, name_suffix = get_name_and_suffix("Loop", default_suffix, style) - arrow_size = style.arrow.size if style.arrow.show else 0 - vertices = draw_arrowed_circle(current, diameter, arrow_size, vert) - if orientation is not None: - vertices = orientation.apply(vertices.T).T - x, y, z = (vertices.T + position).T - line_width = style.arrow.width - circular = dict( - type="scatter3d", - x=x, - y=y, - z=z, - name=f"""{name}{name_suffix}""", - mode="lines", - line_width=line_width, - line_color=color, + update_trace_name(trace, "Line", default_suffix, style) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) - return {**circular, **kwargs} -def make_DefaultTrace( +def make_Loop( obj, position=(0.0, 0.0, 0.0), orientation=None, color=None, style=None, + vertices=50, **kwargs, -) -> dict: +): """ - Creates the plotly scatter3d parameters for an object with no specifically supported - representation. The object will be represented by a scatter point and text above with object - name. + Creates the plotly scatter3d parameters for a Loop current in a dictionary based on the + provided arguments. """ - - default_suffix = "" - name, name_suffix = get_name_and_suffix( - f"{type(obj).__name__}", default_suffix, style - ) - vertices = np.array([position]) - if orientation is not None: - vertices = orientation.apply(vertices).T + style = obj.style if style is None else style + current = obj.current + diameter = obj.diameter + arrow_size = style.arrow.size if style.arrow.show else 0 + vertices = draw_arrowed_circle(current, diameter, arrow_size, vertices) x, y, z = vertices trace = dict( type="scatter3d", x=x, y=y, z=z, - name=f"""{name}{name_suffix}""", - text=name, - mode="markers+text", - marker_size=10, - marker_color=color, - marker_symbol="diamond", + mode="lines", + line_width=style.arrow.width, + line_color=color, + ) + default_suffix = ( + f" ({unit_prefix(current)}A)" + if current is not None + else " (Current not initialized)" + ) + update_trace_name(trace, "Loop", default_suffix, style) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) - return {**trace, **kwargs} def make_Dipole( - moment=(0.0, 0.0, 1.0), + obj, position=(0.0, 0.0, 0.0), orientation=None, + color=None, style=None, autosize=None, **kwargs, ) -> dict: """ - Creates the plotly mesh3d parameters for a Loop current in a dictionary based on the - provided arguments + Create the plotly mesh3d parameters for a Loop current in a dictionary based on the + provided arguments. """ + style = obj.style if style is None else style + moment = obj.moment moment_mag = np.linalg.norm(moment) - default_suffix = f" (moment={unit_prefix(moment_mag)}mT mm³)" - name, name_suffix = get_name_and_suffix("Dipole", default_suffix, style) size = style.size if autosize is not None: size *= autosize - dipole = make_BaseArrow( - "plotly-dict", base=10, diameter=0.3 * size, height=size, pivot=style.pivot + trace = make_BaseArrow( + "plotly-dict", + base=10, + diameter=0.3 * size, + height=size, + pivot=style.pivot, + color=color, ) + default_suffix = f" (moment={unit_prefix(moment_mag)}mT mm³)" + update_trace_name(trace, "Dipole", default_suffix, style) nvec = np.array(moment) / moment_mag zaxis = np.array([0, 0, 1]) cross = np.cross(nvec, zaxis) @@ -189,145 +194,127 @@ def make_Dipole( vec = -t * cross / n mag_orient = RotScipy.from_rotvec(vec) orientation = orientation * mag_orient - mag = np.array((0, 0, 1)) - return _update_mag_mesh( - dipole, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) def make_Cuboid( - mag=(0.0, 0.0, 1000.0), - dimension=(1.0, 1.0, 1.0), + obj, position=(0.0, 0.0, 0.0), orientation=None, + color=None, style=None, **kwargs, ) -> dict: """ - Creates the plotly mesh3d parameters for a Cuboid Magnet in a dictionary based on the - provided arguments + Create the plotly mesh3d parameters for a Cuboid Magnet in a dictionary based on the + provided arguments. """ + style = obj.style if style is None else style + dimension = obj.dimension d = [unit_prefix(d / 1000) for d in dimension] + trace = make_BaseCuboid("plotly-dict", dimension=dimension, color=color) default_suffix = f" ({d[0]}m|{d[1]}m|{d[2]}m)" - name, name_suffix = get_name_and_suffix("Cuboid", default_suffix, style) - cuboid = make_BaseCuboid("plotly-dict", dimension=dimension) - return _update_mag_mesh( - cuboid, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, + update_trace_name(trace, "Cuboid", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization + ) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) def make_Cylinder( - mag=(0.0, 0.0, 1000.0), - base=50, - diameter=1.0, - height=1.0, + obj, position=(0.0, 0.0, 0.0), orientation=None, + color=None, style=None, + base=50, **kwargs, ) -> dict: """ - Creates the plotly mesh3d parameters for a Cylinder Magnet in a dictionary based on the - provided arguments + Create the plotly mesh3d parameters for a Cylinder Magnet in a dictionary based on the + provided arguments. """ + style = obj.style if style is None else style + diameter, height = obj.dimension d = [unit_prefix(d / 1000) for d in (diameter, height)] + trace = make_BasePrism( + "plotly-dict", base=base, diameter=diameter, height=height, color=color + ) default_suffix = f" (D={d[0]}m, H={d[1]}m)" - name, name_suffix = get_name_and_suffix("Cylinder", default_suffix, style) - cylinder = make_BasePrism( - "plotly-dict", - base=base, - diameter=diameter, - height=height, + update_trace_name(trace, "Cylinder", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization ) - return _update_mag_mesh( - cylinder, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) def make_CylinderSegment( - mag=(0.0, 0.0, 1000.0), - dimension=(1.0, 2.0, 1.0, 0.0, 90.0), + obj, position=(0.0, 0.0, 0.0), orientation=None, - vert=25, + color=None, style=None, + vertices=25, **kwargs, ): """ - Creates the plotly mesh3d parameters for a Cylinder Segment Magnet in a dictionary based on the - provided arguments + Create the plotly mesh3d parameters for a Cylinder Segment Magnet in a dictionary based on the + provided arguments. """ + style = obj.style if style is None else style + dimension = obj.dimension d = [unit_prefix(d / (1000 if i < 3 else 1)) for i, d in enumerate(dimension)] + trace = make_BaseCylinderSegment( + "plotly-dict", dimension=dimension, vert=vertices, color=color + ) default_suffix = f" (r={d[0]}m|{d[1]}m, h={d[2]}m, φ={d[3]}°|{d[4]}°)" - name, name_suffix = get_name_and_suffix("CylinderSegment", default_suffix, style) - cylinder_segment = make_BaseCylinderSegment( - "plotly-dict", dimension=dimension, vert=vert + update_trace_name(trace, "CylinderSegment", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization ) - return _update_mag_mesh( - cylinder_segment, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) def make_Sphere( - mag=(0.0, 0.0, 1000.0), - vert=15, - diameter=1, + obj, position=(0.0, 0.0, 0.0), orientation=None, + color=None, style=None, + vertices=15, **kwargs, ) -> dict: """ - Creates the plotly mesh3d parameters for a Sphere Magnet in a dictionary based on the - provided arguments + Create the plotly mesh3d parameters for a Sphere Magnet in a dictionary based on the + provided arguments. """ + style = obj.style if style is None else style + diameter = obj.diameter + vertices = min(max(vertices, 3), 20) + trace = make_BaseEllipsoid( + "plotly-dict", vert=vertices, dimension=[diameter] * 3, color=color + ) default_suffix = f" (D={unit_prefix(diameter / 1000)}m)" - name, name_suffix = get_name_and_suffix("Sphere", default_suffix, style) - vert = min(max(vert, 3), 20) - sphere = make_BaseEllipsoid("plotly-dict", vert=vert, dimension=[diameter] * 3) - return _update_mag_mesh( - sphere, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, + update_trace_name(trace, "Sphere", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization + ) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) def make_Pixels(positions, size=1) -> dict: """ - Creates the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size + Create the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size For now, only "cube" shape is provided. """ pixels = [ @@ -338,8 +325,7 @@ def make_Pixels(positions, size=1) -> dict: def make_Sensor( - pixel=(0.0, 0.0, 0.0), - dimension=(1.0, 1.0, 1.0), + obj, position=(0.0, 0.0, 0.0), orientation=None, color=None, @@ -348,21 +334,18 @@ def make_Sensor( **kwargs, ): """ - Creates the plotly mesh3d parameters for a Sensor object in a dictionary based on the - provided arguments + Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the + provided arguments. size_pixels: float, default=1 A positive number. Adjusts automatic display size of sensor pixels. When set to 0, pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum distance between any pixel of the same sensor, equal to `size_pixel`. """ + style = obj.style if style is None else style + dimension = getattr(obj, "dimension", style.size) + pixel = obj.pixel pixel = np.array(pixel).reshape((-1, 3)) - default_suffix = ( - f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)""" - if pixel.ndim != 1 - else "" - ) - name, name_suffix = get_name_and_suffix("Sensor", default_suffix, style) style_arrows = style.arrows.as_dict(flatten=True, separator="_") sensor = get_sensor_mesh(**style_arrows, center_color=color) vertices = np.array([sensor[k] for k in "xyz"]).T @@ -405,56 +388,74 @@ def make_Sensor( ) hull_mesh["facecolor"] = np.repeat(color, len(hull_mesh["i"])) meshes_to_merge.append(hull_mesh) - sensor = merge_mesh3d(*meshes_to_merge) - return _update_mag_mesh( - sensor, name, name_suffix, orientation=orientation, position=position, **kwargs + trace = merge_mesh3d(*meshes_to_merge) + default_suffix = ( + f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)""" + if pixel.ndim != 1 + else "" + ) + update_trace_name(trace, "Sensor", default_suffix, style) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs ) -def _update_mag_mesh( - mesh_dict, - name, - name_suffix, - magnetization=None, - orientation=None, - position=None, - style=None, - **kwargs, -): +def make_Marker(obj, color=None, style=None, **kwargs): + """Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the + provided arguments.""" + style = obj.style if style is None else style + x, y, z = obj.markers.T + marker_kwargs = { + f"marker_{k}": v + for k, v in style.marker.as_dict(flatten=True, separator="_").items() + } + if marker_kwargs["marker_color"] is None: + marker_kwargs["marker_color"] = ( + style.color if style.color is not None else color + ) + trace = dict( + type="scatter3d", + x=x, + y=y, + z=z, + mode="markers", + **marker_kwargs, + **kwargs, + ) + default_name = "Marker" if len(x) == 1 else "Markers" + default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" + update_trace_name(trace, default_name, default_suffix, style) + return trace + + +def update_magnet_mesh(mesh_dict, mag_style=None, magnetization=None): """ Updates an existing plotly mesh3d dictionary of an object which has a magnetic vector. The - object gets colorized, positioned and oriented based on provided arguments + object gets colorized, positioned and oriented based on provided arguments. """ - if hasattr(style, "magnetization"): - color = style.magnetization.color - if magnetization is not None and style.magnetization.show: - vertices = np.array([mesh_dict[k] for k in "xyz"]).T - color_middle = color.middle - if color.mode == "tricycle": - color_middle = kwargs.get("color", None) - elif color.mode == "bicolor": - color_middle = False - mesh_dict["colorscale"] = getColorscale( - color_transition=color.transition, - color_north=color.north, - color_middle=color_middle, - color_south=color.south, - ) - mesh_dict["intensity"] = getIntensity( - vertices=vertices, - axis=magnetization, - ) - mesh_dict = place_and_orient_model3d( - model_kwargs=mesh_dict, - orientation=orientation, - position=position, - showscale=False, - name=f"{name}{name_suffix}", - ) - return {**mesh_dict, **kwargs} + mag_color = mag_style.color + if magnetization is not None and mag_style.show: + vertices = np.array([mesh_dict[k] for k in "xyz"]).T + color_middle = mag_color.middle + if mag_color.mode == "tricycle": + color_middle = mesh_dict["color"] + elif mag_color.mode == "bicolor": + color_middle = False + mesh_dict["colorscale"] = getColorscale( + color_transition=mag_color.transition, + color_north=mag_color.north, + color_middle=color_middle, + color_south=mag_color.south, + ) + mesh_dict["intensity"] = getIntensity( + vertices=vertices, + axis=magnetization, + ) + mesh_dict["showscale"] = False + return mesh_dict -def get_name_and_suffix(default_name, default_suffix, style): +def update_trace_name(trace, default_name, default_suffix, style): """provides legend entry based on name and suffix""" name = default_name if style.label is None else style.label if style.description.show and style.description.text is None: @@ -463,7 +464,8 @@ def get_name_and_suffix(default_name, default_suffix, style): name_suffix = "" else: name_suffix = f" ({style.description.text})" - return name, name_suffix + trace.update(name=f"{name}{name_suffix}") + return trace def make_mag_arrows(obj, style, legendgroup, kwargs): @@ -516,8 +518,40 @@ def make_mag_arrows(obj, style, legendgroup, kwargs): return trace +def make_path(input_obj, style, legendgroup, kwargs): + """draw obj path based on path style properties""" + x, y, z = np.array(input_obj.position).T + txt_kwargs = ( + {"mode": "markers+text+lines", "text": list(range(len(x)))} + if style.path.numbering + else {"mode": "markers+lines"} + ) + marker = style.path.marker.as_dict() + marker["symbol"] = marker["symbol"] + marker["color"] = kwargs["color"] if marker["color"] is None else marker["color"] + line = style.path.line.as_dict() + line["dash"] = line["style"] + line["color"] = kwargs["color"] if line["color"] is None else line["color"] + line = {k: v for k, v in line.items() if k != "style"} + scatter_path = dict( + type="scatter3d", + x=x, + y=y, + z=z, + name=f"Path: {input_obj}", + showlegend=False, + legendgroup=legendgroup, + **{f"marker_{k}": v for k, v in marker.items()}, + **{f"line_{k}": v for k, v in line.items()}, + **txt_kwargs, + opacity=kwargs["opacity"], + ) + return scatter_path + + def get_generic_traces( input_obj, + make_func=None, color=None, autosize=None, legendgroup=None, @@ -545,15 +579,6 @@ def get_generic_traces( # pylint: disable=too-many-statements # pylint: disable=too-many-nested-blocks - Sensor = _src.obj_classes.class_Sensor.Sensor - Cuboid = _src.obj_classes.class_mag_Cuboid.Cuboid - Cylinder = _src.obj_classes.class_mag_Cylinder.Cylinder - CylinderSegment = _src.obj_classes.class_mag_CylinderSegment.CylinderSegment - Sphere = _src.obj_classes.class_mag_Sphere.Sphere - Dipole = _src.obj_classes.class_misc_Dipole.Dipole - Loop = _src.obj_classes.class_current_Loop.Loop - Line = _src.obj_classes.class_current_Line.Line - # parse kwargs into style and non style args style = get_style(input_obj, Config, **kwargs) kwargs = {k: v for k, v in kwargs.items() if not k.startswith("style")} @@ -563,369 +588,145 @@ def get_generic_traces( kwargs["opacity"] = style.opacity legendgroup = f"{input_obj}" if legendgroup is None else legendgroup - if hasattr(style, "magnetization"): - if style.magnetization.show: + # check excitations validity + for param in ("magnetization", "arrow"): + if getattr(getattr(style, param, None), "show", False): check_excitations([input_obj]) - if hasattr(style, "arrow"): - if style.arrow.show: - check_excitations([input_obj]) + label = getattr(getattr(input_obj, "style", None), "label", None) + label = label if label is not None else str(type(input_obj).__name__) + + object_type = getattr(input_obj, "_object_type", None) + if object_type != "Collection": + make_func = globals().get(f"make_{object_type}", make_DefaultTrace) + make_func_kwargs = kwargs.copy() + if object_type in AUTOSIZE_OBJECTS: + make_func_kwargs["autosize"] = autosize traces = [] + path_traces = [] + path_traces_extra_generic = {} path_traces_extra_specific_backend = [] - if isinstance(input_obj, MagpyMarkers): - x, y, z = input_obj.markers.T - marker = style.marker.as_dict() - default_name = "Marker" if len(x) == 1 else "Markers" - default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" - name, name_suffix = get_name_and_suffix(default_name, default_suffix, style) - trace = dict( - type="scatter3d", - name=f"{name}{name_suffix}", - x=x, - y=y, - z=z, - marker=marker, - mode="markers", - opacity=style.opacity, - ) - traces.append(trace) - else: - if isinstance(input_obj, Sensor): - kwargs.update( - dimension=getattr(input_obj, "dimension", style.size), - pixel=getattr(input_obj, "pixel", (0.0, 0.0, 0.0)), - autosize=autosize, - ) - make_func = make_Sensor - elif isinstance(input_obj, Cuboid): - kwargs.update( - mag=input_obj.magnetization, - dimension=input_obj.dimension, - ) - make_func = make_Cuboid - elif isinstance(input_obj, Cylinder): - base = 50 - kwargs.update( - mag=input_obj.magnetization, - diameter=input_obj.dimension[0], - height=input_obj.dimension[1], - base=base, - ) - make_func = make_Cylinder - elif isinstance(input_obj, CylinderSegment): - vert = 50 - kwargs.update( - mag=input_obj.magnetization, - dimension=input_obj.dimension, - vert=vert, - ) - make_func = make_CylinderSegment - elif isinstance(input_obj, Sphere): - kwargs.update( - mag=input_obj.magnetization, - diameter=input_obj.diameter, - ) - make_func = make_Sphere - elif isinstance(input_obj, Dipole): - kwargs.update( - moment=input_obj.moment, - autosize=autosize, - ) - make_func = make_Dipole - elif isinstance(input_obj, Line): - kwargs.update( - vertices=input_obj.vertices, - current=input_obj.current, - ) - make_func = make_Line - elif isinstance(input_obj, Loop): - kwargs.update( - diameter=input_obj.diameter, - current=input_obj.current, - ) - make_func = make_Loop - elif getattr(input_obj, "children", None) is not None: - make_func = None - else: - kwargs.update(obj=input_obj) - make_func = make_DefaultTrace - - label = getattr(getattr(input_obj, "style", None), "label", None) - label = label if label is not None else str(type(input_obj).__name__) - path_traces = [] - path_traces_extra_generic = {} - extra_model3d_traces = ( - style.model3d.data if style.model3d.data is not None else [] - ) - rots, poss, _ = get_rot_pos_from_path(input_obj, style.path.frames) - for pos_orient_ind, (orient, pos) in enumerate(zip(rots, poss)): - if style.model3d.showdefault and make_func is not None: - path_traces.append( - make_func(position=pos, orientation=orient, **kwargs) + has_path = hasattr(input_obj, "position") and hasattr(input_obj, "orientation") + if not has_path: + traces = [make_func(input_obj, **make_func_kwargs)] + out = (traces,) + if extra_backend is not False: + out += (path_traces_extra_specific_backend,) + return out[0] if len(out) == 1 else out + + extra_model3d_traces = style.model3d.data if style.model3d.data is not None else [] + orientations, positions, _ = get_rot_pos_from_path(input_obj, style.path.frames) + for pos_orient_ind, (orient, pos) in enumerate(zip(orientations, positions)): + if style.model3d.showdefault and make_func is not None: + path_traces.append( + make_func( + input_obj, position=pos, orientation=orient, **make_func_kwargs ) - for extr in extra_model3d_traces: - if extr.show: - extr.update(extr.updatefunc()) - if extr.backend == "generic": - trace3d = {"opacity": kwargs["opacity"]} - ttype = extr.constructor.lower() - obj_extr_trace = ( - extr.kwargs() if callable(extr.kwargs) else extr.kwargs - ) - obj_extr_trace = {"type": ttype, **obj_extr_trace} - if ttype == "scatter3d": - for k in ("marker", "line"): - trace3d[f"{k}_color"] = trace3d.get( - f"{k}_color", kwargs["color"] - ) - elif ttype == "mesh3d": - trace3d["showscale"] = trace3d.get("showscale", False) - if "facecolor" in obj_extr_trace: - ttype = "mesh3d_facecolor" - trace3d["color"] = trace3d.get("color", kwargs["color"]) - else: - raise ValueError( - f"{ttype} is not supported, only 'scatter3d' and 'mesh3d' are" - ) - trace3d.update( - linearize_dict( - place_and_orient_model3d( - model_kwargs=obj_extr_trace, - orientation=orient, - position=pos, - scale=extr.scale, - ), - separator="_", + ) + for extr in extra_model3d_traces: + if extr.show: + extr.update(extr.updatefunc()) + if extr.backend == "generic": + trace3d = {"opacity": kwargs["opacity"]} + ttype = extr.constructor.lower() + obj_extr_trace = ( + extr.kwargs() if callable(extr.kwargs) else extr.kwargs + ) + obj_extr_trace = {"type": ttype, **obj_extr_trace} + if ttype == "scatter3d": + for k in ("marker", "line"): + trace3d[f"{k}_color"] = trace3d.get( + f"{k}_color", kwargs["color"] ) + elif ttype == "mesh3d": + trace3d["showscale"] = trace3d.get("showscale", False) + if "facecolor" in obj_extr_trace: + ttype = "mesh3d_facecolor" + trace3d["color"] = trace3d.get("color", kwargs["color"]) + else: + raise ValueError( + f"{ttype} is not supported, only 'scatter3d' and 'mesh3d' are" ) - if ttype not in path_traces_extra_generic: - path_traces_extra_generic[ttype] = [] - path_traces_extra_generic[ttype].append(trace3d) - elif extr.backend == extra_backend: - showleg = ( - showlegend - and pos_orient_ind == 0 - and not style.model3d.showdefault + trace3d.update( + linearize_dict( + place_and_orient_model3d( + model_kwargs=obj_extr_trace, + orientation=orient, + position=pos, + scale=extr.scale, + ), + separator="_", ) - showleg = True if showleg is None else showleg - trace3d = { - "model3d": extr, - "position": pos, - "orientation": orient, - "kwargs": { - "opacity": kwargs["opacity"], - "color": kwargs["color"], - "legendgroup": legendgroup, - "name": label, - "showlegend": showleg, - }, - } - path_traces_extra_specific_backend.append(trace3d) - trace = merge_traces(*path_traces) - for ind, traces_extra in enumerate(path_traces_extra_generic.values()): - extra_model3d_trace = merge_traces(*traces_extra) - extra_model3d_trace.update( - { - "legendgroup": legendgroup, - "showlegend": showlegend and ind == 0 and not trace, - "name": label, - } - ) - traces.append(extra_model3d_trace) - - if trace: - trace.update( - { - "legendgroup": legendgroup, - "showlegend": True if showlegend is None else showlegend, - } - ) - if legendtext is not None: - trace["name"] = legendtext - traces.append(trace) + ) + if ttype not in path_traces_extra_generic: + path_traces_extra_generic[ttype] = [] + path_traces_extra_generic[ttype].append(trace3d) + elif extr.backend == extra_backend: + showleg = ( + showlegend + and pos_orient_ind == 0 + and not style.model3d.showdefault + ) + showleg = True if showleg is None else showleg + trace3d = { + "model3d": extr, + "position": pos, + "orientation": orient, + "kwargs": { + "opacity": kwargs["opacity"], + "color": kwargs["color"], + "legendgroup": legendgroup, + "name": label, + "showlegend": showleg, + }, + } + path_traces_extra_specific_backend.append(trace3d) + trace = merge_traces(*path_traces) + for ind, traces_extra in enumerate(path_traces_extra_generic.values()): + extra_model3d_trace = merge_traces(*traces_extra) + extra_model3d_trace.update( + { + "legendgroup": legendgroup, + "showlegend": showlegend and ind == 0 and not trace, + "name": label, + } + ) + traces.append(extra_model3d_trace) + + if trace: + trace.update( + { + "legendgroup": legendgroup, + "showlegend": True if showlegend is None else showlegend, + } + ) + if legendtext is not None: + trace["name"] = legendtext + traces.append(trace) - if np.array(input_obj.position).ndim > 1 and style.path.show: - scatter_path = make_path(input_obj, style, legendgroup, kwargs) - traces.append(scatter_path) + if np.array(input_obj.position).ndim > 1 and style.path.show: + scatter_path = make_path(input_obj, style, legendgroup, kwargs) + traces.append(scatter_path) - if mag_arrows and getattr(input_obj, "magnetization", None) is not None: - traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) + if mag_arrows and getattr(input_obj, "magnetization", None) is not None: + traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) out = (traces,) if extra_backend is not False: out += (path_traces_extra_specific_backend,) return out[0] if len(out) == 1 else out -def make_path(input_obj, style, legendgroup, kwargs): - """draw obj path based on path style properties""" - x, y, z = np.array(input_obj.position).T - txt_kwargs = ( - {"mode": "markers+text+lines", "text": list(range(len(x)))} - if style.path.numbering - else {"mode": "markers+lines"} - ) - marker = style.path.marker.as_dict() - marker["symbol"] = marker["symbol"] - marker["color"] = kwargs["color"] if marker["color"] is None else marker["color"] - line = style.path.line.as_dict() - line["dash"] = line["style"] - line["color"] = kwargs["color"] if line["color"] is None else line["color"] - line = {k: v for k, v in line.items() if k != "style"} - scatter_path = dict( - type="scatter3d", - x=x, - y=y, - z=z, - name=f"Path: {input_obj}", - showlegend=False, - legendgroup=legendgroup, - **{f"marker_{k}": v for k, v in marker.items()}, - **{f"line_{k}": v for k, v in line.items()}, - **txt_kwargs, - opacity=kwargs["opacity"], - ) - return scatter_path - - -def draw_frame( - obj_list_semi_flat, - colorsequence=None, - zoom=0.0, - autosize=None, - output="dict", - mag_arrows=False, - extra_backend=False, - **kwargs, -) -> Tuple: - """ - Creates traces from input `objs` and provided parameters, updates the size of objects like - Sensors and Dipoles in `kwargs` depending on the canvas size. - - Returns - ------- - traces_dicts, kwargs: dict, dict - returns the traces in a obj/traces_list dictionary and updated kwargs - """ - # pylint: disable=protected-access - if colorsequence is None: - colorsequence = Config.display.colorsequence - extra_backend_traces = [] - Sensor = _src.obj_classes.class_Sensor.Sensor - Dipole = _src.obj_classes.class_misc_Dipole.Dipole - traces_out = {} - # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. - # autosize is calculated from the other traces overall scene range - traces_to_resize = {} - flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, colorsequence=colorsequence - ) - for obj, params in flat_objs_props.items(): - params.update(kwargs) - if isinstance(obj, (Dipole, Sensor)): - traces_to_resize[obj] = {**params} - # temporary coordinates to be able to calculate ranges - x, y, z = obj._position.T - traces_out[obj] = [dict(x=x, y=y, z=z)] - else: - out_traces = get_generic_traces( - obj, - mag_arrows=mag_arrows, - extra_backend=extra_backend, - **params, - ) - if extra_backend is not False: - out_traces, ebt = out_traces - extra_backend_traces.extend(ebt) - traces_out[obj] = out_traces - traces = [t for tr in traces_out.values() for t in tr] - ranges = get_scene_ranges(*traces, zoom=zoom) - if autosize is None or autosize == "return": - autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor - for obj, params in traces_to_resize.items(): - out_traces = get_generic_traces( - obj, - autosize=autosize, - mag_arrows=mag_arrows, - extra_backend=extra_backend, - **params, - ) - if extra_backend is not False: - out_traces, ebt = out_traces - extra_backend_traces.extend(ebt) - traces_out[obj] = out_traces - if output == "list": - traces = [t for tr in traces_out.values() for t in tr] - traces_out = group_traces(*traces) - return traces_out, autosize, ranges, extra_backend_traces - - -def group_traces(*traces): - """Group and merge mesh traces with similar properties. This drastically improves - browser rendering performance when displaying a lot of mesh3d objects.""" - mesh_groups = {} - common_keys = ["legendgroup", "opacity"] - spec_keys = { - "mesh3d": ["colorscale"], - "scatter3d": [ - "marker", - "line_dash", - "line_color", - "line_width", - "marker_color", - "marker_symbol", - "marker_size", - "mode", - ], - } - for tr in traces: - tr = linearize_dict( - tr, - separator="_", - ) - gr = [tr["type"]] - for k in common_keys + spec_keys[tr["type"]]: - try: - v = tr.get(k, "") - except AttributeError: - v = getattr(tr, k, "") - gr.append(str(v)) - gr = "".join(gr) - if gr not in mesh_groups: - mesh_groups[gr] = [] - mesh_groups[gr].append(tr) - - traces = [] - for key, gr in mesh_groups.items(): - if key.startswith("mesh3d") or key.startswith("scatter3d"): - tr = [merge_traces(*gr)] - else: - tr = gr - traces.extend(tr) - return traces - - -def subdivide_mesh_by_facecolor(trace): - """Subdivide a mesh into a list of meshes based on facecolor""" - facecolor = trace["facecolor"] - subtraces = [] - # pylint: disable=singleton-comparison - facecolor[facecolor == np.array(None)] = "black" - for color in np.unique(facecolor): - mask = facecolor == color - new_trace = trace.copy() - uniq = np.unique(np.hstack([trace[k][mask] for k in "ijk"])) - new_inds = np.arange(len(uniq)) - mapping_ar = np.zeros(uniq.max() + 1, dtype=new_inds.dtype) - mapping_ar[uniq] = new_inds - for k in "ijk": - new_trace[k] = mapping_ar[trace[k][mask]] - for k in "xyz": - new_trace[k] = new_trace[k][uniq] - new_trace["color"] = color - new_trace.pop("facecolor") - subtraces.append(new_trace) - return subtraces +def clean_legendgroups(frames): + """removes legend duplicates for a plotly figure""" + for fr in frames: + legendgroups = [] + for tr in fr["data"]: + lg = tr.get("legendgroup", None) + if lg is not None and lg not in legendgroups: + legendgroups.append(lg) + elif lg is not None: # and tr.legendgrouptitle.text is None: + tr["showlegend"] = False def process_animation_kwargs(obj_list, animation=False, **kwargs): @@ -953,18 +754,6 @@ def process_animation_kwargs(obj_list, animation=False, **kwargs): return kwargs, animation, animation_kwargs -def clean_legendgroups(frames): - """removes legend duplicates for a plotly figure""" - for fr in frames: - legendgroups = [] - for tr in fr["data"]: - lg = tr.get("legendgroup", None) - if lg is not None and lg not in legendgroups: - legendgroups.append(lg) - elif lg is not None: # and tr.legendgrouptitle.text is None: - tr["showlegend"] = False - - def extract_animation_properties( objs, *, @@ -1031,6 +820,78 @@ def extract_animation_properties( return path_indices, exp, frame_duration +def draw_frame( + obj_list_semi_flat, + colorsequence=None, + zoom=0.0, + autosize=None, + output="dict", + mag_arrows=False, + extra_backend=False, + **kwargs, +) -> Tuple: + """ + Creates traces from input `objs` and provided parameters, updates the size of objects like + Sensors and Dipoles in `kwargs` depending on the canvas size. + + Returns + ------- + traces_dicts, kwargs: dict, dict + returns the traces in a obj/traces_list dictionary and updated kwargs + """ + # pylint: disable=protected-access + if colorsequence is None: + colorsequence = Config.display.colorsequence + extra_backend_traces = [] + Sensor = _src.obj_classes.class_Sensor.Sensor + Dipole = _src.obj_classes.class_misc_Dipole.Dipole + traces_out = {} + # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. + # autosize is calculated from the other traces overall scene range + traces_to_resize = {} + flat_objs_props = get_flatten_objects_properties( + *obj_list_semi_flat, colorsequence=colorsequence + ) + for obj, params in flat_objs_props.items(): + params.update(kwargs) + if isinstance(obj, (Dipole, Sensor)): + traces_to_resize[obj] = {**params} + # temporary coordinates to be able to calculate ranges + x, y, z = obj._position.T + traces_out[obj] = [dict(x=x, y=y, z=z)] + else: + out_traces = get_generic_traces( + obj, + mag_arrows=mag_arrows, + extra_backend=extra_backend, + **params, + ) + if extra_backend is not False: + out_traces, ebt = out_traces + extra_backend_traces.extend(ebt) + traces_out[obj] = out_traces + traces = [t for tr in traces_out.values() for t in tr] + ranges = get_scene_ranges(*traces, zoom=zoom) + if autosize is None or autosize == "return": + autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor + for obj, params in traces_to_resize.items(): + out_traces = get_generic_traces( + obj, + autosize=autosize, + mag_arrows=mag_arrows, + extra_backend=extra_backend, + **params, + ) + if extra_backend is not False: + out_traces, ebt = out_traces + extra_backend_traces.extend(ebt) + traces_out[obj] = out_traces + if output == "list": + traces = [t for tr in traces_out.values() for t in tr] + traces_out = group_traces(*traces) + return traces_out, autosize, ranges, extra_backend_traces + + def get_frames( objs, colorsequence=None, diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index 600ffff50..6eca71e61 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -7,6 +7,7 @@ from scipy.spatial.transform import Rotation as RotScipy from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.defaults.defaults_utility import linearize_dict from magpylib._src.style import Markers @@ -412,3 +413,71 @@ def get_scene_ranges(*traces, zoom=1) -> np.ndarray: else: ranges = np.array([[-1.0, 1.0]] * 3) return ranges + + +def group_traces(*traces): + """Group and merge mesh traces with similar properties. This drastically improves + browser rendering performance when displaying a lot of mesh3d objects.""" + mesh_groups = {} + common_keys = ["legendgroup", "opacity"] + spec_keys = { + "mesh3d": ["colorscale"], + "scatter3d": [ + "marker", + "line_dash", + "line_color", + "line_width", + "marker_color", + "marker_symbol", + "marker_size", + "mode", + ], + } + for tr in traces: + tr = linearize_dict( + tr, + separator="_", + ) + gr = [tr["type"]] + for k in common_keys + spec_keys[tr["type"]]: + try: + v = tr.get(k, "") + except AttributeError: + v = getattr(tr, k, "") + gr.append(str(v)) + gr = "".join(gr) + if gr not in mesh_groups: + mesh_groups[gr] = [] + mesh_groups[gr].append(tr) + + traces = [] + for key, gr in mesh_groups.items(): + if key.startswith("mesh3d") or key.startswith("scatter3d"): + tr = [merge_traces(*gr)] + else: + tr = gr + traces.extend(tr) + return traces + + +def subdivide_mesh_by_facecolor(trace): + """Subdivide a mesh into a list of meshes based on facecolor""" + facecolor = trace["facecolor"] + subtraces = [] + # pylint: disable=singleton-comparison + facecolor[facecolor == np.array(None)] = "black" + for color in np.unique(facecolor): + mask = facecolor == color + new_trace = trace.copy() + uniq = np.unique(np.hstack([trace[k][mask] for k in "ijk"])) + new_inds = np.arange(len(uniq)) + mapping_ar = np.zeros(uniq.max() + 1, dtype=new_inds.dtype) + mapping_ar[uniq] = new_inds + for k in "ijk": + new_trace[k] = mapping_ar[trace[k][mask]] + for k in "xyz": + new_trace[k] = new_trace[k][uniq] + new_trace["color"] = color + new_trace.pop("facecolor") + subtraces.append(new_trace) + return subtraces diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index ffa09d4dc..25d598ee5 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -1,8 +1,8 @@ import sys from unittest import mock -import pytest import numpy as np +import pytest import magpylib as magpy From 8f96fc6e7262975868740c364134c90890e1cab9 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Sat, 2 Jul 2022 03:41:16 +0200 Subject: [PATCH 164/207] typo --- magpylib/_src/display/traces_generic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 3f65ec4e5..7537adfb8 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -16,7 +16,7 @@ from magpylib._src.display.traces_base import make_Arrow as make_BaseArrow from magpylib._src.display.traces_base import make_Cuboid as make_BaseCuboid from magpylib._src.display.traces_base import ( - make_CylindserSegment as make_BaseCylinderSegment, + make_CylinderSegment as make_BaseCylinderSegment, ) from magpylib._src.display.traces_base import make_Ellipsoid as make_BaseEllipsoid from magpylib._src.display.traces_base import make_Prism as make_BasePrism From ade08e3d209d931ed4a9e60b91686c1a7314c73a Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 4 Jul 2022 01:44:29 +0200 Subject: [PATCH 165/207] move line tiling to field function --- magpylib/_src/fields/field_BH_line.py | 57 +++++++++----------- magpylib/_src/fields/field_wrap_BH_level2.py | 6 +-- tests/test_field_functions.py | 33 ++++++------ 3 files changed, 44 insertions(+), 52 deletions(-) diff --git a/magpylib/_src/fields/field_BH_line.py b/magpylib/_src/fields/field_BH_line.py index f1b0ab7bd..92c8fa72e 100644 --- a/magpylib/_src/fields/field_BH_line.py +++ b/magpylib/_src/fields/field_BH_line.py @@ -11,7 +11,7 @@ def current_vertices_field( field: str, observers: np.ndarray, current: np.ndarray, - vertices: list = None, + vertices: np.ndarray = None, segment_start=None, # list of mix3 ndarrays segment_end=None, ) -> np.ndarray: @@ -32,36 +32,30 @@ def current_vertices_field( if vertices is None: return current_line_field(field, observers, current, segment_start, segment_end) - nv = len(vertices) # number of input vertex_sets - npp = int(observers.shape[0] / nv) # number of position vectors - nvs = [len(vset) - 1 for vset in vertices] # length of vertex sets - nseg = sum(nvs) # number of segments - - # vertex_sets -> segments - curr_tile = np.repeat(current, nvs) - pos_start = np.concatenate([vert[:-1] for vert in vertices]) - pos_end = np.concatenate([vert[1:] for vert in vertices]) - - # create input for vectorized computation in one go - observers = np.reshape(observers, (nv, npp, 3)) - observers = np.repeat(observers, nvs, axis=0) - observers = np.reshape(observers, (-1, 3)) - - curr_tile = np.repeat(curr_tile, npp) - pos_start = np.repeat(pos_start, npp, axis=0) - pos_end = np.repeat(pos_end, npp, axis=0) - - # compute field - field = current_line_field(field, observers, curr_tile, pos_start, pos_end) - field = np.reshape(field, (nseg, npp, 3)) - - # sum for each vertex set - ns_cum = [sum(nvs[:i]) for i in range(nv + 1)] # cumulative index positions - field_sum = np.array( - [np.sum(field[ns_cum[i - 1] : ns_cum[i]], axis=0) for i in range(1, nv + 1)] - ) - - return np.reshape(field_sum, (-1, 3)) + nvs = np.array([f.shape[0] for f in vertices]) # lengths of vertices sets + if all(v == nvs[0] for v in nvs): # if all vertices sets have the same lenghts + n0, n1, *_ = vertices.shape + BH = current_line_field( + field=field, + observers=np.repeat(observers, n1 - 1, axis=0), + current=np.repeat(current, n1 - 1, axis=0), + segment_start=vertices[:, :-1].reshape(-1, 3), + segment_end=vertices[:, 1:].reshape(-1, 3), + ) + BH = BH.reshape((n0, n1 - 1, 3)) + BH = np.sum(BH, axis=1) + else: + split_indices = np.cumsum(nvs - 1)[:-1] # remove last to avoid empty split + BH = current_line_field( + field=field, + observers=np.repeat(observers, nvs - 1, axis=0), + current=np.repeat(current, nvs - 1, axis=0), + segment_start=np.concatenate([vert[:-1] for vert in vertices]), + segment_end=np.concatenate([vert[1:] for vert in vertices]), + ) + bh_split = np.split(BH, split_indices) + BH = np.array([np.sum(bh, axis=0) for bh in bh_split]) + return BH # ON INTERFACE @@ -122,7 +116,6 @@ def current_line_field( eg. http://www.phys.uri.edu/gerhard/PHY204/tsl216.pdf """ # pylint: disable=too-many-statements - bh = check_field_input(field, "current_line_field()") # allocate for special case treatment diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 873feeffd..f7994071a 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -81,11 +81,7 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: except KeyError as err: raise MagpylibInternalError("Bad source_type in get_src_dict") from err - if src_type == "Line": # get_BH_line_from_vert function tiles internally ! - currv = np.array([src.current for src in group]) - vert_list = [src.vertices for src in group] - kwargs.update({"current": currv, "vertices": vert_list}) - elif src_type == "CustomSource": + if src_type == "CustomSource": kwargs.update(field_func=group[0].field_func) else: for prop in src_props: diff --git a/tests/test_field_functions.py b/tests/test_field_functions.py index fd0d0fc9c..21af4dcdf 100644 --- a/tests/test_field_functions.py +++ b/tests/test_field_functions.py @@ -298,26 +298,29 @@ def test_field_line(): def test_field_line_from_vert(): """test the Line field from vertex input""" - p = np.array([(1, 2, 2), (1, 2, 3), (-1, 0, -3)]) - curr = np.array([1, 5, -3]) + observers = np.array([(1, 2, 2), (1, 2, 3), (-1, 0, -3)]) + current = np.array([1, 5, -3]) - vert1 = np.array( - [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 3, 3), (1, 2, 3), (-3, 4, -5)] + vertices = np.array( + [ + np.array( + [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 3, 3), (1, 2, 3), (-3, 4, -5)] + ), + np.array([(0, 0, 0), (3, 3, 3), (-3, 4, -5)]), + np.array([(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)]), + ], + dtype="object", ) - vert2 = np.array([(0, 0, 0), (3, 3, 3), (-3, 4, -5)]) - vert3 = np.array([(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)]) - pos_tiled = np.tile(p, (3, 1)) - B_vert = current_vertices_field("B", pos_tiled, curr, [vert1, vert2, vert3]) + B_vert = current_vertices_field("B", observers, current, vertices) B = [] - for i, vert in enumerate([vert1, vert2, vert3]): - for pos in p: - p1 = vert[:-1] - p2 = vert[1:] - po = np.array([pos] * (len(vert) - 1)) - cu = np.array([curr[i]] * (len(vert) - 1)) - B += [np.sum(current_line_field("B", po, cu, p1, p2), axis=0)] + for obs, vert, curr in zip(observers, vertices, current): + p1 = vert[:-1] + p2 = vert[1:] + po = np.array([obs] * (len(vert) - 1)) + cu = np.array([curr] * (len(vert) - 1)) + B += [np.sum(current_line_field("B", po, cu, p1, p2), axis=0)] B = np.array(B) assert_allclose(B_vert, B) From 59b44dfeb6f5dbd814b4cba3551fd5e44223c606 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 4 Jul 2022 01:47:27 +0200 Subject: [PATCH 166/207] avoid tiling warning with vertices with diff len --- magpylib/_src/fields/field_wrap_BH_level2.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index f7994071a..d8550fb5c 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -44,7 +44,11 @@ def tile_group_property(group: list, n_pp: int, prop_name: str): """tile up group property""" - out = np.array([getattr(src, prop_name) for src in group]) + out = [getattr(src, prop_name) for src in group] + if not np.isscalar(out[0]) and any(o.shape != out[0].shape for o in out): + out = np.asarray(out, dtype="object") + else: + out = np.array(out) return np.repeat(out, n_pp, axis=0) From 9eeb250d83646cbd9148dbc0f1057a2cf9cd1e0e Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 4 Jul 2022 11:31:00 +0200 Subject: [PATCH 167/207] refactor field_func --- magpylib/_src/fields/field_wrap_BH_level1.py | 41 ++----------- magpylib/_src/fields/field_wrap_BH_level2.py | 57 ++++++++++++------- .../_src/obj_classes/class_BaseDisplayRepr.py | 6 +- magpylib/_src/obj_classes/class_Collection.py | 1 + .../_src/obj_classes/class_current_Line.py | 7 +++ .../_src/obj_classes/class_current_Loop.py | 7 +++ magpylib/_src/obj_classes/class_mag_Cuboid.py | 7 +++ .../_src/obj_classes/class_mag_Cylinder.py | 7 +++ .../obj_classes/class_mag_CylinderSegment.py | 9 +++ magpylib/_src/obj_classes/class_mag_Sphere.py | 7 +++ .../_src/obj_classes/class_misc_Dipole.py | 7 +++ tests/test_exceptions.py | 19 ------- 12 files changed, 97 insertions(+), 78 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py index 2f0cd8b31..89fabb0ac 100644 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ b/magpylib/_src/fields/field_wrap_BH_level1.py @@ -1,30 +1,12 @@ -import numpy as np - -from magpylib._src.exceptions import MagpylibInternalError -from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field -from magpylib._src.fields.field_BH_cylinder import magnet_cylinder_field -from magpylib._src.fields.field_BH_cylinder_segment import ( - magnet_cylinder_segment_field_internal, -) -from magpylib._src.fields.field_BH_dipole import dipole_field -from magpylib._src.fields.field_BH_line import current_vertices_field -from magpylib._src.fields.field_BH_loop import current_loop_field -from magpylib._src.fields.field_BH_sphere import magnet_sphere_field +from typing import Callable -FIELD_FUNCTIONS = { - "Cuboid": magnet_cuboid_field, - "Cylinder": magnet_cylinder_field, - "CylinderSegment": magnet_cylinder_segment_field_internal, - "Sphere": magnet_sphere_field, - "Dipole": dipole_field, - "Loop": current_loop_field, - "Line": current_vertices_field, -} +import numpy as np def getBH_level1( *, - source_type: str, + field_func: Callable, + field: str, position: np.ndarray, orientation: np.ndarray, observers: np.ndarray, @@ -44,23 +26,12 @@ def getBH_level1( field: ndarray, shape (N,3) """ - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches # transform obs_pos into source CS pos_rel_rot = orientation.apply(observers - position, inverse=True) - # collect dictionary inputs and compute field - field_func = FIELD_FUNCTIONS.get(source_type, None) - - if source_type == "CustomSource": - field = kwargs["field"] - if kwargs.get("field_func", None) is not None: - BH = kwargs["field_func"](field, pos_rel_rot) - elif field_func is not None: - BH = field_func(observers=pos_rel_rot, **kwargs) - else: - raise MagpylibInternalError(f'Bad src input type "{source_type}" in level1') + # compute field + BH = field_func(field=field, observers=pos_rel_rot, **kwargs) # transform field back into global CS BH = orientation.apply(BH) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index d8550fb5c..658783d0d 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -5,6 +5,15 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibInternalError +from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field +from magpylib._src.fields.field_BH_cylinder import magnet_cylinder_field +from magpylib._src.fields.field_BH_cylinder_segment import ( + magnet_cylinder_segment_field_internal, +) +from magpylib._src.fields.field_BH_dipole import dipole_field +from magpylib._src.fields.field_BH_line import current_vertices_field +from magpylib._src.fields.field_BH_loop import current_loop_field +from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations @@ -16,6 +25,16 @@ from magpylib._src.utility import format_src_inputs from magpylib._src.utility import LIBRARY_BH_DICT_SOURCE_STRINGS +FIELD_FUNCTIONS = { + "Cuboid": magnet_cuboid_field, + "Cylinder": magnet_cylinder_field, + "CylinderSegment": magnet_cylinder_segment_field_internal, + "Sphere": magnet_sphere_field, + "Dipole": dipole_field, + "Loop": current_loop_field, + "Line": current_vertices_field, +} + PARAM_TILE_DIMS = { "observers": 2, @@ -74,7 +93,6 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: src_type = group[0]._object_type kwargs = { - "source_type": src_type, "position": posv, "observers": posov, "orientation": rotobj, @@ -85,11 +103,8 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: except KeyError as err: raise MagpylibInternalError("Bad source_type in get_src_dict") from err - if src_type == "CustomSource": - kwargs.update(field_func=group[0].field_func) - else: - for prop in src_props: - kwargs[prop] = tile_group_property(group, n_pp, prop) + for prop in src_props: + kwargs[prop] = tile_group_property(group, n_pp, prop) return kwargs @@ -237,28 +252,26 @@ def getBH_level2( n_pix = int(n_pp / max_path_len) # group similar source types---------------------------------------------- - groups = {} + field_func_groups = {} for ind, src in enumerate(src_list): - if src._object_type == "CustomSource": - group_key = src.field_func - else: - group_key = src._object_type - if group_key not in groups: - groups[group_key] = { + group_key = src.field_func + if group_key not in field_func_groups: + field_func_groups[group_key] = { "sources": [], "order": [], - "source_type": src._object_type, } - groups[group_key]["sources"].append(src) - groups[group_key]["order"].append(ind) + field_func_groups[group_key]["sources"].append(src) + field_func_groups[group_key]["order"].append(ind) # evaluate each group in one vectorized step ------------------------------- B = np.empty((num_of_src_list, max_path_len, n_pix, 3)) # allocate B - for group in groups.values(): + for field_func, group in field_func_groups.items(): lg = len(group["sources"]) gr = group["sources"] src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 - B_group = getBH_level1(field=field, **src_dict) # compute field + B_group = getBH_level1( + field_func=field_func, field=field, **src_dict + ) # compute field B_group = B_group.reshape( (lg, max_path_len, n_pix, 3) ) # reshape (2% slower for large arrays) @@ -392,11 +405,13 @@ def getBH_dict_level2( # To allow different input dimensions, the tdim argument is also given # which tells the program which dimension it should tile up. - if source_type not in LIBRARY_BH_DICT_SOURCE_STRINGS: + try: + field_func = FIELD_FUNCTIONS[source_type] + except KeyError as err: raise MagpylibBadUserInput( f"Input parameter `sources` must be one of {LIBRARY_BH_DICT_SOURCE_STRINGS}" " when using the direct interface." - ) + ) from err kwargs["observers"] = observers kwargs["position"] = position @@ -440,7 +455,7 @@ def getBH_dict_level2( kwargs["orientation"] = R.from_quat(kwargs["orientation"]) # compute and return B - B = getBH_level1(source_type=source_type, field=field, **kwargs) + B = getBH_level1(field=field, field_func=field_func, **kwargs) if squeeze: return np.squeeze(B) diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index c38e00dd9..46972acb0 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -43,7 +43,7 @@ def _get_description(self, exclude=None): params = list(self._property_names_generator()) lines = [f"{self!r}"] for k in list(dict.fromkeys(list(UNITS) + list(params))): - if k in params and k not in exclude: + if not k.startswith("_") and k in params and k not in exclude: unit = UNITS.get(k, None) unit_str = f"{unit}" if unit else "" if k == "position": @@ -70,7 +70,7 @@ def _get_description(self, exclude=None): lines.append(f" • {k}: {val} {unit_str}") return lines - def describe(self, *, exclude=("style",), return_string=False): + def describe(self, *, exclude=("style", "field_func"), return_string=False): """Returns a view of the object properties. Parameters @@ -91,7 +91,7 @@ def describe(self, *, exclude=("style",), return_string=False): return None def _repr_html_(self): - lines = self._get_description(exclude=("style",)) + lines = self._get_description(exclude=("style", "field_func")) return f"""
{'
'.join(lines)}
""" def __repr__(self) -> str: diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index fa11d4cac..68b0eeeab 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -76,6 +76,7 @@ def collection_tree_generator( "children", "parent", "style", + "field_func", "sources", "sensors", "collections", diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 00aa6c1d4..f06b95b07 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -1,6 +1,7 @@ """Line current class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.input_checks import check_format_input_vertices from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent @@ -99,6 +100,7 @@ def __init__( # instance attributes self.vertices = vertices self._object_type = "Line" + self._field_func = current_vertices_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -119,3 +121,8 @@ def vertices(self): def vertices(self, vert): """Set Line vertices, array_like, [mm].""" self._vertices = check_format_input_vertices(vert) + + @property + def field_func(self): + """The core function for B- and H-field computation""" + return self._field_func diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index 286c8f94b..a6e1d80ed 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -1,6 +1,7 @@ """Loop current class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_loop import current_loop_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent @@ -93,6 +94,7 @@ def __init__( # instance attributes self.diameter = diameter self._object_type = "Loop" + self._field_func = current_loop_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -115,3 +117,8 @@ def diameter(self, dia): allow_None=True, forbid_negative=True, ) + + @property + def field_func(self): + """The core function for B- and H-field computation""" + return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_Cuboid.py b/magpylib/_src/obj_classes/class_mag_Cuboid.py index d07a8b20a..3c9164735 100644 --- a/magpylib/_src/obj_classes/class_mag_Cuboid.py +++ b/magpylib/_src/obj_classes/class_mag_Cuboid.py @@ -1,6 +1,7 @@ """Magnet Cuboid class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag @@ -94,6 +95,7 @@ def __init__( # instance attributes self.dimension = dimension self._object_type = "Cuboid" + self._field_func = magnet_cuboid_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -118,3 +120,8 @@ def dimension(self, dim): allow_None=True, forbid_negative0=True, ) + + @property + def field_func(self): + """The core function for B- and H-field computation""" + return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_Cylinder.py b/magpylib/_src/obj_classes/class_mag_Cylinder.py index a2adcbb64..3c3a95473 100644 --- a/magpylib/_src/obj_classes/class_mag_Cylinder.py +++ b/magpylib/_src/obj_classes/class_mag_Cylinder.py @@ -1,6 +1,7 @@ """Magnet Cylinder class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_cylinder_segment import magnet_cylinder_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag @@ -94,6 +95,7 @@ def __init__( # instance attributes self.dimension = dimension self._object_type = "Cylinder" + self._field_func = magnet_cylinder_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -118,3 +120,8 @@ def dimension(self, dim): allow_None=True, forbid_negative0=True, ) + + @property + def field_func(self): + """The core function for B- and H-field computation""" + return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py index 9dde2d6a0..b25d99f57 100644 --- a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_mag_CylinderSegment.py @@ -3,6 +3,9 @@ """ import numpy as np +from magpylib._src.fields.field_BH_cylinder_segment import ( + magnet_cylinder_segment_field_internal, +) from magpylib._src.input_checks import check_format_input_cylinder_segment from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag @@ -101,6 +104,7 @@ def __init__( # instance attributes self.dimension = dimension self._object_type = "CylinderSegment" + self._field_func = magnet_cylinder_segment_field_internal # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -151,3 +155,8 @@ def _get_barycenter(position, orientation, dimension): centroid = np.array([x, y, z]) barycenter = orientation.apply(centroid) + position return barycenter + + @property + def field_func(self): + """The core function for B- and H-field computation""" + return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_Sphere.py b/magpylib/_src/obj_classes/class_mag_Sphere.py index 84c9a21d1..b17f87a3c 100644 --- a/magpylib/_src/obj_classes/class_mag_Sphere.py +++ b/magpylib/_src/obj_classes/class_mag_Sphere.py @@ -1,6 +1,7 @@ """Magnet Sphere class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag @@ -94,6 +95,7 @@ def __init__( # instance attributes self.diameter = diameter self._object_type = "Sphere" + self._field_func = magnet_sphere_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -116,3 +118,8 @@ def diameter(self, dia): allow_None=True, forbid_negative=True, ) + + @property + def field_func(self): + """The core function for B- and H-field computation""" + return self._field_func diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 397b285f1..9f30a68c2 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -1,6 +1,7 @@ """Dipole class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo @@ -88,6 +89,7 @@ def __init__( # instance attributes self.moment = moment self._object_type = "Dipole" + self._field_func = dipole_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -110,3 +112,8 @@ def moment(self, mom): sig_type="array_like (list, tuple, ndarray) with shape (3,)", allow_None=True, ) + + @property + def field_func(self): + """The core function for B- and H-field computation""" + return self._field_func diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 0b7f3e428..ea7b65d24 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -30,21 +30,6 @@ def getBHv_unknown_source_type(): ) -def getBH_level1_internal_error(): - """bad source_type input should not happen""" - x = np.array([(1, 2, 3)]) - rot = R.from_quat((0, 0, 0, 1)) - getBH_level1( - field="B", - source_type="woot", - magnetization=x, - dimension=x, - observers=x, - position=x, - orientation=rot, - ) - - def getBH_level2_bad_input1(): """test BadUserInput error at getBH_level2""" src = magpy.magnet.Cuboid((1, 1, 2), (1, 1, 1)) @@ -379,10 +364,6 @@ def test_except_getBHv(self): self.assertRaises(MagpylibBadUserInput, getBHv_bad_input3) self.assertRaises(MagpylibBadUserInput, getBHv_unknown_source_type) - def test_except_getBH_lev1(self): - """getBH_level1 exception testing""" - self.assertRaises(MagpylibInternalError, getBH_level1_internal_error) - def test_except_getBH_lev2(self): """getBH_level2 exception testing""" self.assertRaises(MagpylibBadUserInput, getBH_level2_bad_input1) From 36be73d4ede367916443d299d494348ad5bfba73 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 4 Jul 2022 20:01:12 +0200 Subject: [PATCH 168/207] Register field functions --- magpylib/_src/defaults/defaults_utility.py | 15 +--- magpylib/_src/fields/field_wrap_BH_level2.py | 28 +----- magpylib/_src/input_checks.py | 9 +- magpylib/_src/obj_classes/class_Collection.py | 7 +- .../_src/obj_classes/class_current_Line.py | 9 +- .../_src/obj_classes/class_current_Loop.py | 9 +- ...s_mag_Cuboid.py => class_magnet_Cuboid.py} | 9 +- ...g_Cylinder.py => class_magnet_Cylinder.py} | 9 +- ...ent.py => class_magnet_CylinderSegment.py} | 9 +- ...s_mag_Sphere.py => class_magnet_Sphere.py} | 9 +- ...c_Custom.py => class_misc_CustomSource.py} | 3 +- .../_src/obj_classes/class_misc_Dipole.py | 9 +- magpylib/_src/style.py | 16 ++-- magpylib/_src/utility.py | 89 ++++++++++++------- tests/test_obj_BaseGeo.py | 6 +- tests/test_obj_Collection.py | 2 + 16 files changed, 97 insertions(+), 141 deletions(-) rename magpylib/_src/obj_classes/{class_mag_Cuboid.py => class_magnet_Cuboid.py} (95%) rename magpylib/_src/obj_classes/{class_mag_Cylinder.py => class_magnet_Cylinder.py} (95%) rename magpylib/_src/obj_classes/{class_mag_CylinderSegment.py => class_magnet_CylinderSegment.py} (96%) rename magpylib/_src/obj_classes/{class_mag_Sphere.py => class_magnet_Sphere.py} (94%) rename magpylib/_src/obj_classes/{class_misc_Custom.py => class_misc_CustomSource.py} (97%) diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index c3573bb1b..d16687bd2 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -7,17 +7,6 @@ SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly") -MAGPYLIB_FAMILIES = { - "Line": ("current",), - "Loop": ("current",), - "Cuboid": ("magnet",), - "Cylinder": ("magnet",), - "Sphere": ("magnet",), - "CylinderSegment": ("magnet",), - "Sensor": ("sensor",), - "Dipole": ("dipole",), - "Marker": ("markers",), -} SYMBOLS_MATPLOTLIB_TO_PLOTLY = { ".": "circle", @@ -244,7 +233,7 @@ def color_validator(color_input, allow_None=True, parent_name=""): if isinstance(color_input, (tuple, list)): - if len(color_input) == 4: # do not allow opacity values for now + if len(color_input) == 4: # do not allow opacity values for now color_input = color_input[:-1] if len(color_input) != 3: raise ValueError( @@ -253,7 +242,7 @@ def color_validator(color_input, allow_None=True, parent_name=""): ) # transform matplotlib colors scaled from 0-1 to rgb colors if not isinstance(color_input[0], int): - color_input = [int(255*c) for c in color_input] + color_input = [int(255 * c) for c in color_input] c = tuple(color_input) color_input = f"#{c[0]:02x}{c[1]:02x}{c[2]:02x}" diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 658783d0d..4af567666 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -5,15 +5,6 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibInternalError -from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field -from magpylib._src.fields.field_BH_cylinder import magnet_cylinder_field -from magpylib._src.fields.field_BH_cylinder_segment import ( - magnet_cylinder_segment_field_internal, -) -from magpylib._src.fields.field_BH_dipole import dipole_field -from magpylib._src.fields.field_BH_line import current_vertices_field -from magpylib._src.fields.field_BH_loop import current_loop_field -from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations @@ -23,17 +14,7 @@ from magpylib._src.utility import check_static_sensor_orient from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs -from magpylib._src.utility import LIBRARY_BH_DICT_SOURCE_STRINGS - -FIELD_FUNCTIONS = { - "Cuboid": magnet_cuboid_field, - "Cylinder": magnet_cylinder_field, - "CylinderSegment": magnet_cylinder_segment_field_internal, - "Sphere": magnet_sphere_field, - "Dipole": dipole_field, - "Loop": current_loop_field, - "Line": current_vertices_field, -} +from magpylib._src.utility import Registered PARAM_TILE_DIMS = { @@ -395,8 +376,7 @@ def getBH_dict_level2( - sets default input variables (e.g. pos, rot) if missing - tiles 1D inputs vectors to correct dimension """ - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements + # pylint: disable=protected-access # generate dict of secured inputs for auto-tiling --------------- # entries in this dict will be tested for input length, and then @@ -406,10 +386,10 @@ def getBH_dict_level2( # which tells the program which dimension it should tile up. try: - field_func = FIELD_FUNCTIONS[source_type] + field_func = Registered.sources[source_type]._field_func except KeyError as err: raise MagpylibBadUserInput( - f"Input parameter `sources` must be one of {LIBRARY_BH_DICT_SOURCE_STRINGS}" + f"Input parameter `sources` must be one of {list(Registered.sources)}" " when using the direct interface." ) from err diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index cd51ff532..243a969c5 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -10,8 +10,7 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibMissingInput from magpylib._src.utility import format_obj_input -from magpylib._src.utility import LIBRARY_SENSORS -from magpylib._src.utility import LIBRARY_SOURCES +from magpylib._src.utility import Registered from magpylib._src.utility import wrong_obj_msg @@ -503,14 +502,14 @@ def check_format_input_obj( # select wanted wanted_types = [] if "sources" in allow.split("+"): - wanted_types += list(LIBRARY_SOURCES) + wanted_types += list(Registered.sources) if "sensors" in allow.split("+"): - wanted_types += list(LIBRARY_SENSORS) + wanted_types += list(Registered.sensors) if "collections" in allow.split("+"): wanted_types += ["Collection"] if typechecks: - all_types = list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS) + ["Collection"] + all_types = list(Registered.sources) + list(Registered.sensors) + ["Collection"] obj_list = [] for obj in inp: diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 68b0eeeab..5571af50a 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -9,9 +9,8 @@ from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.utility import format_obj_input -from magpylib._src.utility import LIBRARY_SENSORS -from magpylib._src.utility import LIBRARY_SOURCES from magpylib._src.utility import rec_obj_remover +from magpylib._src.utility import Registered def repr_obj(obj, format="type+id+label"): @@ -355,10 +354,10 @@ def _update_src_and_sens(self): # pylint: disable=protected-access """updates sources, sensors and collections attributes from children""" self._sources = [ - obj for obj in self._children if obj._object_type in LIBRARY_SOURCES + obj for obj in self._children if obj._object_type in Registered.sources ] self._sensors = [ - obj for obj in self._children if obj._object_type in LIBRARY_SENSORS + obj for obj in self._children if obj._object_type in Registered.sensors ] self._collections = [ obj for obj in self._children if obj._object_type == "Collection" diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index f06b95b07..e793bc9af 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -7,8 +7,10 @@ from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="current", field_func=current_vertices_field) class Line(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Current flowing in straight lines from vertex to vertex. @@ -99,8 +101,6 @@ def __init__( # instance attributes self.vertices = vertices - self._object_type = "Line" - self._field_func = current_vertices_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -121,8 +121,3 @@ def vertices(self): def vertices(self, vert): """Set Line vertices, array_like, [mm].""" self._vertices = check_format_input_vertices(vert) - - @property - def field_func(self): - """The core function for B- and H-field computation""" - return self._field_func diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index a6e1d80ed..f595f49b9 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -7,8 +7,10 @@ from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="current", field_func=current_loop_field) class Loop(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Circular current loop. @@ -93,8 +95,6 @@ def __init__( # instance attributes self.diameter = diameter - self._object_type = "Loop" - self._field_func = current_loop_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -117,8 +117,3 @@ def diameter(self, dia): allow_None=True, forbid_negative=True, ) - - @property - def field_func(self): - """The core function for B- and H-field computation""" - return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py similarity index 95% rename from magpylib/_src/obj_classes/class_mag_Cuboid.py rename to magpylib/_src/obj_classes/class_magnet_Cuboid.py index 3c9164735..2ffbab3b2 100644 --- a/magpylib/_src/obj_classes/class_mag_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -7,8 +7,10 @@ from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="magnet", field_func=magnet_cuboid_field) class Cuboid(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cuboid magnet with homogeneous magnetization. @@ -94,8 +96,6 @@ def __init__( # instance attributes self.dimension = dimension - self._object_type = "Cuboid" - self._field_func = magnet_cuboid_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -120,8 +120,3 @@ def dimension(self, dim): allow_None=True, forbid_negative0=True, ) - - @property - def field_func(self): - """The core function for B- and H-field computation""" - return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py similarity index 95% rename from magpylib/_src/obj_classes/class_mag_Cylinder.py rename to magpylib/_src/obj_classes/class_magnet_Cylinder.py index 3c3a95473..662c9fb2e 100644 --- a/magpylib/_src/obj_classes/class_mag_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -7,8 +7,10 @@ from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="magnet", field_func=magnet_cylinder_field) class Cylinder(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder magnet with homogeneous magnetization. @@ -94,8 +96,6 @@ def __init__( # instance attributes self.dimension = dimension - self._object_type = "Cylinder" - self._field_func = magnet_cylinder_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -120,8 +120,3 @@ def dimension(self, dim): allow_None=True, forbid_negative0=True, ) - - @property - def field_func(self): - """The core function for B- and H-field computation""" - return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py similarity index 96% rename from magpylib/_src/obj_classes/class_mag_CylinderSegment.py rename to magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index b25d99f57..16e41d058 100644 --- a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -11,8 +11,10 @@ from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="magnet", field_func=magnet_cylinder_segment_field_internal) class CylinderSegment(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder segment (ring-section) magnet with homogeneous magnetization. @@ -103,8 +105,6 @@ def __init__( # instance attributes self.dimension = dimension - self._object_type = "CylinderSegment" - self._field_func = magnet_cylinder_segment_field_internal # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -155,8 +155,3 @@ def _get_barycenter(position, orientation, dimension): centroid = np.array([x, y, z]) barycenter = orientation.apply(centroid) + position return barycenter - - @property - def field_func(self): - """The core function for B- and H-field computation""" - return self._field_func diff --git a/magpylib/_src/obj_classes/class_mag_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py similarity index 94% rename from magpylib/_src/obj_classes/class_mag_Sphere.py rename to magpylib/_src/obj_classes/class_magnet_Sphere.py index b17f87a3c..74daa3808 100644 --- a/magpylib/_src/obj_classes/class_mag_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -7,8 +7,10 @@ from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="magnet", field_func=magnet_sphere_field) class Sphere(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Spherical magnet with homogeneous magnetization. @@ -94,8 +96,6 @@ def __init__( # instance attributes self.diameter = diameter - self._object_type = "Sphere" - self._field_func = magnet_sphere_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -118,8 +118,3 @@ def diameter(self, dia): allow_None=True, forbid_negative=True, ) - - @property - def field_func(self): - """The core function for B- and H-field computation""" - return self._field_func diff --git a/magpylib/_src/obj_classes/class_misc_Custom.py b/magpylib/_src/obj_classes/class_misc_CustomSource.py similarity index 97% rename from magpylib/_src/obj_classes/class_misc_Custom.py rename to magpylib/_src/obj_classes/class_misc_CustomSource.py index e23f9b1e3..4a5da540e 100644 --- a/magpylib/_src/obj_classes/class_misc_Custom.py +++ b/magpylib/_src/obj_classes/class_misc_CustomSource.py @@ -3,8 +3,10 @@ from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="misc", field_func=None) class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): """User-defined custom source. @@ -91,7 +93,6 @@ def __init__( ): # instance attributes self.field_func = field_func - self._object_type = "CustomSource" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 9f30a68c2..219365284 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -6,8 +6,10 @@ from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(family="dipole", field_func=dipole_field) class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH): """Magnetic dipole moment. @@ -88,8 +90,6 @@ def __init__( ): # instance attributes self.moment = moment - self._object_type = "Dipole" - self._field_func = dipole_field # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) @@ -112,8 +112,3 @@ def moment(self, mom): sig_type="array_like (list, tuple, ndarray) with shape (3,)", allow_None=True, ) - - @property - def field_func(self): - """The core function for B- and H-field computation""" - return self._field_func diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 6bc8e4b3a..36e47632f 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -7,21 +7,19 @@ from magpylib._src.defaults.defaults_utility import get_defaults_dict from magpylib._src.defaults.defaults_utility import LINESTYLES_MATPLOTLIB_TO_PLOTLY from magpylib._src.defaults.defaults_utility import MagicProperties -from magpylib._src.defaults.defaults_utility import MAGPYLIB_FAMILIES from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.defaults.defaults_utility import SYMBOLS_MATPLOTLIB_TO_PLOTLY from magpylib._src.defaults.defaults_utility import validate_property_class from magpylib._src.defaults.defaults_utility import validate_style_keys +from magpylib._src.utility import Registered def get_style_class(obj): """Returns style instance based on object type. If object has no attribute `_object_type` or is - not found in `MAGPYLIB_FAMILIES` returns `BaseStyle` instance. + not found in `Registered.famillies` returns `BaseStyle` instance. """ obj_type = getattr(obj, "_object_type", None) - style_fam = MAGPYLIB_FAMILIES.get(obj_type, None) - if isinstance(style_fam, (list, tuple)): - style_fam = style_fam[0] + style_fam = Registered.families.get(obj_type, None) return STYLE_CLASSES.get(style_fam, BaseStyle) @@ -44,15 +42,11 @@ def get_style(obj, default_settings, **kwargs): # construct object specific dictionary base on style family and default style obj_type = getattr(obj, "_object_type", None) - obj_families = MAGPYLIB_FAMILIES.get(obj_type, []) + obj_family = Registered.families.get(obj_type, None) obj_style_default_dict = { **styles_by_family["base"], - **{ - k: v - for fam in obj_families - for k, v in styles_by_family.get(fam, {}).items() - }, + **{k: v for k, v in styles_by_family.get(obj_family, {}).items()}, } style_kwargs = validate_style_keys(style_kwargs) # create style class instance and update based on precedence diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index c7031f3ff..03d9c7869 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -7,34 +7,59 @@ from magpylib._src.exceptions import MagpylibBadUserInput -LIBRARY_SOURCES = ( - "Cuboid", - "Cylinder", - "CylinderSegment", - "Sphere", - "Dipole", - "Loop", - "Line", - "CustomSource", -) - -LIBRARY_BH_DICT_SOURCE_STRINGS = ( - "Cuboid", - "Cylinder", - "CylinderSegment", - "Sphere", - "Dipole", - "Loop", - "Line", -) - -LIBRARY_SENSORS = ("Sensor",) - -ALLOWED_SOURCE_MSG = f"""Sources must be either -- one of type {LIBRARY_SOURCES} + +class Registered: + """Class decorator to register source class into LIBRARY_SOURCES + and to update field function of source class""" + + sensors = {"Sensor": None} + sources = {} + families = { + "Sensor": "sensor", + "Marker": "markers", + } + + def __init__(self, *, family, field_func): + self.family = family + self.field_func = field_func + + def __call__(self, klass): + if self.field_func is None: + setattr(klass, "_field_func", None) + else: + setattr(klass, "_field_func", staticmethod(self.field_func)) + setattr( + klass, + "field_func", + property( + lambda self: getattr(self, "_field_func"), + doc="""The core function for B- and H-field computation""", + ), + ) + setattr(klass, "_family", self.family) + setattr( + klass, + "family", + property( + lambda self: getattr(self, "_family"), + doc="""The source family (e.g. 'magnet', 'current', 'misc')""", + ), + ) + name = klass.__name__ + setattr(klass, "_object_type", name) + self.sources[name] = klass + self.families[name] = self.family + return klass + + +def get_allowed_sources_msg(): + "Return allowed source message" + return f"""Sources must be either +- one of type {list(Registered.sources)} - Collection with at least one of the above - 1D list of the above -- string {LIBRARY_BH_DICT_SOURCE_STRINGS}""" +- string {list(Registered.sources)}""" + ALLOWED_OBSERVER_MSG = """Observers must be either - array_like positions of shape (N1, N2, ..., 3) @@ -55,7 +80,7 @@ def wrong_obj_msg(*objs, allow="sources"): prefix = "No" if len(allowed) == 1 else "Bad" msg = f"{prefix} {'/'.join(allowed)} provided" if "sources" in allowed: - msg += "\n" + ALLOWED_SOURCE_MSG + msg += "\n" + get_allowed_sources_msg() if "observers" in allowed: msg += "\n" + ALLOWED_OBSERVER_MSG if "sensors" in allowed: @@ -94,8 +119,8 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> flatten_collection = not "collections" in allow.split("+") for obj in objects: try: - if getattr(obj, "_object_type", None) in list(LIBRARY_SOURCES) + list( - LIBRARY_SENSORS + if getattr(obj, "_object_type", None) in list(Registered.sources) + list( + Registered.sensors ): obj_list += [obj] else: @@ -147,7 +172,7 @@ def format_src_inputs(sources) -> list: if not child_sources: raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources")) src_list += child_sources - elif obj_type in LIBRARY_SOURCES: + elif obj_type in list(Registered.sources): src_list += [src] else: raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources")) @@ -220,9 +245,9 @@ def filter_objects(obj_list, allow="sources+sensors", warn=True): allowed_list = [] for allowed in allow.split("+"): if allowed == "sources": - allowed_list.extend(LIBRARY_SOURCES) + allowed_list.extend(list(Registered.sources)) elif allowed == "sensors": - allowed_list.extend(LIBRARY_SENSORS) + allowed_list.extend(list(Registered.sensors)) elif allowed == "collections": allowed_list.extend(["Collection"]) new_list = [] diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index a0d57b69d..697df334d 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -417,8 +417,8 @@ def test_describe(): test = ( "
Cuboid(id=REGEX, label='x1')
• parent: None
• " - + "position: [0. 0. 0.] mm
• orientation: [0. 0. 0.] degrees
• " - + "dimension: None mm
• magnetization: None mT
" + "position: [0. 0. 0.] mm
• orientation: [0. 0. 0.] degrees
• " + "dimension: None mm
• magnetization: None mT
• family: magnet " ) rep = x1._repr_html_() rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep) @@ -432,6 +432,7 @@ def test_describe(): " • orientation: [0. 0. 0.] degrees", " • dimension: None mm", " • magnetization: None mT", + " • family: magnet ", # INVISIBLE SPACE ] desc = x1.describe(return_string=True) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) @@ -444,6 +445,7 @@ def test_describe(): " • orientation: [0. 0. 0.] degrees", " • dimension: [1. 3.] mm", " • magnetization: [2. 3. 4.] mT", + " • family: magnet ", # INVISIBLE SPACE ] desc = x2.describe(return_string=True) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) diff --git a/tests/test_obj_Collection.py b/tests/test_obj_Collection.py index 94fd5442d..293755100 100644 --- a/tests/test_obj_Collection.py +++ b/tests/test_obj_Collection.py @@ -417,11 +417,13 @@ def test_collection_describe(): "│ • orientation: [0. 0. 0.] degrees", "│ • dimension: None mm", "│ • magnetization: None mT", + "│ • family: magnet", "└── y", " • position: [0. 0. 0.] mm", " • orientation: [0. 0. 0.] degrees", " • dimension: None mm", " • magnetization: None mT", + " • family: magnet", ] assert "".join(test) == re.sub("id=*[0-9]*[0-9]", "id=REGEX", "".join(desc)) From bd6f548405e24ad0be9e691478d68994631c73f0 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 4 Jul 2022 20:02:51 +0200 Subject: [PATCH 169/207] automatize imports --- magpylib/current/__init__.py | 18 +++++++++++++++--- magpylib/magnet/__init__.py | 19 ++++++++++++++----- magpylib/misc/__init__.py | 18 +++++++++++++++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/magpylib/current/__init__.py b/magpylib/current/__init__.py index 4f87fde1e..1f3566881 100644 --- a/magpylib/current/__init__.py +++ b/magpylib/current/__init__.py @@ -1,8 +1,20 @@ """ The `magpylib.current` subpackage contains all electric current classes. """ +import importlib +from pkgutil import iter_modules -__all__ = ["Loop", "Line"] +from magpylib._src import obj_classes -from magpylib._src.obj_classes.class_current_Loop import Loop -from magpylib._src.obj_classes.class_current_Line import Line +classes = [] + +for submodule in iter_modules(obj_classes.__path__): + if submodule.name.startswith("class_current"): + _, typ, cls_name = submodule.name.split("_") + classes.append(cls_name) + module = importlib.import_module( + f"{obj_classes.__name__}.{submodule.name}", cls_name + ) + vars()[cls_name] = getattr(module, cls_name) + +__all__ = classes diff --git a/magpylib/magnet/__init__.py b/magpylib/magnet/__init__.py index 32a595551..7e0dc43b7 100644 --- a/magpylib/magnet/__init__.py +++ b/magpylib/magnet/__init__.py @@ -1,11 +1,20 @@ """ The `magpylib.magnet` subpackage contains all magnet classes. """ +import importlib +from pkgutil import iter_modules -__all__ = ["Cuboid", "Cylinder", "Sphere", "CylinderSegment"] +from magpylib._src import obj_classes +classes = [] -from magpylib._src.obj_classes.class_mag_Cuboid import Cuboid -from magpylib._src.obj_classes.class_mag_Cylinder import Cylinder -from magpylib._src.obj_classes.class_mag_Sphere import Sphere -from magpylib._src.obj_classes.class_mag_CylinderSegment import CylinderSegment +for submodule in iter_modules(obj_classes.__path__): + if submodule.name.startswith("class_magnet"): + _, typ, cls_name = submodule.name.split("_") + classes.append(cls_name) + module = importlib.import_module( + f"{obj_classes.__name__}.{submodule.name}", cls_name + ) + vars()[cls_name] = getattr(module, cls_name) + +__all__ = classes diff --git a/magpylib/misc/__init__.py b/magpylib/misc/__init__.py index 4e336393e..b3f299c74 100644 --- a/magpylib/misc/__init__.py +++ b/magpylib/misc/__init__.py @@ -1,8 +1,20 @@ """ The `magpylib.misc` sub-package contains miscellaneous source objects. """ +import importlib +from pkgutil import iter_modules -__all__ = ["Dipole", "CustomSource"] +from magpylib._src import obj_classes -from magpylib._src.obj_classes.class_misc_Dipole import Dipole -from magpylib._src.obj_classes.class_misc_Custom import CustomSource +classes = [] + +for submodule in iter_modules(obj_classes.__path__): + if submodule.name.startswith("class_misc"): + _, typ, cls_name = submodule.name.split("_") + classes.append(cls_name) + module = importlib.import_module( + f"{obj_classes.__name__}.{submodule.name}", cls_name + ) + vars()[cls_name] = getattr(module, cls_name) + +__all__ = classes From c0ef6aaf5c3f0c0172bef879f5242a1309f18d91 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 4 Jul 2022 20:15:49 +0200 Subject: [PATCH 170/207] automatize imports --- magpylib/current/__init__.py | 18 +++++++++++++++--- magpylib/magnet/__init__.py | 19 ++++++++++++++----- magpylib/misc/__init__.py | 18 +++++++++++++++--- 3 files changed, 44 insertions(+), 11 deletions(-) diff --git a/magpylib/current/__init__.py b/magpylib/current/__init__.py index 4f87fde1e..1f3566881 100644 --- a/magpylib/current/__init__.py +++ b/magpylib/current/__init__.py @@ -1,8 +1,20 @@ """ The `magpylib.current` subpackage contains all electric current classes. """ +import importlib +from pkgutil import iter_modules -__all__ = ["Loop", "Line"] +from magpylib._src import obj_classes -from magpylib._src.obj_classes.class_current_Loop import Loop -from magpylib._src.obj_classes.class_current_Line import Line +classes = [] + +for submodule in iter_modules(obj_classes.__path__): + if submodule.name.startswith("class_current"): + _, typ, cls_name = submodule.name.split("_") + classes.append(cls_name) + module = importlib.import_module( + f"{obj_classes.__name__}.{submodule.name}", cls_name + ) + vars()[cls_name] = getattr(module, cls_name) + +__all__ = classes diff --git a/magpylib/magnet/__init__.py b/magpylib/magnet/__init__.py index 32a595551..7e0dc43b7 100644 --- a/magpylib/magnet/__init__.py +++ b/magpylib/magnet/__init__.py @@ -1,11 +1,20 @@ """ The `magpylib.magnet` subpackage contains all magnet classes. """ +import importlib +from pkgutil import iter_modules -__all__ = ["Cuboid", "Cylinder", "Sphere", "CylinderSegment"] +from magpylib._src import obj_classes +classes = [] -from magpylib._src.obj_classes.class_mag_Cuboid import Cuboid -from magpylib._src.obj_classes.class_mag_Cylinder import Cylinder -from magpylib._src.obj_classes.class_mag_Sphere import Sphere -from magpylib._src.obj_classes.class_mag_CylinderSegment import CylinderSegment +for submodule in iter_modules(obj_classes.__path__): + if submodule.name.startswith("class_magnet"): + _, typ, cls_name = submodule.name.split("_") + classes.append(cls_name) + module = importlib.import_module( + f"{obj_classes.__name__}.{submodule.name}", cls_name + ) + vars()[cls_name] = getattr(module, cls_name) + +__all__ = classes diff --git a/magpylib/misc/__init__.py b/magpylib/misc/__init__.py index 4e336393e..b3f299c74 100644 --- a/magpylib/misc/__init__.py +++ b/magpylib/misc/__init__.py @@ -1,8 +1,20 @@ """ The `magpylib.misc` sub-package contains miscellaneous source objects. """ +import importlib +from pkgutil import iter_modules -__all__ = ["Dipole", "CustomSource"] +from magpylib._src import obj_classes -from magpylib._src.obj_classes.class_misc_Dipole import Dipole -from magpylib._src.obj_classes.class_misc_Custom import CustomSource +classes = [] + +for submodule in iter_modules(obj_classes.__path__): + if submodule.name.startswith("class_misc"): + _, typ, cls_name = submodule.name.split("_") + classes.append(cls_name) + module = importlib.import_module( + f"{obj_classes.__name__}.{submodule.name}", cls_name + ) + vars()[cls_name] = getattr(module, cls_name) + +__all__ = classes From cb0b05350520c94f5346cde026abcec84c4f1130 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 4 Jul 2022 20:18:47 +0200 Subject: [PATCH 171/207] back to explicit imports --- magpylib/current/__init__.py | 18 +++--------------- magpylib/magnet/__init__.py | 19 +++++-------------- magpylib/misc/__init__.py | 18 +++--------------- 3 files changed, 11 insertions(+), 44 deletions(-) diff --git a/magpylib/current/__init__.py b/magpylib/current/__init__.py index 1f3566881..4f87fde1e 100644 --- a/magpylib/current/__init__.py +++ b/magpylib/current/__init__.py @@ -1,20 +1,8 @@ """ The `magpylib.current` subpackage contains all electric current classes. """ -import importlib -from pkgutil import iter_modules -from magpylib._src import obj_classes +__all__ = ["Loop", "Line"] -classes = [] - -for submodule in iter_modules(obj_classes.__path__): - if submodule.name.startswith("class_current"): - _, typ, cls_name = submodule.name.split("_") - classes.append(cls_name) - module = importlib.import_module( - f"{obj_classes.__name__}.{submodule.name}", cls_name - ) - vars()[cls_name] = getattr(module, cls_name) - -__all__ = classes +from magpylib._src.obj_classes.class_current_Loop import Loop +from magpylib._src.obj_classes.class_current_Line import Line diff --git a/magpylib/magnet/__init__.py b/magpylib/magnet/__init__.py index 7e0dc43b7..bfa9dc7a7 100644 --- a/magpylib/magnet/__init__.py +++ b/magpylib/magnet/__init__.py @@ -1,20 +1,11 @@ """ The `magpylib.magnet` subpackage contains all magnet classes. """ -import importlib -from pkgutil import iter_modules -from magpylib._src import obj_classes +__all__ = ["Cuboid", "Cylinder", "Sphere", "CylinderSegment"] -classes = [] -for submodule in iter_modules(obj_classes.__path__): - if submodule.name.startswith("class_magnet"): - _, typ, cls_name = submodule.name.split("_") - classes.append(cls_name) - module = importlib.import_module( - f"{obj_classes.__name__}.{submodule.name}", cls_name - ) - vars()[cls_name] = getattr(module, cls_name) - -__all__ = classes +from magpylib._src.obj_classes.class_magnet_Cuboid import Cuboid +from magpylib._src.obj_classes.class_magnet_Cylinder import Cylinder +from magpylib._src.obj_classes.class_magnet_Sphere import Sphere +from magpylib._src.obj_classes.class_magnet_CylinderSegment import CylinderSegment diff --git a/magpylib/misc/__init__.py b/magpylib/misc/__init__.py index b3f299c74..2ab07f329 100644 --- a/magpylib/misc/__init__.py +++ b/magpylib/misc/__init__.py @@ -1,20 +1,8 @@ """ The `magpylib.misc` sub-package contains miscellaneous source objects. """ -import importlib -from pkgutil import iter_modules -from magpylib._src import obj_classes +__all__ = ["Dipole", "CustomSource"] -classes = [] - -for submodule in iter_modules(obj_classes.__path__): - if submodule.name.startswith("class_misc"): - _, typ, cls_name = submodule.name.split("_") - classes.append(cls_name) - module = importlib.import_module( - f"{obj_classes.__name__}.{submodule.name}", cls_name - ) - vars()[cls_name] = getattr(module, cls_name) - -__all__ = classes +from magpylib._src.obj_classes.class_misc_Dipole import Dipole +from magpylib._src.obj_classes.class_misc_CustomSource import CustomSource From eef27c569783d6d065cb7f31ffa6b022af69eaf6 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 4 Jul 2022 23:46:48 +0200 Subject: [PATCH 172/207] renaming modules finish --- magpylib/_src/display/plotly/plotly_display.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py index 2fca4be88..f8e962212 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -509,10 +509,10 @@ def get_plotly_traces( # pylint: disable=too-many-nested-blocks Sensor = _src.obj_classes.class_Sensor.Sensor - Cuboid = _src.obj_classes.class_mag_Cuboid.Cuboid - Cylinder = _src.obj_classes.class_mag_Cylinder.Cylinder - CylinderSegment = _src.obj_classes.class_mag_CylinderSegment.CylinderSegment - Sphere = _src.obj_classes.class_mag_Sphere.Sphere + Cuboid = _src.obj_classes.class_magnet_Cuboid.Cuboid + Cylinder = _src.obj_classes.class_magnet_Cylinder.Cylinder + CylinderSegment = _src.obj_classes.class_magnet_CylinderSegment.CylinderSegment + Sphere = _src.obj_classes.class_magnet_Sphere.Sphere Dipole = _src.obj_classes.class_misc_Dipole.Dipole Loop = _src.obj_classes.class_current_Loop.Loop Line = _src.obj_classes.class_current_Line.Line From 48b066a87fbc2fa3ef72638c689e061a30f61baf Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 4 Jul 2022 23:48:15 +0200 Subject: [PATCH 173/207] refactor param dims properties --- magpylib/_src/fields/field_wrap_BH_level2.py | 38 +++++-------------- .../_src/obj_classes/class_current_Line.py | 6 ++- .../_src/obj_classes/class_current_Loop.py | 6 ++- .../_src/obj_classes/class_magnet_Cuboid.py | 6 ++- .../_src/obj_classes/class_magnet_Cylinder.py | 6 ++- .../class_magnet_CylinderSegment.py | 6 ++- .../_src/obj_classes/class_magnet_Sphere.py | 6 ++- .../_src/obj_classes/class_misc_Dipole.py | 2 +- magpylib/_src/utility.py | 7 +++- 9 files changed, 46 insertions(+), 37 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py index 4af567666..b4a5eebbb 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH_level2.py @@ -17,31 +17,6 @@ from magpylib._src.utility import Registered -PARAM_TILE_DIMS = { - "observers": 2, - "position": 2, - "orientation": 2, - "magnetization": 2, - "current": 1, - "moment": 2, - "dimension": 2, - "diameter": 1, - "segment_start": 2, - "segment_end": 2, -} - -SOURCE_PROPERTIES = { - "Cuboid": ("magnetization", "dimension"), - "Cylinder": ("magnetization", "dimension"), - "CylinderSegment": ("magnetization", "dimension"), - "Sphere": ("magnetization", "diameter"), - "Dipole": ("moment",), - "Loop": ("current", "diameter"), - "Line": ("current", "vertices"), - "CustomSource": (), -} - - def tile_group_property(group: list, n_pp: int, prop_name: str): """tile up group property""" out = [getattr(src, prop_name) for src in group] @@ -80,12 +55,17 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: } try: - src_props = SOURCE_PROPERTIES[src_type] + src_props = Registered.properties[src_type] except KeyError as err: raise MagpylibInternalError("Bad source_type in get_src_dict") from err for prop in src_props: - kwargs[prop] = tile_group_property(group, n_pp, prop) + if hasattr(group[0], prop) and prop not in ( + "position", + "orientation", + "observers", + ): + kwargs[prop] = tile_group_property(group, n_pp, prop) return kwargs @@ -408,7 +388,7 @@ def getBH_dict_level2( raise MagpylibBadUserInput( f"{key} input must be array-like.\n" f"Instead received {val}" ) from err - tdim = PARAM_TILE_DIMS.get(key, 1) + tdim = Registered.properties[source_type].get(key, 1) if val.ndim == tdim: vec_lengths.append(len(val)) kwargs[key] = val @@ -422,7 +402,7 @@ def getBH_dict_level2( # tile 1D inputs and replace original values in kwargs for key, val in kwargs.items(): - tdim = PARAM_TILE_DIMS.get(key, 1) + tdim = Registered.properties[source_type].get(key, 1) if val.ndim < tdim: if tdim == 2: kwargs[key] = np.tile(val, (vec_len, 1)) diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index e793bc9af..ea90c1a6f 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -10,7 +10,11 @@ from magpylib._src.utility import Registered -@Registered(family="current", field_func=current_vertices_field) +@Registered( + family="current", + field_func=current_vertices_field, + properties={"current": 1, "vertices": 2, "segment_start": 2, "segment_end": 2}, +) class Line(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Current flowing in straight lines from vertex to vertex. diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index f595f49b9..1ccdb83bf 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -10,7 +10,11 @@ from magpylib._src.utility import Registered -@Registered(family="current", field_func=current_loop_field) +@Registered( + family="current", + field_func=current_loop_field, + properties={"current": 1, "diameter": 1}, +) class Loop(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Circular current loop. diff --git a/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py index 2ffbab3b2..e54e451bc 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -10,7 +10,11 @@ from magpylib._src.utility import Registered -@Registered(family="magnet", field_func=magnet_cuboid_field) +@Registered( + family="magnet", + field_func=magnet_cuboid_field, + properties={"magnetization": 2, "dimension": 2}, +) class Cuboid(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cuboid magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py index 662c9fb2e..163471bec 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -10,7 +10,11 @@ from magpylib._src.utility import Registered -@Registered(family="magnet", field_func=magnet_cylinder_field) +@Registered( + family="magnet", + field_func=magnet_cylinder_field, + properties={"magnetization": 2, "dimension": 2}, +) class Cylinder(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index 16e41d058..fe5e26213 100644 --- a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -14,7 +14,11 @@ from magpylib._src.utility import Registered -@Registered(family="magnet", field_func=magnet_cylinder_segment_field_internal) +@Registered( + family="magnet", + field_func=magnet_cylinder_segment_field_internal, + properties={"magnetization": 2, "dimension": 2}, +) class CylinderSegment(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder segment (ring-section) magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_magnet_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py index 74daa3808..58712718e 100644 --- a/magpylib/_src/obj_classes/class_magnet_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -10,7 +10,11 @@ from magpylib._src.utility import Registered -@Registered(family="magnet", field_func=magnet_sphere_field) +@Registered( + family="magnet", + field_func=magnet_sphere_field, + properties={"magnetization": 2, "diameter": 1}, +) class Sphere(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Spherical magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 219365284..e38d4cfa5 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -9,7 +9,7 @@ from magpylib._src.utility import Registered -@Registered(family="dipole", field_func=dipole_field) +@Registered(family="dipole", field_func=dipole_field, properties={"moment": 2}) class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH): """Magnetic dipole moment. diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 03d9c7869..51594c659 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -18,10 +18,12 @@ class Registered: "Sensor": "sensor", "Marker": "markers", } + properties = {} - def __init__(self, *, family, field_func): + def __init__(self, *, family, field_func, properties=None): self.family = family self.field_func = field_func + self.properties_new = {} if properties is None else properties def __call__(self, klass): if self.field_func is None: @@ -49,6 +51,9 @@ def __call__(self, klass): setattr(klass, "_object_type", name) self.sources[name] = klass self.families[name] = self.family + if name not in self.properties: + self.properties[name] = {"position": 2, "orientation": 2, "observers": 2} + self.properties[name].update(self.properties_new) return klass From 28a86ecebcd618f588bec4a527c46b103f34c0dc Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Mon, 4 Jul 2022 23:58:34 +0200 Subject: [PATCH 174/207] merge get_BH_wrap functions into one file --- magpylib/_src/fields/__init__.py | 2 +- ...eld_wrap_BH_level2.py => field_wrap_BH.py} | 376 +++++++++++++++++- magpylib/_src/fields/field_wrap_BH_level1.py | 39 -- magpylib/_src/fields/field_wrap_BH_level3.py | 339 ---------------- magpylib/_src/obj_classes/class_BaseGetBH.py | 2 +- magpylib/_src/obj_classes/class_Collection.py | 2 +- magpylib/_src/obj_classes/class_Sensor.py | 2 +- tests/test_exceptions.py | 5 +- 8 files changed, 381 insertions(+), 386 deletions(-) rename magpylib/_src/fields/{field_wrap_BH_level2.py => field_wrap_BH.py} (53%) delete mode 100644 magpylib/_src/fields/field_wrap_BH_level1.py delete mode 100644 magpylib/_src/fields/field_wrap_BH_level3.py diff --git a/magpylib/_src/fields/__init__.py b/magpylib/_src/fields/__init__.py index 35a7b9332..91b9819b5 100644 --- a/magpylib/_src/fields/__init__.py +++ b/magpylib/_src/fields/__init__.py @@ -3,4 +3,4 @@ __all__ = ["getB", "getH"] # create interface to outside of package -from magpylib._src.fields.field_wrap_BH_level3 import getB, getH +from magpylib._src.fields.field_wrap_BH import getB, getH diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH.py similarity index 53% rename from magpylib/_src/fields/field_wrap_BH_level2.py rename to magpylib/_src/fields/field_wrap_BH.py index b4a5eebbb..985996793 100644 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -1,11 +1,11 @@ from itertools import product +from typing import Callable import numpy as np from scipy.spatial.transform import Rotation as R from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibInternalError -from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_observers @@ -70,6 +70,42 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: return kwargs +def getBH_level1( + *, + field_func: Callable, + field: str, + position: np.ndarray, + orientation: np.ndarray, + observers: np.ndarray, + **kwargs: dict, +) -> np.ndarray: + """Vectorized field computation + + - applies spatial transformations global CS <-> source CS + - selects the correct Bfield_XXX function from input + + Args + ---- + kwargs: dict of shape (N,x) input vectors that describes the computation. + + Returns + ------- + field: ndarray, shape (N,3) + + """ + + # transform obs_pos into source CS + pos_rel_rot = orientation.apply(observers - position, inverse=True) + + # compute field + BH = field_func(field=field, observers=pos_rel_rot, **kwargs) + + # transform field back into global CS + BH = orientation.apply(BH) + + return BH + + def getBH_level2( sources, observers, *, field, sumup, squeeze, pixel_agg, output, **kwargs ) -> np.ndarray: @@ -420,3 +456,341 @@ def getBH_dict_level2( if squeeze: return np.squeeze(B) return B + + +def getB( + sources=None, + observers=None, + sumup=False, + squeeze=True, + pixel_agg=None, + output="ndarray", + **kwargs, +): + """Compute B-field in [mT] for given sources and observers. + + Field implementations can be directly accessed (avoiding the object oriented + Magpylib interface) by providing a string input `sources=source_type`, array_like + positions as `observers` input, and all other necessary input parameters (see below) + as kwargs. + + Parameters + ---------- + sources: source and collection objects or 1D list thereof + Sources that generate the magnetic field. Can be a single source (or collection) + or a 1D list of l source and/or collection objects. + + Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, + `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). + + observers: array_like or (list of) `Sensor` objects + Can be array_like positions of shape (n1, n2, ..., 3) where the field + should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list + of such sensor objects (must all have similar pixel shapes). All positions + are given in units of [mm]. + + Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding + positions to observer positions in units of [mm]. + + sumup: bool, default=`False` + If `True`, the fields of all sources are summed up. + + squeeze: bool, default=`True` + If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only + a single sensor or only a single source) are eliminated. + + pixel_agg: str, default=`None` + Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, + which is applied to observer output values, e.g. mean of all sensor pixel outputs. + With this option, observers input with different (pixel) shapes is allowed. + + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). + + Other Parameters (Direct interface) + ----------------------------------- + position: array_like, shape (3,) or (n,3), default=`(0,0,0)` + Source position(s) in the global coordinates in units of [mm]. + + orientation: scipy `Rotation` object with length 1 or n, default=`None` + Object orientation(s) in the global coordinates. `None` corresponds to + a unit-rotation. + + magnetization: array_like, shape (3,) or (n,3) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! + Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in + the local object coordinates (rotates with object). + + moment: array_like, shape (3) or (n,3), unit [mT*mm^3] + Only source_type == `'Dipole'`! + Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates + (rotates with object). For homogeneous magnets the relation moment=magnetization*volume + holds. + + current: array_like, shape (n,) + Only source_type == `'Loop'` or `'Line'`! + Electrical current in units of [A]. + + dimension: array_like, shape (x,) or (n,x) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! + Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar + as in object oriented interface. + + diameter: array_like, shape (n,) + Only source_type == `'Sphere'` or `'Loop'`! + Diameter of source in units of [mm]. + + segment_start: array_like, shape (n,3) + Only source_type == `'Line'`! + Start positions of line current segments in units of [mm]. + + segment_end: array_like, shape (n,3) + Only source_type == `'Line'`! + End positions of line current segments in units of [mm]. + + Returns + ------- + B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame + B-field at each path position (m) for each sensor (k) and each sensor pixel + position (n1, n2, ...) in units of [mT]. Sensor pixel positions are equivalent + to simple observer positions. Paths of objects that are shorter than m will be + considered as static beyond their end. + + Direct interface: ndarray, shape (n,3) + B-field for every parameter set in units of [mT]. + + Notes + ----- + This function automatically joins all sensor and position inputs together and groups + similar sources for optimal vectorization of the computation. For maximal performance + call this function as little as possible and avoid using it in loops. + + Examples + -------- + In this example we compute the B-field [mT] of a spherical magnet and a current loop + at the observer position (1,1,1) given in units of [mm]: + + >>> import magpylib as magpy + >>> src1 = magpy.current.Loop(current=100, diameter=2) + >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) + >>> B = magpy.getB([src1, src2], (1,1,1)) + >>> print(B) + [[6.23597388e+00 6.23597388e+00 2.66977810e+00] + [8.01875374e-01 8.01875374e-01 1.48029737e-16]] + + We can also use sensor objects as observers input: + + >>> sens1 = magpy.Sensor(position=(1,1,1)) + >>> sens2 = sens1.copy(position=(1,1,-1)) + >>> B = magpy.getB([src1, src2], [sens1, sens2]) + >>> print(B) + [[[ 6.23597388e+00 6.23597388e+00 2.66977810e+00] + [-6.23597388e+00 -6.23597388e+00 2.66977810e+00]] + + [[ 8.01875374e-01 8.01875374e-01 1.48029737e-16] + [-8.01875374e-01 -8.01875374e-01 1.48029737e-16]]] + + Through the direct interface we can compute the same fields for the loop as: + + >>> obs = [(1,1,1), (1,1,-1)] + >>> B = magpy.getB('Loop', obs, current=100, diameter=2) + >>> print(B) + [[ 6.23597388 6.23597388 2.6697781 ] + [-6.23597388 -6.23597388 2.6697781 ]] + + But also for a set of four completely different instances: + + >>> B = magpy.getB( + ... 'Loop', + ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), + ... current=(11, 22, 33, 44), + ... diameter=(1, 2, 3, 4), + ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), + ... ) + >>> print(B) + [[ 0.17111325 0.17111325 0.01705189] + [-0.38852048 -0.38852048 0.49400758] + [ 1.14713551 2.29427102 -0.22065346] + [-2.48213467 -2.48213467 -0.79683487]] + """ + return getBH_level2( + sources, + observers, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="B", + **kwargs, + ) + + +def getH( + sources=None, + observers=None, + sumup=False, + squeeze=True, + pixel_agg=None, + output="ndarray", + **kwargs, +): + """Compute H-field in [kA/m] for given sources and observers. + + Field implementations can be directly accessed (avoiding the object oriented + Magpylib interface) by providing a string input `sources=source_type`, array_like + positions as `observers` input, and all other necessary input parameters (see below) + as kwargs. + + Parameters + ---------- + sources: source and collection objects or 1D list thereof + Sources that generate the magnetic field. Can be a single source (or collection) + or a 1D list of l source and/or collection objects. + + Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, + `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). + + observers: array_like or (list of) `Sensor` objects + Can be array_like positions of shape (n1, n2, ..., 3) where the field + should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list + of such sensor objects (must all have similar pixel shapes). All positions + are given in units of [mm]. + + Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding + positions to observer positions in units of [mm]. + + sumup: bool, default=`False` + If `True`, the fields of all sources are summed up. + + squeeze: bool, default=`True` + If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only + a single sensor or only a single source) are eliminated. + + pixel_agg: str, default=`None` + Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, + which is applied to observer output values, e.g. mean of all sensor pixel outputs. + With this option, observer inputs with different (pixel) shapes are allowed. + + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). + + Other Parameters (Direct interface) + ----------------------------------- + position: array_like, shape (3,) or (n,3), default=`(0,0,0)` + Source position(s) in the global coordinates in units of [mm]. + + orientation: scipy `Rotation` object with length 1 or n, default=`None` + Object orientation(s) in the global coordinates. `None` corresponds to + a unit-rotation. + + magnetization: array_like, shape (3,) or (n,3) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! + Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in + the local object coordinates (rotates with object). + + moment: array_like, shape (3) or (n,3), unit [mT*mm^3] + Only source_type == `'Dipole'`! + Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates + (rotates with object). For homogeneous magnets the relation moment=magnetization*volume + holds. + + current: array_like, shape (n,) + Only source_type == `'Loop'` or `'Line'`! + Electrical current in units of [A]. + + dimension: array_like, shape (x,) or (n,x) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! + Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar + as in object oriented interface. + + diameter: array_like, shape (n,) + Only source_type == `'Sphere'` or `'Loop'`! + Diameter of source in units of [mm]. + + segment_start: array_like, shape (n,3) + Only source_type == `'Line'`! + Start positions of line current segments in units of [mm]. + + segment_end: array_like, shape (n,3) + Only source_type == `'Line'`! + End positions of line current segments in units of [mm]. + + Returns + ------- + H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame + H-field at each path position (m) for each sensor (k) and each sensor pixel + position (n1, n2, ...) in units of [kA/m]. Sensor pixel positions are equivalent + to simple observer positions. Paths of objects that are shorter than m will be + considered as static beyond their end. + + Direct interface: ndarray, shape (n,3) + H-field for every parameter set in units of [kA/m]. + + Notes + ----- + This function automatically joins all sensor and position inputs together and groups + similar sources for optimal vectorization of the computation. For maximal performance + call this function as little as possible and avoid using it in loops. + + Examples + -------- + In this example we compute the H-field [kA/m] of a spherical magnet and a current loop + at the observer position (1,1,1) given in units of [mm]: + + >>> import magpylib as magpy + >>> src1 = magpy.current.Loop(current=100, diameter=2) + >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) + >>> H = magpy.getH([src1, src2], (1,1,1)) + >>> print(H) + [[4.96243034e+00 4.96243034e+00 2.12454191e+00] + [6.38112147e-01 6.38112147e-01 1.17798322e-16]] + + We can also use sensor objects as observers input: + + >>> sens1 = magpy.Sensor(position=(1,1,1)) + >>> sens2 = sens1.copy(position=(1,1,-1)) + >>> H = magpy.getH([src1, src2], [sens1, sens2]) + >>> print(H) + [[[ 4.96243034e+00 4.96243034e+00 2.12454191e+00] + [-4.96243034e+00 -4.96243034e+00 2.12454191e+00]] + + [[ 6.38112147e-01 6.38112147e-01 1.17798322e-16] + [-6.38112147e-01 -6.38112147e-01 1.17798322e-16]]] + + Through the direct interface we can compute the same fields for the loop as: + + >>> obs = [(1,1,1), (1,1,-1)] + >>> H = magpy.getH('Loop', obs, current=100, diameter=2) + >>> print(H) + [[ 4.96243034 4.96243034 2.12454191] + [-4.96243034 -4.96243034 2.12454191]] + + But also for a set of four completely different instances: + + >>> H = magpy.getH( + ... 'Loop', + ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), + ... current=(11, 22, 33, 44), + ... diameter=(1, 2, 3, 4), + ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), + ... ) + >>> print(H) + [[ 0.1361676 0.1361676 0.01356947] + [-0.30917477 -0.30917477 0.39311875] + [ 0.91286143 1.82572286 -0.17559045] + [-1.97522001 -1.97522001 -0.63410104]] + """ + return getBH_level2( + sources, + observers, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="H", + **kwargs, + ) diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py deleted file mode 100644 index 89fabb0ac..000000000 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ /dev/null @@ -1,39 +0,0 @@ -from typing import Callable - -import numpy as np - - -def getBH_level1( - *, - field_func: Callable, - field: str, - position: np.ndarray, - orientation: np.ndarray, - observers: np.ndarray, - **kwargs: dict, -) -> np.ndarray: - """Vectorized field computation - - - applies spatial transformations global CS <-> source CS - - selects the correct Bfield_XXX function from input - - Args - ---- - kwargs: dict of shape (N,x) input vectors that describes the computation. - - Returns - ------- - field: ndarray, shape (N,3) - - """ - - # transform obs_pos into source CS - pos_rel_rot = orientation.apply(observers - position, inverse=True) - - # compute field - BH = field_func(field=field, observers=pos_rel_rot, **kwargs) - - # transform field back into global CS - BH = orientation.apply(BH) - - return BH diff --git a/magpylib/_src/fields/field_wrap_BH_level3.py b/magpylib/_src/fields/field_wrap_BH_level3.py deleted file mode 100644 index 3fe29b959..000000000 --- a/magpylib/_src/fields/field_wrap_BH_level3.py +++ /dev/null @@ -1,339 +0,0 @@ -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 - - -def getB( - sources=None, - observers=None, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - **kwargs -): - """Compute B-field in [mT] for given sources and observers. - - Field implementations can be directly accessed (avoiding the object oriented - Magpylib interface) by providing a string input `sources=source_type`, array_like - positions as `observers` input, and all other necessary input parameters (see below) - as kwargs. - - Parameters - ---------- - sources: source and collection objects or 1D list thereof - Sources that generate the magnetic field. Can be a single source (or collection) - or a 1D list of l source and/or collection objects. - - Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, - `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). - - observers: array_like or (list of) `Sensor` objects - Can be array_like positions of shape (n1, n2, ..., 3) where the field - should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list - of such sensor objects (must all have similar pixel shapes). All positions - are given in units of [mm]. - - Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding - positions to observer positions in units of [mm]. - - sumup: bool, default=`False` - If `True`, the fields of all sources are summed up. - - squeeze: bool, default=`True` - If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only - a single sensor or only a single source) are eliminated. - - pixel_agg: str, default=`None` - Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, - which is applied to observer output values, e.g. mean of all sensor pixel outputs. - With this option, observers input with different (pixel) shapes is allowed. - - output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a - `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` - object is returned (the Pandas library must be installed). - - Other Parameters (Direct interface) - ----------------------------------- - position: array_like, shape (3,) or (n,3), default=`(0,0,0)` - Source position(s) in the global coordinates in units of [mm]. - - orientation: scipy `Rotation` object with length 1 or n, default=`None` - Object orientation(s) in the global coordinates. `None` corresponds to - a unit-rotation. - - magnetization: array_like, shape (3,) or (n,3) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! - Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in - the local object coordinates (rotates with object). - - moment: array_like, shape (3) or (n,3), unit [mT*mm^3] - Only source_type == `'Dipole'`! - Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates - (rotates with object). For homogeneous magnets the relation moment=magnetization*volume - holds. - - current: array_like, shape (n,) - Only source_type == `'Loop'` or `'Line'`! - Electrical current in units of [A]. - - dimension: array_like, shape (x,) or (n,x) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! - Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar - as in object oriented interface. - - diameter: array_like, shape (n,) - Only source_type == `'Sphere'` or `'Loop'`! - Diameter of source in units of [mm]. - - segment_start: array_like, shape (n,3) - Only source_type == `'Line'`! - Start positions of line current segments in units of [mm]. - - segment_end: array_like, shape (n,3) - Only source_type == `'Line'`! - End positions of line current segments in units of [mm]. - - Returns - ------- - B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame - B-field at each path position (m) for each sensor (k) and each sensor pixel - position (n1, n2, ...) in units of [mT]. Sensor pixel positions are equivalent - to simple observer positions. Paths of objects that are shorter than m will be - considered as static beyond their end. - - Direct interface: ndarray, shape (n,3) - B-field for every parameter set in units of [mT]. - - Notes - ----- - This function automatically joins all sensor and position inputs together and groups - similar sources for optimal vectorization of the computation. For maximal performance - call this function as little as possible and avoid using it in loops. - - Examples - -------- - In this example we compute the B-field [mT] of a spherical magnet and a current loop - at the observer position (1,1,1) given in units of [mm]: - - >>> import magpylib as magpy - >>> src1 = magpy.current.Loop(current=100, diameter=2) - >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) - >>> B = magpy.getB([src1, src2], (1,1,1)) - >>> print(B) - [[6.23597388e+00 6.23597388e+00 2.66977810e+00] - [8.01875374e-01 8.01875374e-01 1.48029737e-16]] - - We can also use sensor objects as observers input: - - >>> sens1 = magpy.Sensor(position=(1,1,1)) - >>> sens2 = sens1.copy(position=(1,1,-1)) - >>> B = magpy.getB([src1, src2], [sens1, sens2]) - >>> print(B) - [[[ 6.23597388e+00 6.23597388e+00 2.66977810e+00] - [-6.23597388e+00 -6.23597388e+00 2.66977810e+00]] - - [[ 8.01875374e-01 8.01875374e-01 1.48029737e-16] - [-8.01875374e-01 -8.01875374e-01 1.48029737e-16]]] - - Through the direct interface we can compute the same fields for the loop as: - - >>> obs = [(1,1,1), (1,1,-1)] - >>> B = magpy.getB('Loop', obs, current=100, diameter=2) - >>> print(B) - [[ 6.23597388 6.23597388 2.6697781 ] - [-6.23597388 -6.23597388 2.6697781 ]] - - But also for a set of four completely different instances: - - >>> B = magpy.getB( - ... 'Loop', - ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), - ... current=(11, 22, 33, 44), - ... diameter=(1, 2, 3, 4), - ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), - ... ) - >>> print(B) - [[ 0.17111325 0.17111325 0.01705189] - [-0.38852048 -0.38852048 0.49400758] - [ 1.14713551 2.29427102 -0.22065346] - [-2.48213467 -2.48213467 -0.79683487]] - """ - return getBH_level2( - sources, - observers, - sumup=sumup, - squeeze=squeeze, - pixel_agg=pixel_agg, - output=output, - field="B", - **kwargs - ) - - -def getH( - sources=None, - observers=None, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - **kwargs -): - """Compute H-field in [kA/m] for given sources and observers. - - Field implementations can be directly accessed (avoiding the object oriented - Magpylib interface) by providing a string input `sources=source_type`, array_like - positions as `observers` input, and all other necessary input parameters (see below) - as kwargs. - - Parameters - ---------- - sources: source and collection objects or 1D list thereof - Sources that generate the magnetic field. Can be a single source (or collection) - or a 1D list of l source and/or collection objects. - - Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, - `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). - - observers: array_like or (list of) `Sensor` objects - Can be array_like positions of shape (n1, n2, ..., 3) where the field - should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list - of such sensor objects (must all have similar pixel shapes). All positions - are given in units of [mm]. - - Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding - positions to observer positions in units of [mm]. - - sumup: bool, default=`False` - If `True`, the fields of all sources are summed up. - - squeeze: bool, default=`True` - If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only - a single sensor or only a single source) are eliminated. - - pixel_agg: str, default=`None` - Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, - which is applied to observer output values, e.g. mean of all sensor pixel outputs. - With this option, observer inputs with different (pixel) shapes are allowed. - - output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a - `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` - object is returned (the Pandas library must be installed). - - Other Parameters (Direct interface) - ----------------------------------- - position: array_like, shape (3,) or (n,3), default=`(0,0,0)` - Source position(s) in the global coordinates in units of [mm]. - - orientation: scipy `Rotation` object with length 1 or n, default=`None` - Object orientation(s) in the global coordinates. `None` corresponds to - a unit-rotation. - - magnetization: array_like, shape (3,) or (n,3) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! - Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in - the local object coordinates (rotates with object). - - moment: array_like, shape (3) or (n,3), unit [mT*mm^3] - Only source_type == `'Dipole'`! - Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates - (rotates with object). For homogeneous magnets the relation moment=magnetization*volume - holds. - - current: array_like, shape (n,) - Only source_type == `'Loop'` or `'Line'`! - Electrical current in units of [A]. - - dimension: array_like, shape (x,) or (n,x) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! - Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar - as in object oriented interface. - - diameter: array_like, shape (n,) - Only source_type == `'Sphere'` or `'Loop'`! - Diameter of source in units of [mm]. - - segment_start: array_like, shape (n,3) - Only source_type == `'Line'`! - Start positions of line current segments in units of [mm]. - - segment_end: array_like, shape (n,3) - Only source_type == `'Line'`! - End positions of line current segments in units of [mm]. - - Returns - ------- - H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame - H-field at each path position (m) for each sensor (k) and each sensor pixel - position (n1, n2, ...) in units of [kA/m]. Sensor pixel positions are equivalent - to simple observer positions. Paths of objects that are shorter than m will be - considered as static beyond their end. - - Direct interface: ndarray, shape (n,3) - H-field for every parameter set in units of [kA/m]. - - Notes - ----- - This function automatically joins all sensor and position inputs together and groups - similar sources for optimal vectorization of the computation. For maximal performance - call this function as little as possible and avoid using it in loops. - - Examples - -------- - In this example we compute the H-field [kA/m] of a spherical magnet and a current loop - at the observer position (1,1,1) given in units of [mm]: - - >>> import magpylib as magpy - >>> src1 = magpy.current.Loop(current=100, diameter=2) - >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) - >>> H = magpy.getH([src1, src2], (1,1,1)) - >>> print(H) - [[4.96243034e+00 4.96243034e+00 2.12454191e+00] - [6.38112147e-01 6.38112147e-01 1.17798322e-16]] - - We can also use sensor objects as observers input: - - >>> sens1 = magpy.Sensor(position=(1,1,1)) - >>> sens2 = sens1.copy(position=(1,1,-1)) - >>> H = magpy.getH([src1, src2], [sens1, sens2]) - >>> print(H) - [[[ 4.96243034e+00 4.96243034e+00 2.12454191e+00] - [-4.96243034e+00 -4.96243034e+00 2.12454191e+00]] - - [[ 6.38112147e-01 6.38112147e-01 1.17798322e-16] - [-6.38112147e-01 -6.38112147e-01 1.17798322e-16]]] - - Through the direct interface we can compute the same fields for the loop as: - - >>> obs = [(1,1,1), (1,1,-1)] - >>> H = magpy.getH('Loop', obs, current=100, diameter=2) - >>> print(H) - [[ 4.96243034 4.96243034 2.12454191] - [-4.96243034 -4.96243034 2.12454191]] - - But also for a set of four completely different instances: - - >>> H = magpy.getH( - ... 'Loop', - ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), - ... current=(11, 22, 33, 44), - ... diameter=(1, 2, 3, 4), - ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), - ... ) - >>> print(H) - [[ 0.1361676 0.1361676 0.01356947] - [-0.30917477 -0.30917477 0.39311875] - [ 0.91286143 1.82572286 -0.17559045] - [-1.97522001 -1.97522001 -0.63410104]] - """ - return getBH_level2( - sources, - observers, - sumup=sumup, - squeeze=squeeze, - pixel_agg=pixel_agg, - output=output, - field="H", - **kwargs - ) diff --git a/magpylib/_src/obj_classes/class_BaseGetBH.py b/magpylib/_src/obj_classes/class_BaseGetBH.py index 260758fe2..3be60f0b5 100644 --- a/magpylib/_src/obj_classes/class_BaseGetBH.py +++ b/magpylib/_src/obj_classes/class_BaseGetBH.py @@ -1,7 +1,7 @@ """BaseGetBHsimple class code DOCSTRINGS V4 READY """ -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.utility import format_star_input diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 5571af50a..18c334f08 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -4,7 +4,7 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_obj from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 8a8065286..b0e8dca26 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -1,7 +1,7 @@ """Sensor class code DOCSTRINGS V4 READY """ -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index ea7b65d24..91ddac72d 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -6,8 +6,7 @@ import magpylib as magpy from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibInternalError -from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_observers from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs @@ -60,7 +59,7 @@ def getBH_level2_internal_error1(): # pylint: disable=protected-access sens = magpy.Sensor() x = np.zeros((10, 3)) - magpy._src.fields.field_wrap_BH_level2.get_src_dict([sens], 10, 10, x) + magpy._src.fields.field_wrap_BH.get_src_dict([sens], 10, 10, x) # getBHv missing inputs ------------------------------------------------------ From d4ed868dc45b5234154a269915db55298985c374 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 5 Jul 2022 10:57:36 +0200 Subject: [PATCH 175/207] pylint --- magpylib/_src/style.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 36e47632f..8261eb187 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -46,7 +46,7 @@ def get_style(obj, default_settings, **kwargs): obj_style_default_dict = { **styles_by_family["base"], - **{k: v for k, v in styles_by_family.get(obj_family, {}).items()}, + **dict(styles_by_family.get(obj_family, {}).items()), } style_kwargs = validate_style_keys(style_kwargs) # create style class instance and update based on precedence From 85e0cbbcc498b0f4abfe40cda9352de33ecf8290 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 5 Jul 2022 15:39:39 +0200 Subject: [PATCH 176/207] update backend docstrings --- magpylib/__init__.py | 1 + magpylib/_src/defaults/defaults_classes.py | 6 ++++-- magpylib/_src/display/traces_base.py | 18 ++++++++++++------ magpylib/_src/style.py | 4 ++-- tests/test_defaults.py | 4 +++- 5 files changed, 22 insertions(+), 11 deletions(-) diff --git a/magpylib/__init__.py b/magpylib/__init__.py index 69d9ce4ea..18aee2dc1 100644 --- a/magpylib/__init__.py +++ b/magpylib/__init__.py @@ -49,6 +49,7 @@ ] # create interface to outside of package +from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib import magnet, current, misc, core, graphics from magpylib._src.defaults.defaults_classes import default_settings as defaults from magpylib._src.fields import getB, getH diff --git a/magpylib/_src/defaults/defaults_classes.py b/magpylib/_src/defaults/defaults_classes.py index 6730d90d8..478d77901 100644 --- a/magpylib/_src/defaults/defaults_classes.py +++ b/magpylib/_src/defaults/defaults_classes.py @@ -50,7 +50,8 @@ class Display(MagicProperties): ---------- backend: str, default='matplotlib' Defines the plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly']` + function (e.g. 'matplotlib', 'plotly'). + Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS colorsequence: iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', @@ -80,7 +81,8 @@ class Display(MagicProperties): @property def backend(self): """plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly']`""" + function (e.g. 'matplotlib', 'plotly'). + Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS""" return self._backend @backend.setter diff --git a/magpylib/_src/display/traces_base.py b/magpylib/_src/display/traces_base.py index a6e6f94fa..44fed8bc9 100644 --- a/magpylib/_src/display/traces_base.py +++ b/magpylib/_src/display/traces_base.py @@ -54,7 +54,8 @@ def make_Cuboid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. dimension : 3-tuple, default=(1,1,1) Length of the cuboid sides `x,y,z`. @@ -114,7 +115,8 @@ def make_Prism( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. base : int, default=6 Number of vertices of the base in the xy-plane. @@ -202,7 +204,8 @@ def make_Ellipsoid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. dimension : tuple, default=(1.0, 1.0, 1.0) Dimension in the `x,y,z` directions. @@ -282,7 +285,8 @@ def make_CylinderSegment( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. dimension: array_like, shape (5,), default=`None` Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2) @@ -383,7 +387,8 @@ def make_Pyramid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. base : int, default=30 Number of vertices of the cone base. @@ -463,7 +468,8 @@ def make_Arrow( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. base : int, default=30 Number of vertices of the arrow base. diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 6ea7e6afc..6a46198a2 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -336,7 +336,7 @@ class Trace3d(MagicProperties): ---------- backend: str Plotting backend corresponding to the trace. Can be one of - `['matplotlib', 'plotly']`. + `['generic', 'matplotlib', 'plotly']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -490,7 +490,7 @@ def coordsargs(self, val): @property def backend(self): """Plotting backend corresponding to the trace. Can be one of - `['matplotlib', 'plotly']`.""" + `['generic', 'matplotlib', 'plotly']`.""" return self._backend @backend.setter diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 84d88529c..f2f7c96da 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -3,9 +3,11 @@ import magpylib as magpy from magpylib._src.defaults.defaults_classes import DefaultConfig from magpylib._src.defaults.defaults_utility import LINESTYLES_MATPLOTLIB_TO_PLOTLY +from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.defaults.defaults_utility import SYMBOLS_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import DisplayStyle + bad_inputs = { "display_autosizefactor": (0,), # float>0 "display_animation_maxfps": (0,), # int>0 @@ -98,7 +100,7 @@ def test_defaults_bad_inputs(key, value, expected_errortype): "display_animation_time": (10,), # int>0 "display_animation_maxframes": (200,), # int>0 "display_animation_slider": (True, False), # bool - "display_backend": ("matplotlib", "plotly"), # str typo + "display_backend": tuple(SUPPORTED_PLOTTING_BACKENDS), # str typo "display_colorsequence": ( ["#2E91E5", "#0D2A63"], ["blue", "red"], From 9be2655f41b4978d08f6a34bac3321193f2cc221 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 6 Jul 2022 13:14:56 +0200 Subject: [PATCH 177/207] register kind --- magpylib/_src/display/display_utility.py | 4 +- magpylib/_src/fields/field_wrap_BH.py | 6 +- magpylib/_src/obj_classes/class_Sensor.py | 3 +- .../_src/obj_classes/class_current_Line.py | 8 ++- .../_src/obj_classes/class_current_Loop.py | 3 +- .../_src/obj_classes/class_magnet_Cuboid.py | 3 +- .../_src/obj_classes/class_magnet_Cylinder.py | 3 +- .../class_magnet_CylinderSegment.py | 3 +- .../_src/obj_classes/class_magnet_Sphere.py | 3 +- .../obj_classes/class_misc_CustomSource.py | 2 +- .../_src/obj_classes/class_misc_Dipole.py | 7 +- magpylib/_src/style.py | 2 +- magpylib/_src/utility.py | 67 +++++++++++-------- tests/test_obj_BaseGeo.py | 4 ++ 14 files changed, 74 insertions(+), 44 deletions(-) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index 6b26219eb..8804f5763 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -7,13 +7,13 @@ from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.style import Markers +from magpylib._src.utility import Registered +@Registered(kind="nonmodel", family="markers") class MagpyMarkers: """A class that stores markers 3D-coordinates""" - _object_type = "Marker" - def __init__(self, *markers): self.style = Markers() self.markers = np.array(markers) diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index 985996793..82bef194a 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -55,7 +55,7 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: } try: - src_props = Registered.properties[src_type] + src_props = Registered.source_kwargs_ndim[src_type] except KeyError as err: raise MagpylibInternalError("Bad source_type in get_src_dict") from err @@ -424,7 +424,7 @@ def getBH_dict_level2( raise MagpylibBadUserInput( f"{key} input must be array-like.\n" f"Instead received {val}" ) from err - tdim = Registered.properties[source_type].get(key, 1) + tdim = Registered.source_kwargs_ndim[source_type].get(key, 1) if val.ndim == tdim: vec_lengths.append(len(val)) kwargs[key] = val @@ -438,7 +438,7 @@ def getBH_dict_level2( # tile 1D inputs and replace original values in kwargs for key, val in kwargs.items(): - tdim = Registered.properties[source_type].get(key, 1) + tdim = Registered.source_kwargs_ndim[source_type].get(key, 1) if val.ndim < tdim: if tdim == 2: kwargs[key] = np.tile(val, (vec_len, 1)) diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index b0e8dca26..0e02defd1 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -6,8 +6,10 @@ from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.utility import format_star_input +from magpylib._src.utility import Registered +@Registered(kind="sensor", family="sensor") class Sensor(BaseGeo, BaseDisplayRepr): """Magnetic field sensor. @@ -85,7 +87,6 @@ def __init__( # instance attributes self.pixel = pixel - self._object_type = "Sensor" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index ea90c1a6f..d572eced9 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -11,9 +11,15 @@ @Registered( + kind="source", family="current", field_func=current_vertices_field, - properties={"current": 1, "vertices": 2, "segment_start": 2, "segment_end": 2}, + source_kwargs_ndim={ + "current": 1, + "vertices": 2, + "segment_start": 2, + "segment_end": 2, + }, ) class Line(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Current flowing in straight lines from vertex to vertex. diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index 1ccdb83bf..ca04b0818 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -11,9 +11,10 @@ @Registered( + kind="source", family="current", field_func=current_loop_field, - properties={"current": 1, "diameter": 1}, + source_kwargs_ndim={"current": 1, "diameter": 1}, ) class Loop(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Circular current loop. diff --git a/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py index e54e451bc..35d4b8937 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -11,9 +11,10 @@ @Registered( + kind="source", family="magnet", field_func=magnet_cuboid_field, - properties={"magnetization": 2, "dimension": 2}, + source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) class Cuboid(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cuboid magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py index 163471bec..47c268aec 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -11,9 +11,10 @@ @Registered( + kind="source", family="magnet", field_func=magnet_cylinder_field, - properties={"magnetization": 2, "dimension": 2}, + source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) class Cylinder(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index fe5e26213..d0df39f56 100644 --- a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -15,9 +15,10 @@ @Registered( + kind="source", family="magnet", field_func=magnet_cylinder_segment_field_internal, - properties={"magnetization": 2, "dimension": 2}, + source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) class CylinderSegment(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder segment (ring-section) magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_magnet_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py index 58712718e..47a2a5a5f 100644 --- a/magpylib/_src/obj_classes/class_magnet_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -11,9 +11,10 @@ @Registered( + kind="source", family="magnet", field_func=magnet_sphere_field, - properties={"magnetization": 2, "diameter": 1}, + source_kwargs_ndim={"magnetization": 2, "diameter": 1}, ) class Sphere(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Spherical magnet with homogeneous magnetization. diff --git a/magpylib/_src/obj_classes/class_misc_CustomSource.py b/magpylib/_src/obj_classes/class_misc_CustomSource.py index 4a5da540e..89bd3c875 100644 --- a/magpylib/_src/obj_classes/class_misc_CustomSource.py +++ b/magpylib/_src/obj_classes/class_misc_CustomSource.py @@ -6,7 +6,7 @@ from magpylib._src.utility import Registered -@Registered(family="misc", field_func=None) +@Registered(kind="source", family="misc", field_func=None) class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): """User-defined custom source. diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index e38d4cfa5..5101b08cf 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -9,7 +9,12 @@ from magpylib._src.utility import Registered -@Registered(family="dipole", field_func=dipole_field, properties={"moment": 2}) +@Registered( + kind="source", + family="dipole", + field_func=dipole_field, + source_kwargs_ndim={"moment": 2}, +) class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH): """Magnetic dipole moment. diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 8261eb187..412d08f32 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -43,12 +43,12 @@ def get_style(obj, default_settings, **kwargs): # construct object specific dictionary base on style family and default style obj_type = getattr(obj, "_object_type", None) obj_family = Registered.families.get(obj_type, None) - obj_style_default_dict = { **styles_by_family["base"], **dict(styles_by_family.get(obj_family, {}).items()), } style_kwargs = validate_style_keys(style_kwargs) + # create style class instance and update based on precedence obj_style = getattr(obj, "style", None) style = obj_style.copy() if obj_style is not None else BaseStyle() diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 51594c659..a77ec4cc6 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -9,51 +9,60 @@ class Registered: - """Class decorator to register source class into LIBRARY_SOURCES - and to update field function of source class""" + """Class decorator to register sources or sensors + - Sources get their field function assigned""" - sensors = {"Sensor": None} + sensors = {} sources = {} - families = { - "Sensor": "sensor", - "Marker": "markers", - } - properties = {} + families = {} + source_kwargs_ndim = {} - def __init__(self, *, family, field_func, properties=None): + def __init__(self, *, kind, family, field_func=None, source_kwargs_ndim=None): + self.kind = kind self.family = family self.field_func = field_func - self.properties_new = {} if properties is None else properties + self.source_kwargs_ndim_new = ( + {} if source_kwargs_ndim is None else source_kwargs_ndim + ) def __call__(self, klass): - if self.field_func is None: - setattr(klass, "_field_func", None) - else: - setattr(klass, "_field_func", staticmethod(self.field_func)) - setattr( - klass, - "field_func", - property( - lambda self: getattr(self, "_field_func"), - doc="""The core function for B- and H-field computation""", - ), - ) + name = klass.__name__ + setattr(klass, "_object_type", name) setattr(klass, "_family", self.family) setattr( klass, "family", property( lambda self: getattr(self, "_family"), - doc="""The source family (e.g. 'magnet', 'current', 'misc')""", + doc="""The object family (e.g. 'magnet', 'current', 'misc')""", ), ) - name = klass.__name__ - setattr(klass, "_object_type", name) - self.sources[name] = klass self.families[name] = self.family - if name not in self.properties: - self.properties[name] = {"position": 2, "orientation": 2, "observers": 2} - self.properties[name].update(self.properties_new) + + if self.kind == "sensor": + self.sensors[name] = klass + + elif self.kind == "source": + if self.field_func is None: + setattr(klass, "_field_func", None) + else: + setattr(klass, "_field_func", staticmethod(self.field_func)) + setattr( + klass, + "field_func", + property( + lambda self: getattr(self, "_field_func"), + doc="""The core function for B- and H-field computation""", + ), + ) + self.sources[name] = klass + if name not in self.source_kwargs_ndim: + self.source_kwargs_ndim[name] = { + "position": 2, + "orientation": 2, + "observers": 2, + } + self.source_kwargs_ndim[name].update(self.source_kwargs_ndim_new) return klass diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 697df334d..40b75206a 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -457,6 +457,7 @@ def test_describe(): " • path length: 3", " • position (last): [1. 2. 3.] mm", " • orientation (last): [0. 0. 0.] degrees", + " • family: sensor ", # INVISIBLE SPACE " • pixel: 15 ", # INVISIBLE SPACE ] desc = s1.describe(return_string=True) @@ -471,6 +472,7 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" + + " • family: sensor \n" + " • pixel: 1 \n" + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," @@ -491,6 +493,7 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" + + " • family: sensor \n" + " • pixel: 1 \n" + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," @@ -511,6 +514,7 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" + + " • family: sensor \n" + " • pixel: 75 (3x5x5) " ) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) From ac00e6540a6b91035b8c6c7b0eadbf2180bbea24 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Wed, 6 Jul 2022 15:19:03 +0200 Subject: [PATCH 178/207] add matplotlib numbering --- magpylib/_src/display/backend_matplotlib.py | 52 ++++++++++++--------- 1 file changed, 31 insertions(+), 21 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index 38729a3b5..c56222412 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -30,16 +30,17 @@ def generic_trace_to_matplotlib(trace): for subtrace in subtraces: x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) triangles = np.array([subtrace[k] for k in "ijk"]).T - trace_mpl = { - "constructor": "plot_trisurf", - "args": (x, y, z), - "kwargs": { - "triangles": triangles, - "alpha": subtrace.get("opacity", None), - "color": subtrace.get("color", None), - }, - } - traces_mpl.append(trace_mpl) + traces_mpl.append( + { + "constructor": "plot_trisurf", + "args": (x, y, z), + "kwargs": { + "triangles": triangles, + "alpha": subtrace.get("opacity", None), + "color": subtrace.get("color", None), + }, + } + ) elif trace["type"] == "scatter3d": x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) mode = trace.get("mode", None) @@ -64,15 +65,24 @@ def generic_trace_to_matplotlib(trace): props["ls"] = "" if "markers" not in mode: props["marker"] = None - trace_mpl = { - "constructor": "plot", - "args": (x, y, z), - "kwargs": { - **{k: v for k, v in props.items() if v is not None}, - "alpha": trace.get("opacity", 1), - }, - } - traces_mpl.append(trace_mpl) + if "text" in mode and trace.get("text", False): + for xs, ys, zs, txt in zip(x, y, z, trace["text"]): + traces_mpl.append( + { + "constructor": "text", + "args": (xs, ys, zs, txt), + } + ) + traces_mpl.append( + { + "constructor": "plot", + "args": (x, y, z), + "kwargs": { + **{k: v for k, v in props.items() if v is not None}, + "alpha": trace.get("opacity", 1), + }, + } + ) else: raise ValueError( f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" @@ -150,8 +160,8 @@ def display_matplotlib( def draw_frame(ind): for tr in frames[ind]["data"]: constructor = tr["constructor"] - args = tr["args"] - kwargs = tr["kwargs"] + args = tr.get("args", ()) + kwargs = tr.get("kwargs", {}) getattr(ax, constructor)(*args, **kwargs) ax.set( **{f"{k}label": f"{k} [mm]" for k in "xyz"}, From 948c4b71c93d76abc8ccb015a09898d7ef73c965 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 8 Jul 2022 17:01:53 +0200 Subject: [PATCH 179/207] return_fig, update_layout and layout --- magpylib/_src/display/backend_matplotlib.py | 9 +++- magpylib/_src/display/backend_plotly.py | 49 +++++++++++++++------ magpylib/_src/display/display.py | 9 +++- 3 files changed, 52 insertions(+), 15 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index c56222412..ee672156d 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -122,6 +122,7 @@ def display_matplotlib( animation=False, repeat=False, colorsequence=None, + return_fig=False, return_animation=False, **kwargs, ): @@ -184,7 +185,13 @@ def animate(ind): blit=False, repeat=repeat, ) + out = () + if return_fig: + out += (fig,) if return_animation and len(frames) != 1: - return anim + out += (anim,) if show_canvas: plt.show() + + if out: + return out[0] if len(out) == 1 else out diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index a6e795afb..9558d55de 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -54,6 +54,7 @@ def animate_path( path_indices, frame_duration, animation_slider=False, + update_layout=True, ): """This is a helper function which attaches plotly frames to the provided `fig` object according to a certain zoom level. All three space direction will be equal and match the @@ -128,9 +129,12 @@ def animate_path( frame0 = fig.frames[0] fig.add_traces(frame0.data) title = frame0.layout.title.text + if update_layout: + fig.update_layout( + height=None, + title=title, + ) fig.update_layout( - height=None, - title=title, updatemenus=[buttons_dict], sliders=[sliders_dict] if animation_slider else None, ) @@ -181,6 +185,15 @@ def process_extra_trace(model): return trace3d +def extract_layout_kwargs(kwargs): + """Extract layout kwargs""" + layout = kwargs.pop("layout", {}) + layout_kwargs = {k[7:]: v for k, v in kwargs.items() if k.startswith("layout")} + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("layout")} + layout.update(layout_kwargs) + return layout, kwargs + + def display_plotly( *obj_list, zoom=1, @@ -188,20 +201,25 @@ def display_plotly( renderer=None, animation=False, colorsequence=None, + return_fig=False, + update_layout=True, **kwargs, ): """Display objects and paths graphically using the plotly library.""" - show_canvas = False + fig = canvas + show_fig = False extra_data = False - if canvas is None: - show_canvas = True - canvas = go.Figure() + if fig is None: + if not return_fig: + show_fig = True + fig = go.Figure() if colorsequence is None: colorsequence = Config.display.colorsequence + layout, kwargs = extract_layout_kwargs(kwargs) data = get_frames( objs=obj_list, colorsequence=colorsequence, @@ -220,22 +238,27 @@ def display_plotly( new_data.append(process_extra_trace(model)) fr["data"] = new_data fr.pop("extra_backend_traces", None) - with canvas.batch_update(): + with fig.batch_update(): if len(frames) == 1: - canvas.add_traces(frames[0]["data"]) + fig.add_traces(frames[0]["data"]) else: animation_slider = data.get("animation_slider", False) animate_path( - canvas, + fig, frames, data["path_indices"], data["frame_duration"], animation_slider=animation_slider, + update_layout=update_layout, ) ranges = data["ranges"] if extra_data: ranges = get_scene_ranges(*frames[0]["data"], zoom=zoom) - apply_fig_ranges(canvas, ranges) - canvas.update_layout(legend_itemsizing="constant") - if show_canvas: - canvas.show(renderer=renderer) + if update_layout: + apply_fig_ranges(fig, ranges) + fig.update_layout(legend_itemsizing="constant") + fig.update_layout(layout) + + if not show_fig: + return fig + fig.show(renderer=renderer) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 8d5ad2aca..04d8ce22b 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -19,6 +19,7 @@ def show( markers=None, backend=None, canvas=None, + return_fig=False, **kwargs, ): """Display objects and paths graphically. @@ -53,9 +54,14 @@ def show( - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. By default a new canvas is created and immediately displayed. + canvas: bool, default=False + If True, the function call returns the figure object. + - with matplotlib: `matplotlib.figure.Figure`. + - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. + Returns ------- - `None`: NoneType + `None` or figure object Examples -------- @@ -134,5 +140,6 @@ def show( zoom=zoom, canvas=canvas, animation=animation, + return_fig=return_fig, **kwargs, ) From 370c8c8757431ba5a733c319111a4d79dbd60a74 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 8 Jul 2022 17:28:22 +0200 Subject: [PATCH 180/207] fig show bug fix plotly --- magpylib/_src/display/backend_plotly.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index 9558d55de..ef1bfdd2d 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -259,6 +259,7 @@ def display_plotly( fig.update_layout(legend_itemsizing="constant") fig.update_layout(layout) - if not show_fig: + if return_fig and not show_fig: return fig - fig.show(renderer=renderer) + if show_fig: + fig.show(renderer=renderer) From a67f7ef62fab7d598f8c119c2b870ebe809f2aba Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 8 Jul 2022 17:45:19 +0200 Subject: [PATCH 181/207] Merge branch 'display-backend-rework' of https://github.com/magpylib/magpylib into refactor-getBH-02 --- magpylib/_src/display/backend_plotly.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index 9558d55de..ef1bfdd2d 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -259,6 +259,7 @@ def display_plotly( fig.update_layout(legend_itemsizing="constant") fig.update_layout(layout) - if not show_fig: + if return_fig and not show_fig: return fig - fig.show(renderer=renderer) + if show_fig: + fig.show(renderer=renderer) From 26cc7be1dc9dc7137c51b270682c5ec5fdb2527d Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 8 Jul 2022 18:48:21 +0200 Subject: [PATCH 182/207] Merge branch 'display-backend-rework' of https://github.com/magpylib/magpylib into refactor-getBH-02 --- docs/examples/examples_13_3d_models.md | 89 +- magpylib/__init__.py | 1 + magpylib/_src/defaults/defaults_classes.py | 6 +- magpylib/_src/defaults/defaults_utility.py | 2 +- magpylib/_src/display/backend_matplotlib.py | 197 +++ ...atplotlib.py => backend_matplotlib_old.py} | 315 ++++- magpylib/_src/display/backend_plotly.py | 265 ++++ magpylib/_src/display/display.py | 62 +- magpylib/_src/display/display_utility.py | 504 ------- magpylib/_src/display/plotly/__init__.py | 1 - .../_src/display/plotly/plotly_display.py | 1196 ----------------- .../_src/display/plotly/plotly_utility.py | 147 -- .../plotly_sensor_mesh.py => sensor_mesh.py} | 0 .../{base_traces.py => traces_base.py} | 34 +- magpylib/_src/display/traces_generic.py | 974 ++++++++++++++ magpylib/_src/display/traces_utility.py | 483 +++++++ magpylib/_src/input_checks.py | 7 +- magpylib/_src/style.py | 14 +- magpylib/graphics/model3d/__init__.py | 2 +- tests/test_Coumpound_setters.py | 2 +- tests/test_default_utils.py | 2 +- tests/test_defaults.py | 4 +- tests/test_display_matplotlib.py | 9 - tests/test_display_plotly.py | 34 +- tests/test_display_utility.py | 2 +- tests/test_getBH_interfaces.py | 2 +- 26 files changed, 2314 insertions(+), 2040 deletions(-) create mode 100644 magpylib/_src/display/backend_matplotlib.py rename magpylib/_src/display/{display_matplotlib.py => backend_matplotlib_old.py} (64%) create mode 100644 magpylib/_src/display/backend_plotly.py delete mode 100644 magpylib/_src/display/display_utility.py delete mode 100644 magpylib/_src/display/plotly/__init__.py delete mode 100644 magpylib/_src/display/plotly/plotly_display.py delete mode 100644 magpylib/_src/display/plotly/plotly_utility.py rename magpylib/_src/display/{plotly/plotly_sensor_mesh.py => sensor_mesh.py} (100%) rename magpylib/_src/display/{base_traces.py => traces_base.py} (96%) create mode 100644 magpylib/_src/display/traces_generic.py create mode 100644 magpylib/_src/display/traces_utility.py diff --git a/docs/examples/examples_13_3d_models.md b/docs/examples/examples_13_3d_models.md index f111317eb..39ee4664e 100644 --- a/docs/examples/examples_13_3d_models.md +++ b/docs/examples/examples_13_3d_models.md @@ -18,20 +18,20 @@ kernelspec: (examples-own-3d-models)= ## Custom 3D models -Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. +Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. When using the `'generic'` backend, custom traces are automatically translated into any other backend. If a specific backend is used, it will only show when called with the corresponding backend. The input `trace` is a dictionary which includes all necessary information for plotting or a `magpylib.graphics.Trace3d` object. A `trace` dictionary has the following keys: -1. `'backend'`: `'matplotlib'` or `'plotly'` +1. `'backend'`: `'generic'`, `'matplotlib'` or `'plotly'` 2. `'constructor'`: name of the plotting constructor from the respective backend, e.g. plotly `'Mesh3d'` or matplotlib `'plot_surface'` 3. `'args'`: default `None`, positional arguments handed to constructor 4. `'kwargs'`: default `None`, keyword arguments handed to constructor -5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the Plotly backend and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend. +5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the `'generic'` backend and Plotly backend, and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend. 6. `'show'`: default `True`, toggle if this trace should be displayed 7. `'scale'`: default 1, object geometric scaling factor 8. `'updatefunc'`: default `None`, updates the trace parameters when `show` is called. Used to generate dynamic traces. -The following example shows how a **Plotly** trace is constructed with `Mesh3d` and `Scatter3d`: +The following example shows how a **generic** trace is constructed with `Mesh3d` and `Scatter3d`: ```{code-cell} ipython3 import numpy as np @@ -40,7 +40,7 @@ import magpylib as magpy # Mesh3d trace ######################### trace_mesh3d = { - 'backend': 'plotly', + 'backend': 'generic', 'constructor': 'Mesh3d', 'kwargs': { 'x': (1, 0, -1, 0), @@ -59,7 +59,7 @@ coll.style.model3d.add_trace(trace_mesh3d) ts = np.linspace(0, 2*np.pi, 30) trace_scatter3d = { - 'backend': 'plotly', + 'backend': 'generic', 'constructor': 'Scatter3d', 'kwargs': { 'x': np.cos(ts), @@ -68,7 +68,7 @@ trace_scatter3d = { 'mode': 'lines', } } -dipole = magpy.misc.Dipole(moment=(0,0,1), style_label="'Scatter3d' trace") +dipole = magpy.misc.Dipole(moment=(0,0,1), style_label="'Scatter3d' trace", style_size=6) dipole.style.model3d.add_trace(trace_scatter3d) magpy.show(coll, dipole, backend='plotly') @@ -94,7 +94,7 @@ trace3.kwargs['z'] = np.cos(ts) dipole.style.model3d.add_trace(trace3) -dipole.show(dipole, backend='plotly') +dipole.show(dipole, backend='matplotlib') ``` **Matplotlib** plotting functions often use positional arguments for $(x,y,z)$ input, that are handed over from `args=(x,y,z)` in `trace`. The following examples show how to construct traces with `plot`, `plot_surface` and `plot_trisurf`: @@ -160,12 +160,12 @@ trace_trisurf = { mobius = magpy.misc.CustomSource(style_model3d_showdefault=False, position=(3,0,0)) mobius.style.model3d.add_trace(trace_trisurf) -magpy.show(magnet, ball, mobius) +magpy.show(magnet, ball, mobius, zoom=2) ``` ## Pre-defined 3D models -Automatic trace generators are provided for several 3D models in `magpylib.graphics.model3d`. They can be used as follows, +Automatic trace generators are provided for several basic 3D models in `magpylib.graphics.model3d`. If no backend is specified, it defaults back to `'generic'`. They can be used as follows, ```{code-cell} ipython3 import magpylib as magpy @@ -173,7 +173,6 @@ from magpylib.graphics import model3d # prism trace ################################### trace_prism = model3d.make_Prism( - backend='plotly', base=6, diameter=2, height=1, @@ -184,7 +183,6 @@ obj0.style.model3d.add_trace(trace_prism) # pyramid trace ################################# trace_pyramid = model3d.make_Pyramid( - backend='plotly', base=30, diameter=2, height=1, @@ -195,7 +193,6 @@ obj1.style.model3d.add_trace(trace_pyramid) # cuboid trace ################################## trace_cuboid = model3d.make_Cuboid( - backend='plotly', dimension=(2,2,2), position=(0,3,0), ) @@ -204,7 +201,6 @@ obj2.style.model3d.add_trace(trace_cuboid) # cylinder segment trace ######################## trace_cylinder_segment = model3d.make_CylinderSegment( - backend='plotly', dimension=(1, 2, 1, 140, 220), position=(1,0,-3), ) @@ -213,7 +209,6 @@ obj3.style.model3d.add_trace(trace_cylinder_segment) # ellipsoid trace ############################### trace_ellipsoid = model3d.make_Ellipsoid( - backend='plotly', dimension=(2,2,2), position=(0,0,3), ) @@ -222,7 +217,6 @@ obj4.style.model3d.add_trace(trace_ellipsoid) # arrow trace ################################### trace_arrow = model3d.make_Arrow( - backend='plotly', base=30, diameter=0.6, height=2, @@ -238,7 +232,7 @@ magpy.show(obj0, obj1, obj2, obj3, obj4, obj5, backend='plotly') ## Adding a CAD model -As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. +As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a generic Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. ```{note} The code below requires installation of the `numpy-stl` package. @@ -251,18 +245,20 @@ import requests import numpy as np from stl import mesh # requires installation of numpy-stl import magpylib as magpy +from matplotlib.colors import to_hex -def get_stl_color(x): - """ transform stl_mesh attr to plotly color""" +def bin_color_to_hex(x): + """ transform binary rgb into hex color""" sb = f"{x:015b}"[::-1] - r = int(255 / 31 * int(sb[:5], base=2)) - g = int(255 / 31 * int(sb[5:10], base=2)) - b = int(255 / 31 * int(sb[10:15], base=2)) - return f"rgb({r},{g},{b})" + r = int(sb[:5], base=2)/31 + g = int(sb[5:10], base=2)/31 + b = int(sb[10:15], base=2)/31 + return to_hex((r,g,b)) + -def trace_from_stl(stl_file, backend='matplotlib'): +def trace_from_stl(stl_file): """ Generates a Magpylib 3D model trace dictionary from an *.stl file. backend: 'matplotlib' or 'plotly' @@ -278,26 +274,14 @@ def trace_from_stl(stl_file, backend='matplotlib'): k = np.take(ixr, [3 * k + 2 for k in range(p)]) x, y, z = vertices.T - # generate and return Magpylib traces - if backend == 'matplotlib': - triangles = np.array([i, j, k]).T - trace = { - 'backend': 'matplotlib', - 'constructor': 'plot_trisurf', - 'args': (x, y, z), - 'kwargs': {'triangles': triangles}, - } - elif backend == 'plotly': - colors = stl_mesh.attr.flatten() - facecolor = np.array([get_stl_color(c) for c in colors]).T - trace = { - 'backend': 'plotly', - 'constructor': 'Mesh3d', - 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), - } - else: - raise ValueError("Backend must be one of ['matplotlib', 'plotly'].") - + # generate and return a generic trace which can be translated into any backend + colors = stl_mesh.attr.flatten() + facecolor = np.array([bin_color_to_hex(c) for c in colors]).T + trace = { + 'backend': 'generic', + 'constructor': 'mesh3d', + 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), + } return trace @@ -311,21 +295,20 @@ with tempfile.TemporaryDirectory() as temp: f.write(response.content) # create traces for both backends - trace_mpl = trace_from_stl(fn, backend='matplotlib') - trace_ply = trace_from_stl(fn, backend='plotly') + trace = trace_from_stl(fn) # create sensor and add CAD model sensor = magpy.Sensor(style_label='PG-SSO-3 package') -sensor.style.model3d.add_trace(trace_mpl) -sensor.style.model3d.add_trace(trace_ply) +sensor.style.model3d.add_trace(trace) # create magnet and sensor path magnet = magpy.magnet.Cylinder(magnetization=(0,0,100), dimension=(15,20)) sensor.position = np.linspace((-15,0,8), (-15,0,-4), 21) -sensor.rotate_from_angax(np.linspace(0, 200, 21), 'z', anchor=0, start=0) +sensor.rotate_from_angax(np.linspace(0, 180, 21), 'z', anchor=0, start=0) -# display with both backends -magpy.show(sensor, magnet, style_path_frames=5, style_magnetization_show=False) -magpy.show(sensor, magnet, style_path_frames=5, backend="plotly") +# display with matplotlib and plotly backends +args = (sensor, magnet) +kwargs = dict(style_path_frames=5) +magpy.show(args, **kwargs, backend="matplotlib") +magpy.show(args, **kwargs, backend="plotly") ``` - diff --git a/magpylib/__init__.py b/magpylib/__init__.py index 69d9ce4ea..18aee2dc1 100644 --- a/magpylib/__init__.py +++ b/magpylib/__init__.py @@ -49,6 +49,7 @@ ] # create interface to outside of package +from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib import magnet, current, misc, core, graphics from magpylib._src.defaults.defaults_classes import default_settings as defaults from magpylib._src.fields import getB, getH diff --git a/magpylib/_src/defaults/defaults_classes.py b/magpylib/_src/defaults/defaults_classes.py index 6730d90d8..478d77901 100644 --- a/magpylib/_src/defaults/defaults_classes.py +++ b/magpylib/_src/defaults/defaults_classes.py @@ -50,7 +50,8 @@ class Display(MagicProperties): ---------- backend: str, default='matplotlib' Defines the plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly']` + function (e.g. 'matplotlib', 'plotly'). + Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS colorsequence: iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', @@ -80,7 +81,8 @@ class Display(MagicProperties): @property def backend(self): """plotting backend to be used by default, if not explicitly set in the `display` - function. Can be one of `['matplotlib', 'plotly']`""" + function (e.g. 'matplotlib', 'plotly'). + Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS""" return self._backend @backend.setter diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index d16687bd2..067b0fef5 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -5,7 +5,7 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly") +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "matplotlib_old") SYMBOLS_MATPLOTLIB_TO_PLOTLY = { diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py new file mode 100644 index 000000000..ee672156d --- /dev/null +++ b/magpylib/_src/display/backend_matplotlib.py @@ -0,0 +1,197 @@ +import matplotlib.pyplot as plt +import numpy as np +from matplotlib.animation import FuncAnimation + +from magpylib._src.display.traces_generic import get_frames +from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.traces_utility import subdivide_mesh_by_facecolor + +# from magpylib._src.utility import format_obj_input + +SYMBOLS = {"circle": "o", "cross": "+", "diamond": "d", "square": "s", "x": "x"} + +LINE_STYLES = { + "solid": "-", + "dash": "--", + "dashdot": "-.", + "dot": (0, (1, 1)), + "longdash": "loosely dotted", + "longdashdot": "loosely dashdotted", +} + + +def generic_trace_to_matplotlib(trace): + """Transform a generic trace into a matplotlib trace""" + traces_mpl = [] + if trace["type"] == "mesh3d": + subtraces = [trace] + if trace.get("facecolor", None) is not None: + subtraces = subdivide_mesh_by_facecolor(trace) + for subtrace in subtraces: + x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) + triangles = np.array([subtrace[k] for k in "ijk"]).T + traces_mpl.append( + { + "constructor": "plot_trisurf", + "args": (x, y, z), + "kwargs": { + "triangles": triangles, + "alpha": subtrace.get("opacity", None), + "color": subtrace.get("color", None), + }, + } + ) + elif trace["type"] == "scatter3d": + x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) + mode = trace.get("mode", None) + props = { + k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None)) + for k, v in { + "ls": ("line", "dash"), + "lw": ("line", "width"), + "color": ("line", "color"), + "marker": ("marker", "symbol"), + "mfc": ("marker", "color"), + "mec": ("marker", "color"), + "ms": ("marker", "size"), + }.items() + } + if "ls" in props: + props["ls"] = LINE_STYLES.get(props["ls"], "solid") + if "marker" in props: + props["marker"] = SYMBOLS.get(props["marker"], "x") + if mode is not None: + if "lines" not in mode: + props["ls"] = "" + if "markers" not in mode: + props["marker"] = None + if "text" in mode and trace.get("text", False): + for xs, ys, zs, txt in zip(x, y, z, trace["text"]): + traces_mpl.append( + { + "constructor": "text", + "args": (xs, ys, zs, txt), + } + ) + traces_mpl.append( + { + "constructor": "plot", + "args": (x, y, z), + "kwargs": { + **{k: v for k, v in props.items() if v is not None}, + "alpha": trace.get("opacity", 1), + }, + } + ) + else: + raise ValueError( + f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" + ) + return traces_mpl + + +def process_extra_trace(model): + "process extra trace attached to some magpylib object" + extr = model["model3d"] + model_kwargs = {"color": model["kwargs"]["color"]} + model_kwargs.update(extr.kwargs() if callable(extr.kwargs) else extr.kwargs) + model_args = extr.args() if callable(extr.args) else extr.args + trace3d = { + "constructor": extr.constructor, + "kwargs": model_kwargs, + "args": model_args, + } + kwargs, args, = place_and_orient_model3d( + model_kwargs=model_kwargs, + model_args=model_args, + orientation=model["orientation"], + position=model["position"], + coordsargs=extr.coordsargs, + scale=extr.scale, + return_model_args=True, + ) + trace3d["kwargs"].update(kwargs) + trace3d["args"] = args + return trace3d + + +def display_matplotlib( + *obj_list, + zoom=1, + canvas=None, + animation=False, + repeat=False, + colorsequence=None, + return_fig=False, + return_animation=False, + **kwargs, +): + + """Display objects and paths graphically using the matplotlib library.""" + data = get_frames( + objs=obj_list, + colorsequence=colorsequence, + zoom=zoom, + animation=animation, + mag_arrows=True, + extra_backend="matplotlib", + **kwargs, + ) + frames = data["frames"] + ranges = data["ranges"] + + for fr in frames: + new_data = [] + for tr in fr["data"]: + new_data.extend(generic_trace_to_matplotlib(tr)) + for model in fr["extra_backend_traces"]: + new_data.append(process_extra_trace(model)) + fr["data"] = new_data + + show_canvas = False + if canvas is None: + show_canvas = True + fig = plt.figure(dpi=80, figsize=(8, 8)) + ax = fig.add_subplot(111, projection="3d") + ax.set_box_aspect((1, 1, 1)) + else: + ax = canvas + fig = ax.get_figure() + + def draw_frame(ind): + for tr in frames[ind]["data"]: + constructor = tr["constructor"] + args = tr.get("args", ()) + kwargs = tr.get("kwargs", {}) + getattr(ax, constructor)(*args, **kwargs) + ax.set( + **{f"{k}label": f"{k} [mm]" for k in "xyz"}, + **{f"{k}lim": r for k, r in zip("xyz", ranges)}, + ) + + def animate(ind): + plt.cla() + draw_frame(ind) + return [ax] + + if len(frames) == 1: + draw_frame(0) + else: + anim = FuncAnimation( + fig, + animate, + frames=range(len(frames)), + interval=100, + blit=False, + repeat=repeat, + ) + out = () + if return_fig: + out += (fig,) + if return_animation and len(frames) != 1: + out += (anim,) + if show_canvas: + plt.show() + + if out: + return out[0] if len(out) == 1 else out diff --git a/magpylib/_src/display/display_matplotlib.py b/magpylib/_src/display/backend_matplotlib_old.py similarity index 64% rename from magpylib/_src/display/display_matplotlib.py rename to magpylib/_src/display/backend_matplotlib_old.py index 049b4aaa6..144681143 100644 --- a/magpylib/_src/display/display_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib_old.py @@ -1,24 +1,280 @@ """ matplotlib draw-functionalities""" +import warnings + import matplotlib.pyplot as plt import numpy as np from mpl_toolkits.mplot3d.art3d import Poly3DCollection from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.display_utility import draw_arrow_from_vertices -from magpylib._src.display.display_utility import draw_arrowed_circle -from magpylib._src.display.display_utility import faces_cuboid -from magpylib._src.display.display_utility import faces_cylinder -from magpylib._src.display.display_utility import faces_cylinder_segment -from magpylib._src.display.display_utility import faces_sphere -from magpylib._src.display.display_utility import get_flatten_objects_properties -from magpylib._src.display.display_utility import get_rot_pos_from_path -from magpylib._src.display.display_utility import MagpyMarkers -from magpylib._src.display.display_utility import place_and_orient_model3d -from magpylib._src.display.display_utility import system_size +from magpylib._src.display.traces_utility import draw_arrow_from_vertices +from magpylib._src.display.traces_utility import draw_arrowed_circle +from magpylib._src.display.traces_utility import get_flatten_objects_properties +from magpylib._src.display.traces_utility import get_rot_pos_from_path +from magpylib._src.display.traces_utility import MagpyMarkers +from magpylib._src.display.traces_utility import place_and_orient_model3d from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style +def faces_cuboid(src, show_path): + """ + compute vertices and faces of Cuboid input for plotting + takes Cuboid source + returns vert, faces + returns all faces when show_path=all + """ + # pylint: disable=protected-access + a, b, c = src.dimension + vert0 = np.array( + ( + (0, 0, 0), + (a, 0, 0), + (0, b, 0), + (0, 0, c), + (a, b, 0), + (a, 0, c), + (0, b, c), + (a, b, c), + ) + ) + vert0 = vert0 - src.dimension / 2 + + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + faces = [] + for rot, pos in zip(rots, poss): + vert = rot.apply(vert0) + pos + faces += [ + [vert[0], vert[1], vert[4], vert[2]], + [vert[0], vert[1], vert[5], vert[3]], + [vert[0], vert[2], vert[6], vert[3]], + [vert[7], vert[6], vert[2], vert[4]], + [vert[7], vert[6], vert[3], vert[5]], + [vert[7], vert[5], vert[1], vert[4]], + ] + return faces + + +def faces_cylinder(src, show_path): + """ + Compute vertices and faces of Cylinder input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder faces + r, h2 = src.dimension / 2 + hs = np.array([-h2, h2]) + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + faces = [ + np.array( + [ + (r * np.cos(p1), r * np.sin(p1), h2), + (r * np.cos(p1), r * np.sin(p1), -h2), + (r * np.cos(p2), r * np.sin(p2), -h2), + (r * np.cos(p2), r * np.sin(p2), h2), + ] + ) + for p1, p2 in zip(phis, phis2) + ] + faces += [ + np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_cylinder_segment(src, show_path): + """ + Compute vertices and faces of CylinderSegment for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder segment faces + r1, r2, h, phi1, phi2 = src.dimension + res_tile = ( + int((phi2 - phi1) / 360 * 2 * res) + 2 + ) # resolution used for tile curved surface + phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi + phis2 = np.roll(phis, 1) + faces = [ + np.array( + [ # inner curved surface + (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), + (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # outer curved surface + (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), + (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # sides + (r1 * np.cos(p), r1 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), -h / 2), + (r1 * np.cos(p), r1 * np.sin(p), -h / 2), + ] + ) + for p in [phis[0], phis[-1]] + ] + faces += [ + np.array( # top surface + [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] + ) + ] + faces += [ + np.array( # bottom surface + [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] + ) + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_sphere(src, show_path): + """ + Compute vertices and faces of Sphere input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate sphere faces + r = src.diameter / 2 + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + ths = np.linspace(0, np.pi, res) + faces = [ + r + * np.array( + [ + (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), + (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), + ] + ) + for p, p2 in zip(phis, phis2) + for t1, t2 in zip(ths[1:-2], ths[2:-1]) + ] + faces += [ + r + * np.array( + [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] + ) + for th in [ths[1], ths[-2]] + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def system_size(points): + """compute system size for display""" + # determine min/max from all to generate aspect=1 plot + if points: + + # bring (n,m,3) point dimensions (e.g. from plot_surface body) + # to correct (n,3) shape + for i, p in enumerate(points): + if p.ndim == 3: + points[i] = np.reshape(p, (-1, 3)) + + pts = np.vstack(points) + xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] + ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] + zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] + + xsize = xs[1] - xs[0] + ysize = ys[1] - ys[0] + zsize = zs[1] - zs[0] + + xcenter = (xs[1] + xs[0]) / 2 + ycenter = (ys[1] + ys[0]) / 2 + zcenter = (zs[1] + zs[0]) / 2 + + size = max([xsize, ysize, zsize]) + + limx0 = xcenter + size / 2 + limx1 = xcenter - size / 2 + limy0 = ycenter + size / 2 + limy1 = ycenter - size / 2 + limz0 = zcenter + size / 2 + limz1 = zcenter - size / 2 + else: + limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 + return limx0, limx1, limy0, limy1, limz0, limz1 + + def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): """draw direction of magnetization of faced magnets @@ -318,29 +574,30 @@ def draw_model3d_extra(obj, style, show_path, ax, color): return points -def display_matplotlib( +def display_matplotlib_old( *obj_list_semi_flat, - axis=None, + canvas=None, markers=None, zoom=0, - color_sequence=None, + colorsequence=None, + animation=False, **kwargs, ): - """ - Display objects and paths graphically with the matplotlib backend. - - - axis: matplotlib axis3d object - - markers: list of marker positions - - path: bool / int / list of ints - - zoom: zoom level, 0=tight boundaries - - color_sequence: list of colors for object coloring - """ + """Display objects and paths graphically with the matplotlib backend.""" # pylint: disable=protected-access # pylint: disable=too-many-branches # pylint: disable=too-many-statements # apply config default values if None # create or set plotting axis + + if animation is not False: + msg = "The matplotlib backend does not support animation at the moment.\n" + msg += "Use `backend=plotly` instead." + warnings.warn(msg) + # animation = False + + axis = canvas if axis is None: fig = plt.figure(dpi=80, figsize=(8, 8)) ax = fig.add_subplot(111, projection="3d") @@ -356,8 +613,10 @@ def display_matplotlib( points = [] dipoles = [] sensors = [] + markers_list = [o for o in obj_list_semi_flat if isinstance(o, MagpyMarkers)] + obj_list_semi_flat = [o for o in obj_list_semi_flat if o not in markers_list] flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, color_sequence=color_sequence + *obj_list_semi_flat, colorsequence=colorsequence ) for obj, props in flat_objs_props.items(): color = props["color"] @@ -450,10 +709,10 @@ def display_matplotlib( ) # markers ------------------------------------------------------- - if markers is not None and markers: - m = MagpyMarkers() - style = get_style(m, Config, **kwargs) - markers = np.array(markers) + if markers_list: + markers_instance = markers_list[0] + style = get_style(markers_instance, Config, **kwargs) + markers = np.array(markers_instance.markers) s = style.marker draw_markers(markers, ax, s.color, s.symbol, s.size) points += [markers] diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py new file mode 100644 index 000000000..ef1bfdd2d --- /dev/null +++ b/magpylib/_src/display/backend_plotly.py @@ -0,0 +1,265 @@ +""" plotly draw-functionalities""" +# pylint: disable=C0302 +# pylint: disable=too-many-branches + +try: + import plotly.graph_objects as go +except ImportError as missing_module: # pragma: no cover + raise ModuleNotFoundError( + """In order to use the plotly plotting backend, you need to install plotly via pip or conda, + see https://github.com/plotly/plotly.py""" + ) from missing_module + +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.display.traces_generic import get_frames +from magpylib._src.defaults.defaults_utility import linearize_dict +from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.traces_utility import get_scene_ranges +from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY +from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY +from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY + + +def apply_fig_ranges(fig, ranges): + """This is a helper function which applies the ranges properties of the provided `fig` object + according to a certain zoom level. All three space direction will be equal and match the + maximum of the ranges needed to display all objects, including their paths. + + Parameters + ---------- + ranges: array of dimension=(3,2) + min and max graph range + + zoom: float, default = 1 + When zoom=0 all objects are just inside the 3D-axes. + + Returns + ------- + None: NoneType + """ + fig.update_scenes( + **{ + f"{k}axis": dict(range=ranges[i], autorange=False, title=f"{k} [mm]") + for i, k in enumerate("xyz") + }, + aspectratio={k: 1 for k in "xyz"}, + aspectmode="manual", + camera_eye={"x": 1, "y": -1.5, "z": 1.4}, + ) + + +def animate_path( + fig, + frames, + path_indices, + frame_duration, + animation_slider=False, + update_layout=True, +): + """This is a helper function which attaches plotly frames to the provided `fig` object + according to a certain zoom level. All three space direction will be equal and match the + maximum of the ranges needed to display all objects, including their paths. + """ + fps = int(1000 / frame_duration) + if animation_slider: + sliders_dict = { + "active": 0, + "yanchor": "top", + "font": {"size": 10}, + "xanchor": "left", + "currentvalue": { + "prefix": f"Fps={fps}, Path index: ", + "visible": True, + "xanchor": "right", + }, + "pad": {"b": 10, "t": 10}, + "len": 0.9, + "x": 0.1, + "y": 0, + "steps": [], + } + + buttons_dict = { + "buttons": [ + { + "args": [ + None, + { + "frame": {"duration": frame_duration}, + "transition": {"duration": 0}, + "fromcurrent": True, + }, + ], + "label": "Play", + "method": "animate", + }, + { + "args": [[None], {"frame": {"duration": 0}, "mode": "immediate"}], + "label": "Pause", + "method": "animate", + }, + ], + "direction": "left", + "pad": {"r": 10, "t": 20}, + "showactive": False, + "type": "buttons", + "x": 0.1, + "xanchor": "right", + "y": 0, + "yanchor": "top", + } + + for ind in path_indices: + if animation_slider: + slider_step = { + "args": [ + [str(ind + 1)], + { + "frame": {"duration": 0, "redraw": True}, + "mode": "immediate", + }, + ], + "label": str(ind + 1), + "method": "animate", + } + sliders_dict["steps"].append(slider_step) + + # update fig + fig.frames = frames + frame0 = fig.frames[0] + fig.add_traces(frame0.data) + title = frame0.layout.title.text + if update_layout: + fig.update_layout( + height=None, + title=title, + ) + fig.update_layout( + updatemenus=[buttons_dict], + sliders=[sliders_dict] if animation_slider else None, + ) + + +def generic_trace_to_plotly(trace): + """Transform a generic trace into a plotly trace""" + if trace["type"] == "scatter3d": + if "line_width" in trace: + trace["line_width"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] + dash = trace.get("line_dash", None) + if dash is not None: + trace["line_dash"] = LINESTYLES_MATPLOTLIB_TO_PLOTLY.get(dash, dash) + symb = trace.get("marker_symbol", None) + if symb is not None: + trace["marker_symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) + if "marker_size" in trace: + trace["marker_size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] + return trace + + +def process_extra_trace(model): + "process extra trace attached to some magpylib object" + extr = model["model3d"] + kwargs = model["kwargs"] + trace3d = {**kwargs} + ttype = extr.constructor.lower() + trace_kwargs = extr.kwargs() if callable(extr.kwargs) else extr.kwargs + trace3d.update({"type": ttype, **trace_kwargs}) + if ttype == "scatter3d": + for k in ("marker", "line"): + trace3d[f"{k}_color"] = trace3d.get(f"{k}_color", kwargs["color"]) + trace3d.pop("color", None) + elif ttype == "mesh3d": + trace3d["showscale"] = trace3d.get("showscale", False) + trace3d["color"] = trace3d.get("color", kwargs["color"]) + trace3d.update( + linearize_dict( + place_and_orient_model3d( + model_kwargs=trace3d, + orientation=model["orientation"], + position=model["position"], + scale=extr.scale, + ), + separator="_", + ) + ) + return trace3d + + +def extract_layout_kwargs(kwargs): + """Extract layout kwargs""" + layout = kwargs.pop("layout", {}) + layout_kwargs = {k[7:]: v for k, v in kwargs.items() if k.startswith("layout")} + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("layout")} + layout.update(layout_kwargs) + return layout, kwargs + + +def display_plotly( + *obj_list, + zoom=1, + canvas=None, + renderer=None, + animation=False, + colorsequence=None, + return_fig=False, + update_layout=True, + **kwargs, +): + + """Display objects and paths graphically using the plotly library.""" + + fig = canvas + show_fig = False + extra_data = False + if fig is None: + if not return_fig: + show_fig = True + fig = go.Figure() + + if colorsequence is None: + colorsequence = Config.display.colorsequence + + layout, kwargs = extract_layout_kwargs(kwargs) + data = get_frames( + objs=obj_list, + colorsequence=colorsequence, + zoom=zoom, + animation=animation, + extra_backend="plotly", + **kwargs, + ) + frames = data["frames"] + for fr in frames: + new_data = [] + for tr in fr["data"]: + new_data.append(generic_trace_to_plotly(tr)) + for model in fr["extra_backend_traces"]: + extra_data = True + new_data.append(process_extra_trace(model)) + fr["data"] = new_data + fr.pop("extra_backend_traces", None) + with fig.batch_update(): + if len(frames) == 1: + fig.add_traces(frames[0]["data"]) + else: + animation_slider = data.get("animation_slider", False) + animate_path( + fig, + frames, + data["path_indices"], + data["frame_duration"], + animation_slider=animation_slider, + update_layout=update_layout, + ) + ranges = data["ranges"] + if extra_data: + ranges = get_scene_ranges(*frames[0]["data"], zoom=zoom) + if update_layout: + apply_fig_ranges(fig, ranges) + fig.update_layout(legend_itemsizing="constant") + fig.update_layout(layout) + + if return_fig and not show_fig: + return fig + if show_fig: + fig.show(renderer=renderer) diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 945463bfe..04d8ce22b 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -1,7 +1,7 @@ """ Display function codes""" -import warnings +from importlib import import_module -from magpylib._src.display.display_matplotlib import display_matplotlib +from magpylib._src.display.traces_generic import MagpyMarkers from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_backend @@ -19,6 +19,7 @@ def show( markers=None, backend=None, canvas=None, + return_fig=False, **kwargs, ): """Display objects and paths graphically. @@ -43,7 +44,7 @@ def show( Display position markers in the global coordinate system. backend: string, default=`None` - Define plotting backend. Must be one of `'matplotlib'` or `'plotly'`. If not + Define plotting backend. Must be one of `'matplotlib'`, `'plotly'`. If not set, parameter will default to `magpylib.defaults.display.backend` which is `'matplotlib'` by installation default. @@ -53,9 +54,14 @@ def show( - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. By default a new canvas is created and immediately displayed. + canvas: bool, default=False + If True, the function call returns the figure object. + - with matplotlib: `matplotlib.figure.Figure`. + - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. + Returns ------- - `None`: NoneType + `None` or figure object Examples -------- @@ -121,39 +127,19 @@ def show( allow_None=True, ) - check_input_zoom(zoom) - check_input_animation(animation) - check_format_input_vector( - markers, - dims=(2,), - shape_m1=3, - sig_name="markers", - sig_type="array_like of shape (n,3)", - allow_None=True, + # pylint: disable=import-outside-toplevel + display_func = getattr( + import_module(f"magpylib._src.display.backend_{backend}"), f"display_{backend}" ) - if backend == "matplotlib": - if animation is not False: - msg = "The matplotlib backend does not support animation at the moment.\n" - msg += "Use `backend=plotly` instead." - warnings.warn(msg) - # animation = False - display_matplotlib( - *obj_list_semi_flat, - markers=markers, - zoom=zoom, - axis=canvas, - **kwargs, - ) - elif backend == "plotly": - # pylint: disable=import-outside-toplevel - from magpylib._src.display.plotly.plotly_display import display_plotly - - display_plotly( - *obj_list_semi_flat, - markers=markers, - zoom=zoom, - fig=canvas, - animation=animation, - **kwargs, - ) + if markers is not None and markers: + obj_list_semi_flat = list(obj_list_semi_flat) + [MagpyMarkers(*markers)] + + return display_func( + *obj_list_semi_flat, + zoom=zoom, + canvas=canvas, + animation=animation, + return_fig=return_fig, + **kwargs, + ) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py deleted file mode 100644 index 8804f5763..000000000 --- a/magpylib/_src/display/display_utility.py +++ /dev/null @@ -1,504 +0,0 @@ -""" Display function codes""" -from itertools import cycle -from typing import Tuple - -import numpy as np -from scipy.spatial.transform import Rotation as RotScipy - -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.style import Markers -from magpylib._src.utility import Registered - - -@Registered(kind="nonmodel", family="markers") -class MagpyMarkers: - """A class that stores markers 3D-coordinates""" - - def __init__(self, *markers): - self.style = Markers() - self.markers = np.array(markers) - - -# pylint: disable=too-many-branches -def place_and_orient_model3d( - model_kwargs, - model_args=None, - orientation=None, - position=None, - coordsargs=None, - scale=1, - return_vertices=False, - return_model_args=False, - **kwargs, -): - """places and orients mesh3d dict""" - if orientation is None and position is None: - return {**model_kwargs, **kwargs} - position = (0.0, 0.0, 0.0) if position is None else position - position = np.array(position, dtype=float) - new_model_dict = {} - if model_args is None: - model_args = () - new_model_args = list(model_args) - if model_args: - if coordsargs is None: # matplotlib default - coordsargs = dict(x="args[0]", y="args[1]", z="args[2]") - vertices = [] - if coordsargs is None: - coordsargs = {"x": "x", "y": "y", "z": "z"} - useargs = False - for k in "xyz": - key = coordsargs[k] - if key.startswith("args"): - useargs = True - ind = int(key[5]) - v = model_args[ind] - else: - if key in model_kwargs: - v = model_kwargs[key] - else: - raise ValueError( - "Rotating/Moving of provided model failed, trace dictionary " - f"has no argument {k!r}, use `coordsargs` to specify the names of the " - "coordinates to be used.\n" - "Matplotlib backends will set up coordsargs automatically if " - "the `args=(xs,ys,zs)` argument is provided." - ) - vertices.append(v) - - vertices = np.array(vertices) - - # sometimes traces come as (n,m,3) shape - vert_shape = vertices.shape - vertices = np.reshape(vertices, (3, -1)) - - vertices = vertices.T - - if orientation is not None: - vertices = orientation.apply(vertices) - new_vertices = (vertices * scale + position).T - new_vertices = np.reshape(new_vertices, vert_shape) - for i, k in enumerate("xyz"): - key = coordsargs[k] - if useargs: - ind = int(key[5]) - new_model_args[ind] = new_vertices[i] - else: - new_model_dict[key] = new_vertices[i] - new_model_kwargs = {**model_kwargs, **new_model_dict, **kwargs} - - out = (new_model_kwargs,) - if return_model_args: - out += (new_model_args,) - if return_vertices: - out += (new_vertices,) - return out[0] if len(out) == 1 else out - - -def draw_arrowed_line(vec, pos, sign=1, arrow_size=1) -> Tuple: - """ - Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and - centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and - moved to position `pos`. - """ - norm = np.linalg.norm(vec) - nvec = np.array(vec) / norm - yaxis = np.array([0, 1, 0]) - cross = np.cross(nvec, yaxis) - dot = np.dot(nvec, yaxis) - n = np.linalg.norm(cross) - if dot == -1: - sign *= -1 - hy = sign * 0.1 * arrow_size - hx = 0.06 * arrow_size - arrow = ( - np.array( - [ - [0, -0.5, 0], - [0, 0, 0], - [-hx, 0 - hy, 0], - [0, 0, 0], - [hx, 0 - hy, 0], - [0, 0, 0], - [0, 0.5, 0], - ] - ) - * norm - ) - if n != 0: - t = np.arccos(dot) - R = RotScipy.from_rotvec(-t * cross / n) - arrow = R.apply(arrow) - x, y, z = (arrow + pos).T - return x, y, z - - -def draw_arrow_from_vertices(vertices, current, arrow_size): - """returns scatter coordinates of arrows between input vertices""" - vectors = np.diff(vertices, axis=0) - positions = vertices[:-1] + vectors / 2 - vertices = np.concatenate( - [ - draw_arrowed_line(vec, pos, np.sign(current), arrow_size=arrow_size) - for vec, pos in zip(vectors, positions) - ], - axis=1, - ) - - return vertices - - -def draw_arrowed_circle(current, diameter, arrow_size, vert): - """draws an oriented circle with an arrow""" - t = np.linspace(0, 2 * np.pi, vert) - x = np.cos(t) - y = np.sin(t) - if arrow_size != 0: - hy = 0.2 * np.sign(current) * arrow_size - hx = 0.15 * arrow_size - x = np.hstack([x, [1 + hx, 1, 1 - hx]]) - y = np.hstack([y, [-hy, 0, -hy]]) - x = x * diameter / 2 - y = y * diameter / 2 - z = np.zeros(x.shape) - vertices = np.array([x, y, z]) - return vertices - - -def get_rot_pos_from_path(obj, show_path=None): - """ - subsets orientations and positions depending on `show_path` value. - examples: - show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6] - returns rots[[1,2,6]], poss[[1,2,6]] - """ - # pylint: disable=protected-access - # pylint: disable=invalid-unary-operand-type - if show_path is None: - show_path = True - pos = getattr(obj, "_position", None) - if pos is None: - pos = obj.position - pos = np.array(pos) - orient = getattr(obj, "_orientation", None) - if orient is None: - orient = getattr(obj, "orientation", None) - if orient is None: - orient = RotScipy.from_rotvec([[0, 0, 1]]) - pos = np.array([pos]) if pos.ndim == 1 else pos - path_len = pos.shape[0] - if show_path is True or show_path is False or show_path == 0: - inds = np.array([-1]) - elif isinstance(show_path, int): - inds = np.arange(path_len, dtype=int)[::-show_path] - elif hasattr(show_path, "__iter__") and not isinstance(show_path, str): - inds = np.array(show_path) - inds[inds >= path_len] = path_len - 1 - inds = np.unique(inds) - if inds.size == 0: - inds = np.array([path_len - 1]) - rots = orient[inds] - poss = pos[inds] - return rots, poss, inds - - -def faces_cuboid(src, show_path): - """ - compute vertices and faces of Cuboid input for plotting - takes Cuboid source - returns vert, faces - returns all faces when show_path=all - """ - # pylint: disable=protected-access - a, b, c = src.dimension - vert0 = np.array( - ( - (0, 0, 0), - (a, 0, 0), - (0, b, 0), - (0, 0, c), - (a, b, 0), - (a, 0, c), - (0, b, c), - (a, b, c), - ) - ) - vert0 = vert0 - src.dimension / 2 - - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - faces = [] - for rot, pos in zip(rots, poss): - vert = rot.apply(vert0) + pos - faces += [ - [vert[0], vert[1], vert[4], vert[2]], - [vert[0], vert[1], vert[5], vert[3]], - [vert[0], vert[2], vert[6], vert[3]], - [vert[7], vert[6], vert[2], vert[4]], - [vert[7], vert[6], vert[3], vert[5]], - [vert[7], vert[5], vert[1], vert[4]], - ] - return faces - - -def faces_cylinder(src, show_path): - """ - Compute vertices and faces of Cylinder input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder faces - r, h2 = src.dimension / 2 - hs = np.array([-h2, h2]) - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - faces = [ - np.array( - [ - (r * np.cos(p1), r * np.sin(p1), h2), - (r * np.cos(p1), r * np.sin(p1), -h2), - (r * np.cos(p2), r * np.sin(p2), -h2), - (r * np.cos(p2), r * np.sin(p2), h2), - ] - ) - for p1, p2 in zip(phis, phis2) - ] - faces += [ - np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_cylinder_segment(src, show_path): - """ - Compute vertices and faces of CylinderSegment for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder segment faces - r1, r2, h, phi1, phi2 = src.dimension - res_tile = ( - int((phi2 - phi1) / 360 * 2 * res) + 2 - ) # resolution used for tile curved surface - phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi - phis2 = np.roll(phis, 1) - faces = [ - np.array( - [ # inner curved surface - (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), - (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # outer curved surface - (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), - (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # sides - (r1 * np.cos(p), r1 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), -h / 2), - (r1 * np.cos(p), r1 * np.sin(p), -h / 2), - ] - ) - for p in [phis[0], phis[-1]] - ] - faces += [ - np.array( # top surface - [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] - ) - ] - faces += [ - np.array( # bottom surface - [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] - ) - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_sphere(src, show_path): - """ - Compute vertices and faces of Sphere input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate sphere faces - r = src.diameter / 2 - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - ths = np.linspace(0, np.pi, res) - faces = [ - r - * np.array( - [ - (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), - (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), - ] - ) - for p, p2 in zip(phis, phis2) - for t1, t2 in zip(ths[1:-2], ths[2:-1]) - ] - faces += [ - r - * np.array( - [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] - ) - for th in [ths[1], ths[-2]] - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def system_size(points): - """compute system size for display""" - # determine min/max from all to generate aspect=1 plot - if points: - - # bring (n,m,3) point dimensions (e.g. from plot_surface body) - # to correct (n,3) shape - for i, p in enumerate(points): - if p.ndim == 3: - points[i] = np.reshape(p, (-1, 3)) - - pts = np.vstack(points) - xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] - ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] - zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] - - xsize = xs[1] - xs[0] - ysize = ys[1] - ys[0] - zsize = zs[1] - zs[0] - - xcenter = (xs[1] + xs[0]) / 2 - ycenter = (ys[1] + ys[0]) / 2 - zcenter = (zs[1] + zs[0]) / 2 - - size = max([xsize, ysize, zsize]) - - limx0 = xcenter + size / 2 - limx1 = xcenter - size / 2 - limy0 = ycenter + size / 2 - limy1 = ycenter - size / 2 - limz0 = zcenter + size / 2 - limz1 = zcenter - size / 2 - else: - limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 - return limx0, limx1, limy0, limy1, limz0, limz1 - - -def get_flatten_objects_properties( - *obj_list_semi_flat, - color_sequence=None, - color_cycle=None, - **parent_props, -): - """returns a flat dict -> (obj: display_props, ...) from nested collections""" - if color_sequence is None: - color_sequence = Config.display.colorsequence - if color_cycle is None: - color_cycle = cycle(color_sequence) - flat_objs = {} - for subobj in obj_list_semi_flat: - isCollection = getattr(subobj, "children", None) is not None - props = {**parent_props} - parent_color = parent_props.get("color", "!!!missing!!!") - if parent_color == "!!!missing!!!": - props["color"] = next(color_cycle) - if parent_props.get("legendgroup", None) is None: - props["legendgroup"] = f"{subobj}" - if parent_props.get("showlegend", None) is None: - props["showlegend"] = True - if parent_props.get("legendtext", None) is None: - legendtext = None - if isCollection: - legendtext = getattr(getattr(subobj, "style", None), "label", None) - legendtext = f"{subobj!r}" if legendtext is None else legendtext - props["legendtext"] = legendtext - flat_objs[subobj] = props - if isCollection: - if subobj.style.color is not None: - flat_objs[subobj]["color"] = subobj.style.color - flat_objs.update( - get_flatten_objects_properties( - *subobj.children, - color_sequence=color_sequence, - color_cycle=color_cycle, - **flat_objs[subobj], - ) - ) - return flat_objs diff --git a/magpylib/_src/display/plotly/__init__.py b/magpylib/_src/display/plotly/__init__.py deleted file mode 100644 index 21c94b0be..000000000 --- a/magpylib/_src/display/plotly/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""display.plotly""" diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py deleted file mode 100644 index f8e962212..000000000 --- a/magpylib/_src/display/plotly/plotly_display.py +++ /dev/null @@ -1,1196 +0,0 @@ -""" plotly draw-functionalities""" -# pylint: disable=C0302 -# pylint: disable=too-many-branches -import numbers -import warnings -from itertools import combinations -from typing import Tuple - -try: - import plotly.graph_objects as go -except ImportError as missing_module: # pragma: no cover - raise ModuleNotFoundError( - """In order to use the plotly plotting backend, you need to install plotly via pip or conda, - see https://github.com/plotly/plotly.py""" - ) from missing_module - -import numpy as np -from scipy.spatial.transform import Rotation as RotScipy -from magpylib import _src -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.plotly.plotly_sensor_mesh import get_sensor_mesh -from magpylib._src.style import ( - get_style, - LINESTYLES_MATPLOTLIB_TO_PLOTLY, - SYMBOLS_MATPLOTLIB_TO_PLOTLY, -) -from magpylib._src.display.display_utility import ( - get_rot_pos_from_path, - MagpyMarkers, - draw_arrow_from_vertices, - draw_arrowed_circle, - place_and_orient_model3d, - get_flatten_objects_properties, -) -from magpylib._src.defaults.defaults_utility import ( - SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY, - linearize_dict, -) - -from magpylib._src.input_checks import check_excitations -from magpylib._src.utility import unit_prefix, format_obj_input -from magpylib._src.display.base_traces import ( - make_Cuboid as make_BaseCuboid, - make_CylinderSegment as make_BaseCylinderSegment, - make_Ellipsoid as make_BaseEllipsoid, - make_Prism as make_BasePrism, - # make_Pyramid as make_BasePyramid, - make_Arrow as make_BaseArrow, -) -from magpylib._src.display.plotly.plotly_utility import ( - merge_mesh3d, - merge_traces, - getColorscale, - getIntensity, - clean_legendgroups, -) - - -def make_Line( - current=0.0, - vertices=((-1.0, 0.0, 0.0), (1.0, 0.0, 0.0)), - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - **kwargs, -) -> dict: - """ - Creates the plotly scatter3d parameters for a Line current in a dictionary based on the - provided arguments - """ - default_suffix = ( - f" ({unit_prefix(current)}A)" - if current is not None - else " (Current not initialized)" - ) - name, name_suffix = get_name_and_suffix("Line", default_suffix, style) - show_arrows = style.arrow.show - arrow_size = style.arrow.size - if show_arrows: - vertices = draw_arrow_from_vertices(vertices, current, arrow_size) - else: - vertices = np.array(vertices).T - if orientation is not None: - vertices = orientation.apply(vertices.T).T - x, y, z = (vertices.T + position).T - line_width = style.arrow.width * SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] - line = dict( - type="scatter3d", - x=x, - y=y, - z=z, - name=f"""{name}{name_suffix}""", - mode="lines", - line_width=line_width, - line_color=color, - ) - return {**line, **kwargs} - - -def make_Loop( - current=0.0, - diameter=1.0, - position=(0.0, 0.0, 0.0), - vert=50, - orientation=None, - color=None, - style=None, - **kwargs, -): - """ - Creates the plotly scatter3d parameters for a Loop current in a dictionary based on the - provided arguments - """ - default_suffix = ( - f" ({unit_prefix(current)}A)" - if current is not None - else " (Current not initialized)" - ) - name, name_suffix = get_name_and_suffix("Loop", default_suffix, style) - arrow_size = style.arrow.size if style.arrow.show else 0 - vertices = draw_arrowed_circle(current, diameter, arrow_size, vert) - if orientation is not None: - vertices = orientation.apply(vertices.T).T - x, y, z = (vertices.T + position).T - line_width = style.arrow.width * SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] - circular = dict( - type="scatter3d", - x=x, - y=y, - z=z, - name=f"""{name}{name_suffix}""", - mode="lines", - line_width=line_width, - line_color=color, - ) - return {**circular, **kwargs} - - -def make_DefaultTrace( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - **kwargs, -) -> dict: - """ - Creates the plotly scatter3d parameters for an object with no specifically supported - representation. The object will be represented by a scatter point and text above with object - name. - """ - - default_suffix = "" - name, name_suffix = get_name_and_suffix( - f"{type(obj).__name__}", default_suffix, style - ) - vertices = np.array([position]) - if orientation is not None: - vertices = orientation.apply(vertices).T - x, y, z = vertices - trace = dict( - type="scatter3d", - x=x, - y=y, - z=z, - name=f"""{name}{name_suffix}""", - text=name, - mode="markers+text", - marker_size=10, - marker_color=color, - marker_symbol="diamond", - ) - return {**trace, **kwargs} - - -def make_Dipole( - moment=(0.0, 0.0, 1.0), - position=(0.0, 0.0, 0.0), - orientation=None, - style=None, - autosize=None, - **kwargs, -) -> dict: - """ - Creates the plotly mesh3d parameters for a Loop current in a dictionary based on the - provided arguments - """ - moment_mag = np.linalg.norm(moment) - default_suffix = f" (moment={unit_prefix(moment_mag)}mT mm³)" - name, name_suffix = get_name_and_suffix("Dipole", default_suffix, style) - size = style.size - if autosize is not None: - size *= autosize - dipole = make_BaseArrow( - "plotly-dict", base=10, diameter=0.3 * size, height=size, pivot=style.pivot - ) - nvec = np.array(moment) / moment_mag - zaxis = np.array([0, 0, 1]) - cross = np.cross(nvec, zaxis) - dot = np.dot(nvec, zaxis) - n = np.linalg.norm(cross) - t = np.arccos(dot) - vec = -t * cross / n if n != 0 else (0, 0, 0) - mag_orient = RotScipy.from_rotvec(vec) - orientation = orientation * mag_orient - mag = np.array((0, 0, 1)) - return _update_mag_mesh( - dipole, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, - ) - - -def make_Cuboid( - mag=(0.0, 0.0, 1000.0), - dimension=(1.0, 1.0, 1.0), - position=(0.0, 0.0, 0.0), - orientation=None, - style=None, - **kwargs, -) -> dict: - """ - Creates the plotly mesh3d parameters for a Cuboid Magnet in a dictionary based on the - provided arguments - """ - d = [unit_prefix(d / 1000) for d in dimension] - default_suffix = f" ({d[0]}m|{d[1]}m|{d[2]}m)" - name, name_suffix = get_name_and_suffix("Cuboid", default_suffix, style) - cuboid = make_BaseCuboid("plotly-dict", dimension=dimension) - return _update_mag_mesh( - cuboid, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, - ) - - -def make_Cylinder( - mag=(0.0, 0.0, 1000.0), - base=50, - diameter=1.0, - height=1.0, - position=(0.0, 0.0, 0.0), - orientation=None, - style=None, - **kwargs, -) -> dict: - """ - Creates the plotly mesh3d parameters for a Cylinder Magnet in a dictionary based on the - provided arguments - """ - d = [unit_prefix(d / 1000) for d in (diameter, height)] - default_suffix = f" (D={d[0]}m, H={d[1]}m)" - name, name_suffix = get_name_and_suffix("Cylinder", default_suffix, style) - cylinder = make_BasePrism( - "plotly-dict", - base=base, - diameter=diameter, - height=height, - ) - return _update_mag_mesh( - cylinder, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, - ) - - -def make_CylinderSegment( - mag=(0.0, 0.0, 1000.0), - dimension=(1.0, 2.0, 1.0, 0.0, 90.0), - position=(0.0, 0.0, 0.0), - orientation=None, - vert=25, - style=None, - **kwargs, -): - """ - Creates the plotly mesh3d parameters for a Cylinder Segment Magnet in a dictionary based on the - provided arguments - """ - d = [unit_prefix(d / (1000 if i < 3 else 1)) for i, d in enumerate(dimension)] - default_suffix = f" (r={d[0]}m|{d[1]}m, h={d[2]}m, φ={d[3]}°|{d[4]}°)" - name, name_suffix = get_name_and_suffix("CylinderSegment", default_suffix, style) - cylinder_segment = make_BaseCylinderSegment( - "plotly-dict", dimension=dimension, vert=vert - ) - return _update_mag_mesh( - cylinder_segment, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, - ) - - -def make_Sphere( - mag=(0.0, 0.0, 1000.0), - vert=15, - diameter=1, - position=(0.0, 0.0, 0.0), - orientation=None, - style=None, - **kwargs, -) -> dict: - """ - Creates the plotly mesh3d parameters for a Sphere Magnet in a dictionary based on the - provided arguments - """ - default_suffix = f" (D={unit_prefix(diameter / 1000)}m)" - name, name_suffix = get_name_and_suffix("Sphere", default_suffix, style) - vert = min(max(vert, 3), 20) - sphere = make_BaseEllipsoid("plotly-dict", vert=vert, dimension=[diameter] * 3) - return _update_mag_mesh( - sphere, - name, - name_suffix, - mag, - orientation, - position, - style, - **kwargs, - ) - - -def make_Pixels(positions, size=1) -> dict: - """ - Creates the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size - For now, only "cube" shape is provided. - """ - pixels = [ - make_BaseCuboid("plotly-dict", position=p, dimension=[size] * 3) - for p in positions - ] - return merge_mesh3d(*pixels) - - -def make_Sensor( - pixel=(0.0, 0.0, 0.0), - dimension=(1.0, 1.0, 1.0), - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - autosize=None, - **kwargs, -): - """ - Creates the plotly mesh3d parameters for a Sensor object in a dictionary based on the - provided arguments - - size_pixels: float, default=1 - A positive number. Adjusts automatic display size of sensor pixels. When set to 0, - pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum - distance between any pixel of the same sensor, equal to `size_pixel`. - """ - pixel = np.array(pixel).reshape((-1, 3)) - default_suffix = ( - f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)""" - if pixel.ndim != 1 - else "" - ) - name, name_suffix = get_name_and_suffix("Sensor", default_suffix, style) - style_arrows = style.arrows.as_dict(flatten=True, separator="_") - sensor = get_sensor_mesh(**style_arrows, center_color=color) - vertices = np.array([sensor[k] for k in "xyz"]).T - if color is not None: - sensor["facecolor"][sensor["facecolor"] == "rgb(238,238,238)"] = color - dim = np.array( - [dimension] * 3 if isinstance(dimension, (float, int)) else dimension[:3], - dtype=float, - ) - if autosize is not None: - dim *= autosize - if pixel.shape[0] == 1: - dim_ext = dim - else: - hull_dim = pixel.max(axis=0) - pixel.min(axis=0) - dim_ext = max(np.mean(dim), np.min(hull_dim)) - cube_mask = (vertices < 1).all(axis=1) - vertices[cube_mask] = 0 * vertices[cube_mask] - vertices[~cube_mask] = dim_ext * vertices[~cube_mask] - vertices /= 2 # sensor_mesh vertices are of length 2 - x, y, z = vertices.T - sensor.update(x=x, y=y, z=z) - meshes_to_merge = [sensor] - if pixel.shape[0] != 1: - pixel_color = style.pixel.color - pixel_size = style.pixel.size - combs = np.array(list(combinations(pixel, 2))) - vecs = np.diff(combs, axis=1) - dists = np.linalg.norm(vecs, axis=2) - pixel_dim = np.min(dists) / 2 - if pixel_size > 0: - pixel_dim *= pixel_size - pixels_mesh = make_Pixels(positions=pixel, size=pixel_dim) - pixels_mesh["facecolor"] = np.repeat(pixel_color, len(pixels_mesh["i"])) - meshes_to_merge.append(pixels_mesh) - hull_pos = 0.5 * (pixel.max(axis=0) + pixel.min(axis=0)) - hull_dim[hull_dim == 0] = pixel_dim / 2 - hull_mesh = make_BaseCuboid( - "plotly-dict", position=hull_pos, dimension=hull_dim - ) - hull_mesh["facecolor"] = np.repeat(color, len(hull_mesh["i"])) - meshes_to_merge.append(hull_mesh) - sensor = merge_mesh3d(*meshes_to_merge) - return _update_mag_mesh( - sensor, name, name_suffix, orientation=orientation, position=position, **kwargs - ) - - -def _update_mag_mesh( - mesh_dict, - name, - name_suffix, - magnetization=None, - orientation=None, - position=None, - style=None, - **kwargs, -): - """ - Updates an existing plotly mesh3d dictionary of an object which has a magnetic vector. The - object gets colorized, positioned and oriented based on provided arguments - """ - if hasattr(style, "magnetization"): - color = style.magnetization.color - if magnetization is not None and style.magnetization.show: - vertices = np.array([mesh_dict[k] for k in "xyz"]).T - color_middle = color.middle - if color.mode == "tricycle": - color_middle = kwargs.get("color", None) - elif color.mode == "bicolor": - color_middle = False - mesh_dict["colorscale"] = getColorscale( - color_transition=color.transition, - color_north=color.north, - color_middle=color_middle, - color_south=color.south, - ) - mesh_dict["intensity"] = getIntensity( - vertices=vertices, - axis=magnetization, - ) - mesh_dict = place_and_orient_model3d( - model_kwargs=mesh_dict, - orientation=orientation, - position=position, - showscale=False, - name=f"{name}{name_suffix}", - ) - return {**mesh_dict, **kwargs} - - -def get_name_and_suffix(default_name, default_suffix, style): - """provides legend entry based on name and suffix""" - name = default_name if style.label is None else style.label - if style.description.show and style.description.text is None: - name_suffix = default_suffix - elif not style.description.show: - name_suffix = "" - else: - name_suffix = f" ({style.description.text})" - return name, name_suffix - - -def get_plotly_traces( - input_obj, - color=None, - autosize=None, - legendgroup=None, - showlegend=None, - legendtext=None, - **kwargs, -) -> list: - """ - This is a helper function providing the plotly traces for any object of the magpylib library. If - the object is not supported, the trace representation will fall back to a single scatter point - with the object name marked above it. - - - If the object has a path (multiple positions), the function will return both the object trace - and the corresponding path trace. The legend entry of the path trace will be hidden but both - traces will share the same `legendgroup` so that a legend entry click will hide/show both traces - at once. From the user's perspective, the traces will be merged. - - - The argument caught by the kwargs dictionary must all be arguments supported both by - `scatter3d` and `mesh3d` plotly objects, otherwise an error will be raised. - """ - - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - # pylint: disable=too-many-nested-blocks - - Sensor = _src.obj_classes.class_Sensor.Sensor - Cuboid = _src.obj_classes.class_magnet_Cuboid.Cuboid - Cylinder = _src.obj_classes.class_magnet_Cylinder.Cylinder - CylinderSegment = _src.obj_classes.class_magnet_CylinderSegment.CylinderSegment - Sphere = _src.obj_classes.class_magnet_Sphere.Sphere - Dipole = _src.obj_classes.class_misc_Dipole.Dipole - Loop = _src.obj_classes.class_current_Loop.Loop - Line = _src.obj_classes.class_current_Line.Line - - # parse kwargs into style and non style args - style = get_style(input_obj, Config, **kwargs) - kwargs = {k: v for k, v in kwargs.items() if not k.startswith("style")} - kwargs["style"] = style - style_color = getattr(style, "color", None) - kwargs["color"] = style_color if style_color is not None else color - kwargs["opacity"] = style.opacity - legendgroup = f"{input_obj}" if legendgroup is None else legendgroup - - if hasattr(style, "magnetization"): - if style.magnetization.show: - check_excitations([input_obj]) - - if hasattr(style, "arrow"): - if style.arrow.show: - check_excitations([input_obj]) - - traces = [] - if isinstance(input_obj, MagpyMarkers): - x, y, z = input_obj.markers.T - marker = style.as_dict()["marker"] - symb = marker["symbol"] - marker["symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) - marker["size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] - default_name = "Marker" if len(x) == 1 else "Markers" - default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" - name, name_suffix = get_name_and_suffix(default_name, default_suffix, style) - trace = go.Scatter3d( - name=f"{name}{name_suffix}", - x=x, - y=y, - z=z, - marker=marker, - mode="markers", - opacity=style.opacity, - ) - traces.append(trace) - else: - if isinstance(input_obj, Sensor): - kwargs.update( - dimension=getattr(input_obj, "dimension", style.size), - pixel=getattr(input_obj, "pixel", (0.0, 0.0, 0.0)), - autosize=autosize, - ) - make_func = make_Sensor - elif isinstance(input_obj, Cuboid): - kwargs.update( - mag=input_obj.magnetization, - dimension=input_obj.dimension, - ) - make_func = make_Cuboid - elif isinstance(input_obj, Cylinder): - base = 50 - kwargs.update( - mag=input_obj.magnetization, - diameter=input_obj.dimension[0], - height=input_obj.dimension[1], - base=base, - ) - make_func = make_Cylinder - elif isinstance(input_obj, CylinderSegment): - vert = 50 - kwargs.update( - mag=input_obj.magnetization, - dimension=input_obj.dimension, - vert=vert, - ) - make_func = make_CylinderSegment - elif isinstance(input_obj, Sphere): - kwargs.update( - mag=input_obj.magnetization, - diameter=input_obj.diameter, - ) - make_func = make_Sphere - elif isinstance(input_obj, Dipole): - kwargs.update( - moment=input_obj.moment, - autosize=autosize, - ) - make_func = make_Dipole - elif isinstance(input_obj, Line): - kwargs.update( - vertices=input_obj.vertices, - current=input_obj.current, - ) - make_func = make_Line - elif isinstance(input_obj, Loop): - kwargs.update( - diameter=input_obj.diameter, - current=input_obj.current, - ) - make_func = make_Loop - elif getattr(input_obj, "children", None) is not None: - make_func = None - else: - kwargs.update(obj=input_obj) - make_func = make_DefaultTrace - - path_traces = [] - path_traces_extra = {} - extra_model3d_traces = ( - style.model3d.data if style.model3d.data is not None else [] - ) - rots, poss, _ = get_rot_pos_from_path(input_obj, style.path.frames) - for orient, pos in zip(rots, poss): - if style.model3d.showdefault and make_func is not None: - path_traces.append( - make_func(position=pos, orientation=orient, **kwargs) - ) - for extr in extra_model3d_traces: - if extr.show: - extr.update(extr.updatefunc()) - if extr.backend == "plotly": - trace3d = {} - ttype = extr.constructor.lower() - obj_extr_trace = ( - extr.kwargs() if callable(extr.kwargs) else extr.kwargs - ) - obj_extr_trace = {"type": ttype, **obj_extr_trace} - if ttype == "mesh3d": - trace3d["showscale"] = False - if "facecolor" in obj_extr_trace: - ttype = "mesh3d_facecolor" - if ttype == "scatter3d": - trace3d["marker_color"] = kwargs["color"] - trace3d["line_color"] = kwargs["color"] - else: - trace3d["color"] = kwargs["color"] - trace3d.update( - linearize_dict( - place_and_orient_model3d( - model_kwargs=obj_extr_trace, - orientation=orient, - position=pos, - scale=extr.scale, - ), - separator="_", - ) - ) - if ttype not in path_traces_extra: - path_traces_extra[ttype] = [] - path_traces_extra[ttype].append(trace3d) - trace = merge_traces(*path_traces) - for ind, traces_extra in enumerate(path_traces_extra.values()): - extra_model3d_trace = merge_traces(*traces_extra) - label = ( - input_obj.style.label - if input_obj.style.label is not None - else str(type(input_obj).__name__) - ) - extra_model3d_trace.update( - { - "legendgroup": legendgroup, - "showlegend": showlegend and ind == 0 and not trace, - "name": label, - } - ) - traces.append(extra_model3d_trace) - - if trace: - trace.update( - { - "legendgroup": legendgroup, - "showlegend": True if showlegend is None else showlegend, - } - ) - if legendtext is not None: - trace["name"] = legendtext - traces.append(trace) - - if np.array(input_obj.position).ndim > 1 and style.path.show: - scatter_path = make_path(input_obj, style, legendgroup, kwargs) - traces.append(scatter_path) - - return traces - - -def make_path(input_obj, style, legendgroup, kwargs): - """draw obj path based on path style properties""" - x, y, z = np.array(input_obj.position).T - txt_kwargs = ( - {"mode": "markers+text+lines", "text": list(range(len(x)))} - if style.path.numbering - else {"mode": "markers+lines"} - ) - marker = style.path.marker.as_dict() - symb = marker["symbol"] - marker["symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) - marker["color"] = kwargs["color"] if marker["color"] is None else marker["color"] - marker["size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] - line = style.path.line.as_dict() - dash = line["style"] - line["dash"] = LINESTYLES_MATPLOTLIB_TO_PLOTLY.get(dash, dash) - line["color"] = kwargs["color"] if line["color"] is None else line["color"] - line["width"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] - line = {k: v for k, v in line.items() if k != "style"} - scatter_path = dict( - type="scatter3d", - x=x, - y=y, - z=z, - name=f"Path: {input_obj}", - showlegend=False, - legendgroup=legendgroup, - marker=marker, - line=line, - **txt_kwargs, - opacity=kwargs["opacity"], - ) - return scatter_path - - -def draw_frame( - obj_list_semi_flat, color_sequence, zoom, autosize=None, output="dict", **kwargs -) -> Tuple: - """ - Creates traces from input `objs` and provided parameters, updates the size of objects like - Sensors and Dipoles in `kwargs` depending on the canvas size. - - Returns - ------- - traces_dicts, kwargs: dict, dict - returns the traces in a obj/traces_list dictionary and updated kwargs - """ - # pylint: disable=protected-access - return_autosize = False - Sensor = _src.obj_classes.class_Sensor.Sensor - Dipole = _src.obj_classes.class_misc_Dipole.Dipole - traces_out = {} - # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. - # autosize is calculated from the other traces overall scene range - traces_to_resize = {} - flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, color_sequence=color_sequence - ) - for obj, params in flat_objs_props.items(): - params.update(kwargs) - if isinstance(obj, (Dipole, Sensor)): - traces_to_resize[obj] = {**params} - # temporary coordinates to be able to calculate ranges - x, y, z = obj._position.T - traces_out[obj] = [dict(x=x, y=y, z=z)] - else: - traces_out[obj] = get_plotly_traces(obj, **params) - traces = [t for tr in traces_out.values() for t in tr] - ranges = get_scene_ranges(*traces, zoom=zoom) - if autosize is None or autosize == "return": - if autosize == "return": - return_autosize = True - autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor - for obj, params in traces_to_resize.items(): - traces_out[obj] = get_plotly_traces(obj, autosize=autosize, **params) - if output == "list": - traces = [t for tr in traces_out.values() for t in tr] - traces_out = group_traces(*traces) - if return_autosize: - res = traces_out, autosize - else: - res = traces_out - return res - - -def group_traces(*traces): - """Group and merge mesh traces with similar properties. This drastically improves - browser rendering performance when displaying a lot of mesh3d objects.""" - mesh_groups = {} - common_keys = ["legendgroup", "opacity"] - spec_keys = {"mesh3d": ["colorscale"], "scatter3d": ["marker", "line"]} - for tr in traces: - gr = [tr["type"]] - for k in common_keys + spec_keys[tr["type"]]: - try: - v = tr.get(k, "") - except AttributeError: - v = getattr(tr, k, "") - gr.append(str(v)) - gr = "".join(gr) - if gr not in mesh_groups: - mesh_groups[gr] = [] - mesh_groups[gr].append(tr) - - traces = [] - for key, gr in mesh_groups.items(): - if key.startswith("mesh3d") or key.startswith("scatter3d"): - tr = [merge_traces(*gr)] - else: - tr = gr - traces.extend(tr) - return traces - - -def apply_fig_ranges(fig, ranges=None, zoom=None): - """This is a helper function which applies the ranges properties of the provided `fig` object - according to a certain zoom level. All three space direction will be equal and match the - maximum of the ranges needed to display all objects, including their paths. - - Parameters - ---------- - ranges: array of dimension=(3,2) - min and max graph range - - zoom: float, default = 1 - When zoom=0 all objects are just inside the 3D-axes. - - Returns - ------- - None: NoneType - """ - if ranges is None: - frames = fig.frames if fig.frames else [fig] - traces = [t for frame in frames for t in frame.data] - ranges = get_scene_ranges(*traces, zoom=zoom) - fig.update_scenes( - **{ - f"{k}axis": dict(range=ranges[i], autorange=False, title=f"{k} [mm]") - for i, k in enumerate("xyz") - }, - aspectratio={k: 1 for k in "xyz"}, - aspectmode="manual", - camera_eye={"x": 1, "y": -1.5, "z": 1.4}, - ) - - -def get_scene_ranges(*traces, zoom=1) -> np.ndarray: - """ - Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be - any plotly trace object or a dict, with x,y,z numbered parameters. - """ - if traces: - ranges = {k: [] for k in "xyz"} - for t in traces: - for k, v in ranges.items(): - v.extend( - [ - np.nanmin(np.array(t[k], dtype=float)), - np.nanmax(np.array(t[k], dtype=float)), - ] - ) - r = np.array([[np.nanmin(v), np.nanmax(v)] for v in ranges.values()]) - size = np.diff(r, axis=1) - size[size == 0] = 1 - m = size.max() / 2 - center = r.mean(axis=1) - ranges = np.array([center - m * (1 + zoom), center + m * (1 + zoom)]).T - else: - ranges = np.array([[-1.0, 1.0]] * 3) - return ranges - - -def animate_path( - fig, - objs, - color_sequence=None, - zoom=1, - title="3D-Paths Animation", - animation_time=3, - animation_fps=30, - animation_maxfps=50, - animation_maxframes=200, - animation_slider=False, - **kwargs, -): - """This is a helper function which attaches plotly frames to the provided `fig` object - according to a certain zoom level. All three space direction will be equal and match the - maximum of the ranges needed to display all objects, including their paths. - - Parameters - ---------- - animation_time: float, default = 3 - Sets the animation duration - - animation_fps: float, default = 30 - This sets the maximum allowed frame rate. In case of path positions needed to be displayed - exceeds the `animation_fps` the path position will be downsampled to be lower or equal - the `animation_fps`. This is mainly depending on the pc/browser performance and is set to - 50 by default to avoid hanging the animation process. - - animation_slider: bool, default = False - if True, an interactive slider will be displayed and stay in sync with the animation - - title: str, default = "3D-Paths Animation" - When zoom=0 all objects are just inside the 3D-axes. - - color_sequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - - Returns - ------- - None: NoneTyp - """ - # make sure the number of frames does not exceed the max frames and max frame rate - # downsample if necessary - path_lengths = [] - for obj in objs: - subobjs = [obj] - if getattr(obj, "_object_type", None) == "Collection": - subobjs.extend(obj.children) - for subobj in subobjs: - path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] - path_lengths.append(path_len) - - max_pl = max(path_lengths) - if animation_fps > animation_maxfps: - warnings.warn( - f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" - f" {animation_maxfps}. `animation_fps` will be set to {animation_maxfps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxfps`" - ) - animation_fps = animation_maxfps - - maxpos = min(animation_time * animation_fps, animation_maxframes) - - if max_pl <= maxpos: - path_indices = np.arange(max_pl) - else: - round_step = max_pl / (maxpos - 1) - ar = np.linspace(0, max_pl, max_pl, endpoint=False) - path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( - int - ) # downsampled indices - path_indices[-1] = ( - max_pl - 1 - ) # make sure the last frame is the last path position - - # calculate exponent of last frame index to avoid digit shift in - # frame number display during animation - exp = ( - np.log10(path_indices.max()).astype(int) + 1 - if path_indices.ndim != 0 and path_indices.max() > 0 - else 1 - ) - - frame_duration = int(animation_time * 1000 / path_indices.shape[0]) - new_fps = int(1000 / frame_duration) - if max_pl > animation_maxframes: - warnings.warn( - f"The number of frames ({max_pl}) is greater than the max allowed " - f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxframes`" - ) - - if animation_slider: - sliders_dict = { - "active": 0, - "yanchor": "top", - "font": {"size": 10}, - "xanchor": "left", - "currentvalue": { - "prefix": f"Fps={new_fps}, Path index: ", - "visible": True, - "xanchor": "right", - }, - "pad": {"b": 10, "t": 10}, - "len": 0.9, - "x": 0.1, - "y": 0, - "steps": [], - } - - buttons_dict = { - "buttons": [ - { - "args": [ - None, - { - "frame": {"duration": frame_duration}, - "transition": {"duration": 0}, - "fromcurrent": True, - }, - ], - "label": "Play", - "method": "animate", - }, - { - "args": [[None], {"frame": {"duration": 0}, "mode": "immediate"}], - "label": "Pause", - "method": "animate", - }, - ], - "direction": "left", - "pad": {"r": 10, "t": 20}, - "showactive": False, - "type": "buttons", - "x": 0.1, - "xanchor": "right", - "y": 0, - "yanchor": "top", - } - - # create frame for each path index or downsampled path index - frames = [] - autosize = "return" - for i, ind in enumerate(path_indices): - kwargs["style_path_frames"] = [ind] - frame = draw_frame( - objs, - color_sequence, - zoom, - autosize=autosize, - output="list", - **kwargs, - ) - if i == 0: # get the dipoles and sensors autosize from first frame - traces, autosize = frame - else: - traces = frame - frames.append( - go.Frame( - data=traces, - name=str(ind + 1), - layout=dict(title=f"""{title} - path index: {ind+1:0{exp}d}"""), - ) - ) - if animation_slider: - slider_step = { - "args": [ - [str(ind + 1)], - { - "frame": {"duration": 0, "redraw": True}, - "mode": "immediate", - }, - ], - "label": str(ind + 1), - "method": "animate", - } - sliders_dict["steps"].append(slider_step) - - # update fig - fig.frames = frames - fig.add_traces(frames[0].data) - fig.update_layout( - height=None, - title=title, - updatemenus=[buttons_dict], - sliders=[sliders_dict] if animation_slider else None, - ) - apply_fig_ranges(fig, zoom=zoom) - - -def display_plotly( - *obj_list, - markers=None, - zoom=1, - fig=None, - renderer=None, - animation=False, - color_sequence=None, - **kwargs, -): - - """ - Display objects and paths graphically using the plotly library. - - Parameters - ---------- - objects: sources, collections or sensors - Objects to be displayed. - - markers: array_like, None, shape (N,3), default=None - Display position markers in the global CS. By default no marker is displayed. - - zoom: float, default = 1 - Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. - - fig: plotly Figure, default=None - Display graphical output in a given figure: - - plotly.graph_objects.Figure - - plotly.graph_objects.FigureWidget - By default a new `Figure` is created and displayed. - - renderer: str. default=None, - The renderers framework is a flexible approach for displaying plotly.py figures in a variety - of contexts. - Available renderers are: - ['plotly_mimetype', 'jupyterlab', 'nteract', 'vscode', - 'notebook', 'notebook_connected', 'kaggle', 'azure', 'colab', - 'cocalc', 'databricks', 'json', 'png', 'jpeg', 'jpg', 'svg', - 'pdf', 'browser', 'firefox', 'chrome', 'chromium', 'iframe', - 'iframe_connected', 'sphinx_gallery', 'sphinx_gallery_png'] - - title: str, default = "3D-Paths Animation" - When zoom=0 all objects are just inside the 3D-axes. - - color_sequence: list or array_like, iterable, default= - ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', - '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', - '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', - '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] - An iterable of color values used to cycle trough for every object displayed. - A color and may be specified as: - - A hex string (e.g. '#ff0000') - - An rgb/rgba string (e.g. 'rgb(255,0,0)') - - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') - - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') - - A named CSS color - - Returns - ------- - None: NoneType - """ - - flat_obj_list = format_obj_input(obj_list) - - show_fig = False - if fig is None: - show_fig = True - fig = go.Figure() - - # set animation and animation_time - if isinstance(animation, numbers.Number) and not isinstance(animation, bool): - kwargs["animation_time"] = animation - animation = True - if ( - not any( - getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list - ) - and animation is not False - ): # check if some path exist for any object - animation = False - warnings.warn("No path to be animated detected, displaying standard plot") - - animation_kwargs = { - k: v for k, v in kwargs.items() if k.split("_")[0] == "animation" - } - if animation is False: - kwargs = {k: v for k, v in kwargs.items() if k not in animation_kwargs} - else: - for k, v in Config.display.animation.as_dict().items(): - anim_key = f"animation_{k}" - if kwargs.get(anim_key, None) is None: - kwargs[anim_key] = v - - if obj_list: - style = getattr(obj_list[0], "style", None) - label = getattr(style, "label", None) - title = label if len(obj_list) == 1 else None - else: - title = "No objects to be displayed" - - if markers is not None and markers: - obj_list = list(obj_list) + [MagpyMarkers(*markers)] - - if color_sequence is None: - color_sequence = Config.display.colorsequence - - with fig.batch_update(): - if animation is not False: - title = "3D-Paths Animation" if title is None else title - animate_path( - fig=fig, - objs=obj_list, - color_sequence=color_sequence, - zoom=zoom, - title=title, - **kwargs, - ) - else: - traces = draw_frame(obj_list, color_sequence, zoom, output="list", **kwargs) - fig.add_traces(traces) - fig.update_layout(title_text=title) - apply_fig_ranges(fig, zoom=zoom) - clean_legendgroups(fig) - fig.update_layout(legend_itemsizing="constant") - if show_fig: - fig.show(renderer=renderer) diff --git a/magpylib/_src/display/plotly/plotly_utility.py b/magpylib/_src/display/plotly/plotly_utility.py deleted file mode 100644 index 92cebb96e..000000000 --- a/magpylib/_src/display/plotly/plotly_utility.py +++ /dev/null @@ -1,147 +0,0 @@ -"""utility functions for plotly backend""" -import numpy as np - - -def merge_mesh3d(*traces): - """Merges a list of plotly mesh3d dictionaries. The `i,j,k` index parameters need to cumulate - the indices of each object in order to point to the right vertices in the concatenated - vertices. `x,y,z,i,j,k` are mandatory fields, the `intensity` and `facecolor` parameters also - get concatenated if they are present in all objects. All other parameter found in the - dictionary keys are taken from the first object, other keys from further objects are ignored. - """ - merged_trace = {} - L = np.array([0] + [len(b["x"]) for b in traces[:-1]]).cumsum() - for k in "ijk": - if k in traces[0]: - merged_trace[k] = np.hstack([b[k] + l for b, l in zip(traces, L)]) - for k in "xyz": - merged_trace[k] = np.concatenate([b[k] for b in traces]) - for k in ("intensity", "facecolor"): - if k in traces[0] and traces[0][k] is not None: - merged_trace[k] = np.hstack([b[k] for b in traces]) - for k, v in traces[0].items(): - if k not in merged_trace: - merged_trace[k] = v - return merged_trace - - -def merge_scatter3d(*traces): - """Merges a list of plotly scatter3d. `x,y,z` are mandatory fields and are concatenated with a - `None` vertex to prevent line connection between objects to be concatenated. Keys are taken - from the first object, other keys from further objects are ignored. - """ - merged_trace = {} - for k in "xyz": - merged_trace[k] = np.hstack([pts for b in traces for pts in [[None], b[k]]]) - for k, v in traces[0].items(): - if k not in merged_trace: - merged_trace[k] = v - return merged_trace - - -def merge_traces(*traces): - """Merges a list of plotly 3d-traces. Supported trace types are `mesh3d` and `scatter3d`. - All traces have be of the same type when merging. Keys are taken from the first object, other - keys from further objects are ignored. - """ - if len(traces) > 1: - if traces[0]["type"] == "mesh3d": - trace = merge_mesh3d(*traces) - elif traces[0]["type"] == "scatter3d": - trace = merge_scatter3d(*traces) - elif len(traces) == 1: - trace = traces[0] - else: - trace = [] - return trace - - -def getIntensity(vertices, axis) -> np.ndarray: - """Calculates the intensity values for vertices based on the distance of the vertices to - the mean vertices position in the provided axis direction. It can be used for plotting - fields on meshes. If `mag` See more infos here:https://plotly.com/python/3d-mesh/ - - Parameters - ---------- - vertices : ndarray, shape (n,3) - The n vertices of the mesh object. - axis : ndarray, shape (3,) - Direction vector. - - Returns - ------- - Intensity values: ndarray, shape (n,) - """ - p = np.array(vertices).T - pos = np.mean(p, axis=1) - m = np.array(axis) - intensity = (p[0] - pos[0]) * m[0] + (p[1] - pos[1]) * m[1] + (p[2] - pos[2]) * m[2] - # normalize to interval [0,1] (necessary for when merging mesh3d traces) - ptp = np.ptp(intensity) - ptp = ptp if ptp != 0 else 1 - intensity = (intensity - np.min(intensity)) / ptp - return intensity - - -def getColorscale( - color_transition=0, - color_north="#E71111", # 'red' - color_middle="#DDDDDD", # 'grey' - color_south="#00B050", # 'green' -) -> list: - """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array - containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named - color string. At minimum, a mapping for the lowest (0) and highest (1) values is required. - For example, `[[0, 'rgb(0,0,255)'], [1,'rgb(255,0,0)']]`. In this case the colorscale - is created depending on the north/middle/south poles colors. If the middle color is - None, the colorscale will only have north and south pole colors. - - Parameters - ---------- - color_transition : float, default=0.1 - A value between 0 and 1. Sets the smoothness of the color transitions from adjacent colors - visualization. - color_north : str, default=None - Magnetic north pole color. - color_middle : str, default=None - Color of area between south and north pole. - color_south : str, default=None - Magnetic north pole color. - - Returns - ------- - colorscale: list - Colorscale as list of tuples. - """ - if color_middle is False: - colorscale = [ - [0.0, color_south], - [0.5 * (1 - color_transition), color_south], - [0.5 * (1 + color_transition), color_north], - [1, color_north], - ] - else: - colorscale = [ - [0.0, color_south], - [0.2 - 0.2 * (color_transition), color_south], - [0.2 + 0.3 * (color_transition), color_middle], - [0.8 - 0.3 * (color_transition), color_middle], - [0.8 + 0.2 * (color_transition), color_north], - [1.0, color_north], - ] - return colorscale - - -def clean_legendgroups(fig): - """removes legend duplicates""" - frames = [fig.data] - if fig.frames: - data_list = [f["data"] for f in fig.frames] - frames.extend(data_list) - for f in frames: - legendgroups = [] - for t in f: - if t.legendgroup not in legendgroups and t.legendgroup is not None: - legendgroups.append(t.legendgroup) - elif t.legendgroup is not None and t.legendgrouptitle.text is None: - t.showlegend = False diff --git a/magpylib/_src/display/plotly/plotly_sensor_mesh.py b/magpylib/_src/display/sensor_mesh.py similarity index 100% rename from magpylib/_src/display/plotly/plotly_sensor_mesh.py rename to magpylib/_src/display/sensor_mesh.py diff --git a/magpylib/_src/display/base_traces.py b/magpylib/_src/display/traces_base.py similarity index 96% rename from magpylib/_src/display/base_traces.py rename to magpylib/_src/display/traces_base.py index 3d74f342f..44fed8bc9 100644 --- a/magpylib/_src/display/base_traces.py +++ b/magpylib/_src/display/traces_base.py @@ -3,8 +3,8 @@ import numpy as np -from magpylib._src.display.display_utility import place_and_orient_model3d -from magpylib._src.display.plotly.plotly_utility import merge_mesh3d +from magpylib._src.display.traces_utility import merge_mesh3d +from magpylib._src.display.traces_utility import place_and_orient_model3d def base_validator(name, value, conditions): @@ -40,7 +40,7 @@ def get_model(trace, *, backend, show, scale, kwargs): def make_Cuboid( - backend, + backend="generic", dimension=(1.0, 1.0, 1.0), position=None, orientation=None, @@ -54,7 +54,8 @@ def make_Cuboid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. dimension : 3-tuple, default=(1,1,1) Length of the cuboid sides `x,y,z`. @@ -97,7 +98,7 @@ def make_Cuboid( def make_Prism( - backend, + backend="generic", base=3, diameter=1.0, height=1.0, @@ -114,7 +115,8 @@ def make_Prism( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. base : int, default=6 Number of vertices of the base in the xy-plane. @@ -186,7 +188,7 @@ def make_Prism( def make_Ellipsoid( - backend, + backend="generic", dimension=(1.0, 1.0, 1.0), vert=15, position=None, @@ -202,7 +204,8 @@ def make_Ellipsoid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. dimension : tuple, default=(1.0, 1.0, 1.0) Dimension in the `x,y,z` directions. @@ -266,7 +269,7 @@ def make_Ellipsoid( def make_CylinderSegment( - backend, + backend="generic", dimension=(1.0, 2.0, 1.0, 0.0, 90.0), vert=50, position=None, @@ -282,7 +285,8 @@ def make_CylinderSegment( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. dimension: array_like, shape (5,), default=`None` Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2) @@ -365,7 +369,7 @@ def make_CylinderSegment( def make_Pyramid( - backend, + backend="generic", base=3, diameter=1, height=1, @@ -383,7 +387,8 @@ def make_Pyramid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. base : int, default=30 Number of vertices of the cone base. @@ -445,7 +450,7 @@ def make_Pyramid( def make_Arrow( - backend, + backend="generic", base=3, diameter=0.3, height=1, @@ -463,7 +468,8 @@ def make_Arrow( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. base : int, default=30 Number of vertices of the arrow base. diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py new file mode 100644 index 000000000..7537adfb8 --- /dev/null +++ b/magpylib/_src/display/traces_generic.py @@ -0,0 +1,974 @@ +"""Generic trace drawing functionalities""" +# pylint: disable=C0302 +# pylint: disable=too-many-branches +import numbers +import warnings +from itertools import combinations +from typing import Tuple + +import numpy as np +from scipy.spatial.transform import Rotation as RotScipy + +from magpylib import _src +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.defaults.defaults_utility import linearize_dict +from magpylib._src.display.sensor_mesh import get_sensor_mesh +from magpylib._src.display.traces_base import make_Arrow as make_BaseArrow +from magpylib._src.display.traces_base import make_Cuboid as make_BaseCuboid +from magpylib._src.display.traces_base import ( + make_CylinderSegment as make_BaseCylinderSegment, +) +from magpylib._src.display.traces_base import make_Ellipsoid as make_BaseEllipsoid +from magpylib._src.display.traces_base import make_Prism as make_BasePrism +from magpylib._src.display.traces_utility import draw_arrow_from_vertices +from magpylib._src.display.traces_utility import draw_arrowed_circle +from magpylib._src.display.traces_utility import draw_arrowed_line +from magpylib._src.display.traces_utility import get_flatten_objects_properties +from magpylib._src.display.traces_utility import get_rot_pos_from_path +from magpylib._src.display.traces_utility import get_scene_ranges +from magpylib._src.display.traces_utility import getColorscale +from magpylib._src.display.traces_utility import getIntensity +from magpylib._src.display.traces_utility import group_traces +from magpylib._src.display.traces_utility import MagpyMarkers +from magpylib._src.display.traces_utility import merge_mesh3d +from magpylib._src.display.traces_utility import merge_traces +from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.input_checks import check_excitations +from magpylib._src.style import get_style +from magpylib._src.utility import format_obj_input +from magpylib._src.utility import unit_prefix + +AUTOSIZE_OBJECTS = ("Sensor", "Dipole") + + +def make_DefaultTrace( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly scatter3d parameters for an object with no specifically supported + representation. The object will be represented by a scatter point and text above with object + name. + """ + style = obj.style if style is None else style + trace = dict( + type="scatter3d", + x=[0.0], + y=[0.0], + z=[0.0], + mode="markers+text", + marker_size=10, + marker_color=color, + marker_symbol="diamond", + ) + update_trace_name(trace, f"{type(obj).__name__}", "", style) + trace["text"] = trace["name"] + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Line( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly scatter3d parameters for a Line current in a dictionary based on the + provided arguments. + """ + style = obj.style if style is None else style + current = obj.current + vertices = obj.vertices + show_arrows = style.arrow.show + arrow_size = style.arrow.size + if show_arrows: + vertices = draw_arrow_from_vertices(vertices, current, arrow_size) + else: + vertices = np.array(vertices).T + x, y, z = vertices + trace = dict( + type="scatter3d", + x=x, + y=y, + z=z, + mode="lines", + line_width=style.arrow.width, + line_color=color, + ) + default_suffix = ( + f" ({unit_prefix(current)}A)" + if current is not None + else " (Current not initialized)" + ) + update_trace_name(trace, "Line", default_suffix, style) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Loop( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + vertices=50, + **kwargs, +): + """ + Creates the plotly scatter3d parameters for a Loop current in a dictionary based on the + provided arguments. + """ + style = obj.style if style is None else style + current = obj.current + diameter = obj.diameter + arrow_size = style.arrow.size if style.arrow.show else 0 + vertices = draw_arrowed_circle(current, diameter, arrow_size, vertices) + x, y, z = vertices + trace = dict( + type="scatter3d", + x=x, + y=y, + z=z, + mode="lines", + line_width=style.arrow.width, + line_color=color, + ) + default_suffix = ( + f" ({unit_prefix(current)}A)" + if current is not None + else " (Current not initialized)" + ) + update_trace_name(trace, "Loop", default_suffix, style) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Dipole( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + autosize=None, + **kwargs, +) -> dict: + """ + Create the plotly mesh3d parameters for a Loop current in a dictionary based on the + provided arguments. + """ + style = obj.style if style is None else style + moment = obj.moment + moment_mag = np.linalg.norm(moment) + size = style.size + if autosize is not None: + size *= autosize + trace = make_BaseArrow( + "plotly-dict", + base=10, + diameter=0.3 * size, + height=size, + pivot=style.pivot, + color=color, + ) + default_suffix = f" (moment={unit_prefix(moment_mag)}mT mm³)" + update_trace_name(trace, "Dipole", default_suffix, style) + nvec = np.array(moment) / moment_mag + zaxis = np.array([0, 0, 1]) + cross = np.cross(nvec, zaxis) + n = np.linalg.norm(cross) + if n == 0: + n = 1 + cross = np.array([-np.sign(nvec[-1]), 0, 0]) + dot = np.dot(nvec, zaxis) + t = np.arccos(dot) + vec = -t * cross / n + mag_orient = RotScipy.from_rotvec(vec) + orientation = orientation * mag_orient + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Cuboid( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + **kwargs, +) -> dict: + """ + Create the plotly mesh3d parameters for a Cuboid Magnet in a dictionary based on the + provided arguments. + """ + style = obj.style if style is None else style + dimension = obj.dimension + d = [unit_prefix(d / 1000) for d in dimension] + trace = make_BaseCuboid("plotly-dict", dimension=dimension, color=color) + default_suffix = f" ({d[0]}m|{d[1]}m|{d[2]}m)" + update_trace_name(trace, "Cuboid", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization + ) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Cylinder( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + base=50, + **kwargs, +) -> dict: + """ + Create the plotly mesh3d parameters for a Cylinder Magnet in a dictionary based on the + provided arguments. + """ + style = obj.style if style is None else style + diameter, height = obj.dimension + d = [unit_prefix(d / 1000) for d in (diameter, height)] + trace = make_BasePrism( + "plotly-dict", base=base, diameter=diameter, height=height, color=color + ) + default_suffix = f" (D={d[0]}m, H={d[1]}m)" + update_trace_name(trace, "Cylinder", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization + ) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_CylinderSegment( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + vertices=25, + **kwargs, +): + """ + Create the plotly mesh3d parameters for a Cylinder Segment Magnet in a dictionary based on the + provided arguments. + """ + style = obj.style if style is None else style + dimension = obj.dimension + d = [unit_prefix(d / (1000 if i < 3 else 1)) for i, d in enumerate(dimension)] + trace = make_BaseCylinderSegment( + "plotly-dict", dimension=dimension, vert=vertices, color=color + ) + default_suffix = f" (r={d[0]}m|{d[1]}m, h={d[2]}m, φ={d[3]}°|{d[4]}°)" + update_trace_name(trace, "CylinderSegment", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization + ) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Sphere( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + vertices=15, + **kwargs, +) -> dict: + """ + Create the plotly mesh3d parameters for a Sphere Magnet in a dictionary based on the + provided arguments. + """ + style = obj.style if style is None else style + diameter = obj.diameter + vertices = min(max(vertices, 3), 20) + trace = make_BaseEllipsoid( + "plotly-dict", vert=vertices, dimension=[diameter] * 3, color=color + ) + default_suffix = f" (D={unit_prefix(diameter / 1000)}m)" + update_trace_name(trace, "Sphere", default_suffix, style) + update_magnet_mesh( + trace, mag_style=style.magnetization, magnetization=obj.magnetization + ) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Pixels(positions, size=1) -> dict: + """ + Create the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size + For now, only "cube" shape is provided. + """ + pixels = [ + make_BaseCuboid("plotly-dict", position=p, dimension=[size] * 3) + for p in positions + ] + return merge_mesh3d(*pixels) + + +def make_Sensor( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + autosize=None, + **kwargs, +): + """ + Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the + provided arguments. + + size_pixels: float, default=1 + A positive number. Adjusts automatic display size of sensor pixels. When set to 0, + pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum + distance between any pixel of the same sensor, equal to `size_pixel`. + """ + style = obj.style if style is None else style + dimension = getattr(obj, "dimension", style.size) + pixel = obj.pixel + pixel = np.array(pixel).reshape((-1, 3)) + style_arrows = style.arrows.as_dict(flatten=True, separator="_") + sensor = get_sensor_mesh(**style_arrows, center_color=color) + vertices = np.array([sensor[k] for k in "xyz"]).T + if color is not None: + sensor["facecolor"][sensor["facecolor"] == "rgb(238,238,238)"] = color + dim = np.array( + [dimension] * 3 if isinstance(dimension, (float, int)) else dimension[:3], + dtype=float, + ) + if autosize is not None: + dim *= autosize + if pixel.shape[0] == 1: + dim_ext = dim + else: + hull_dim = pixel.max(axis=0) - pixel.min(axis=0) + dim_ext = max(np.mean(dim), np.min(hull_dim)) + cube_mask = (vertices < 1).all(axis=1) + vertices[cube_mask] = 0 * vertices[cube_mask] + vertices[~cube_mask] = dim_ext * vertices[~cube_mask] + vertices /= 2 # sensor_mesh vertices are of length 2 + x, y, z = vertices.T + sensor.update(x=x, y=y, z=z) + meshes_to_merge = [sensor] + if pixel.shape[0] != 1: + pixel_color = style.pixel.color + pixel_size = style.pixel.size + combs = np.array(list(combinations(pixel, 2))) + vecs = np.diff(combs, axis=1) + dists = np.linalg.norm(vecs, axis=2) + pixel_dim = np.min(dists) / 2 + if pixel_size > 0: + pixel_dim *= pixel_size + pixels_mesh = make_Pixels(positions=pixel, size=pixel_dim) + pixels_mesh["facecolor"] = np.repeat(pixel_color, len(pixels_mesh["i"])) + meshes_to_merge.append(pixels_mesh) + hull_pos = 0.5 * (pixel.max(axis=0) + pixel.min(axis=0)) + hull_dim[hull_dim == 0] = pixel_dim / 2 + hull_mesh = make_BaseCuboid( + "plotly-dict", position=hull_pos, dimension=hull_dim + ) + hull_mesh["facecolor"] = np.repeat(color, len(hull_mesh["i"])) + meshes_to_merge.append(hull_mesh) + trace = merge_mesh3d(*meshes_to_merge) + default_suffix = ( + f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)""" + if pixel.ndim != 1 + else "" + ) + update_trace_name(trace, "Sensor", default_suffix, style) + return place_and_orient_model3d( + trace, orientation=orientation, position=position, **kwargs + ) + + +def make_Marker(obj, color=None, style=None, **kwargs): + """Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the + provided arguments.""" + style = obj.style if style is None else style + x, y, z = obj.markers.T + marker_kwargs = { + f"marker_{k}": v + for k, v in style.marker.as_dict(flatten=True, separator="_").items() + } + if marker_kwargs["marker_color"] is None: + marker_kwargs["marker_color"] = ( + style.color if style.color is not None else color + ) + trace = dict( + type="scatter3d", + x=x, + y=y, + z=z, + mode="markers", + **marker_kwargs, + **kwargs, + ) + default_name = "Marker" if len(x) == 1 else "Markers" + default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" + update_trace_name(trace, default_name, default_suffix, style) + return trace + + +def update_magnet_mesh(mesh_dict, mag_style=None, magnetization=None): + """ + Updates an existing plotly mesh3d dictionary of an object which has a magnetic vector. The + object gets colorized, positioned and oriented based on provided arguments. + """ + mag_color = mag_style.color + if magnetization is not None and mag_style.show: + vertices = np.array([mesh_dict[k] for k in "xyz"]).T + color_middle = mag_color.middle + if mag_color.mode == "tricycle": + color_middle = mesh_dict["color"] + elif mag_color.mode == "bicolor": + color_middle = False + mesh_dict["colorscale"] = getColorscale( + color_transition=mag_color.transition, + color_north=mag_color.north, + color_middle=color_middle, + color_south=mag_color.south, + ) + mesh_dict["intensity"] = getIntensity( + vertices=vertices, + axis=magnetization, + ) + mesh_dict["showscale"] = False + return mesh_dict + + +def update_trace_name(trace, default_name, default_suffix, style): + """provides legend entry based on name and suffix""" + name = default_name if style.label is None else style.label + if style.description.show and style.description.text is None: + name_suffix = default_suffix + elif not style.description.show: + name_suffix = "" + else: + name_suffix = f" ({style.description.text})" + trace.update(name=f"{name}{name_suffix}") + return trace + + +def make_mag_arrows(obj, style, legendgroup, kwargs): + """draw direction of magnetization of faced magnets + + Parameters + ---------- + - faced_objects(list of src objects): with magnetization vector to be drawn + - colors: colors of faced_objects + - show_path(bool or int): draw on every position where object is displayed + """ + # pylint: disable=protected-access + + # add src attributes position and orientation depending on show_path + rots, _, inds = get_rot_pos_from_path(obj, style.path.frames) + + # vector length, color and magnetization + if obj._object_type in ("Cuboid", "Cylinder"): + length = 1.8 * np.amax(obj.dimension) + elif obj._object_type == "CylinderSegment": + length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h + else: + length = 1.8 * obj.diameter # Sphere + length *= style.magnetization.size + mag = obj.magnetization + # collect all draw positions and directions + points = [] + for rot, ind in zip(rots, inds): + pos = getattr(obj, "_barycenter", obj._position)[ind] + direc = mag / (np.linalg.norm(mag) + 1e-6) * length + vec = rot.apply(direc) + pts = draw_arrowed_line(vec, pos, sign=1, arrow_pos=1, pivot="tail") + points.append(pts) + # insert empty point to avoid connecting line between arrows + points = np.array(points) + points = np.insert(points, points.shape[-1], np.nan, axis=2) + # remove last nan after insert with [:-1] + x, y, z = np.concatenate(points.swapaxes(1, 2))[:-1].T + trace = { + "type": "scatter3d", + "mode": "lines", + "line_color": kwargs["color"], + "opacity": kwargs["opacity"], + "x": x, + "y": y, + "z": z, + "legendgroup": legendgroup, + "showlegend": False, + } + return trace + + +def make_path(input_obj, style, legendgroup, kwargs): + """draw obj path based on path style properties""" + x, y, z = np.array(input_obj.position).T + txt_kwargs = ( + {"mode": "markers+text+lines", "text": list(range(len(x)))} + if style.path.numbering + else {"mode": "markers+lines"} + ) + marker = style.path.marker.as_dict() + marker["symbol"] = marker["symbol"] + marker["color"] = kwargs["color"] if marker["color"] is None else marker["color"] + line = style.path.line.as_dict() + line["dash"] = line["style"] + line["color"] = kwargs["color"] if line["color"] is None else line["color"] + line = {k: v for k, v in line.items() if k != "style"} + scatter_path = dict( + type="scatter3d", + x=x, + y=y, + z=z, + name=f"Path: {input_obj}", + showlegend=False, + legendgroup=legendgroup, + **{f"marker_{k}": v for k, v in marker.items()}, + **{f"line_{k}": v for k, v in line.items()}, + **txt_kwargs, + opacity=kwargs["opacity"], + ) + return scatter_path + + +def get_generic_traces( + input_obj, + make_func=None, + color=None, + autosize=None, + legendgroup=None, + showlegend=None, + legendtext=None, + mag_arrows=False, + extra_backend=False, + **kwargs, +) -> list: + """ + This is a helper function providing the plotly traces for any object of the magpylib library. If + the object is not supported, the trace representation will fall back to a single scatter point + with the object name marked above it. + + - If the object has a path (multiple positions), the function will return both the object trace + and the corresponding path trace. The legend entry of the path trace will be hidden but both + traces will share the same `legendgroup` so that a legend entry click will hide/show both traces + at once. From the user's perspective, the traces will be merged. + + - The argument caught by the kwargs dictionary must all be arguments supported both by + `scatter3d` and `mesh3d` plotly objects, otherwise an error will be raised. + """ + + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-nested-blocks + + # parse kwargs into style and non style args + style = get_style(input_obj, Config, **kwargs) + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("style")} + kwargs["style"] = style + style_color = getattr(style, "color", None) + kwargs["color"] = style_color if style_color is not None else color + kwargs["opacity"] = style.opacity + legendgroup = f"{input_obj}" if legendgroup is None else legendgroup + + # check excitations validity + for param in ("magnetization", "arrow"): + if getattr(getattr(style, param, None), "show", False): + check_excitations([input_obj]) + + label = getattr(getattr(input_obj, "style", None), "label", None) + label = label if label is not None else str(type(input_obj).__name__) + + object_type = getattr(input_obj, "_object_type", None) + if object_type != "Collection": + make_func = globals().get(f"make_{object_type}", make_DefaultTrace) + make_func_kwargs = kwargs.copy() + if object_type in AUTOSIZE_OBJECTS: + make_func_kwargs["autosize"] = autosize + + traces = [] + path_traces = [] + path_traces_extra_generic = {} + path_traces_extra_specific_backend = [] + has_path = hasattr(input_obj, "position") and hasattr(input_obj, "orientation") + if not has_path: + traces = [make_func(input_obj, **make_func_kwargs)] + out = (traces,) + if extra_backend is not False: + out += (path_traces_extra_specific_backend,) + return out[0] if len(out) == 1 else out + + extra_model3d_traces = style.model3d.data if style.model3d.data is not None else [] + orientations, positions, _ = get_rot_pos_from_path(input_obj, style.path.frames) + for pos_orient_ind, (orient, pos) in enumerate(zip(orientations, positions)): + if style.model3d.showdefault and make_func is not None: + path_traces.append( + make_func( + input_obj, position=pos, orientation=orient, **make_func_kwargs + ) + ) + for extr in extra_model3d_traces: + if extr.show: + extr.update(extr.updatefunc()) + if extr.backend == "generic": + trace3d = {"opacity": kwargs["opacity"]} + ttype = extr.constructor.lower() + obj_extr_trace = ( + extr.kwargs() if callable(extr.kwargs) else extr.kwargs + ) + obj_extr_trace = {"type": ttype, **obj_extr_trace} + if ttype == "scatter3d": + for k in ("marker", "line"): + trace3d[f"{k}_color"] = trace3d.get( + f"{k}_color", kwargs["color"] + ) + elif ttype == "mesh3d": + trace3d["showscale"] = trace3d.get("showscale", False) + if "facecolor" in obj_extr_trace: + ttype = "mesh3d_facecolor" + trace3d["color"] = trace3d.get("color", kwargs["color"]) + else: + raise ValueError( + f"{ttype} is not supported, only 'scatter3d' and 'mesh3d' are" + ) + trace3d.update( + linearize_dict( + place_and_orient_model3d( + model_kwargs=obj_extr_trace, + orientation=orient, + position=pos, + scale=extr.scale, + ), + separator="_", + ) + ) + if ttype not in path_traces_extra_generic: + path_traces_extra_generic[ttype] = [] + path_traces_extra_generic[ttype].append(trace3d) + elif extr.backend == extra_backend: + showleg = ( + showlegend + and pos_orient_ind == 0 + and not style.model3d.showdefault + ) + showleg = True if showleg is None else showleg + trace3d = { + "model3d": extr, + "position": pos, + "orientation": orient, + "kwargs": { + "opacity": kwargs["opacity"], + "color": kwargs["color"], + "legendgroup": legendgroup, + "name": label, + "showlegend": showleg, + }, + } + path_traces_extra_specific_backend.append(trace3d) + trace = merge_traces(*path_traces) + for ind, traces_extra in enumerate(path_traces_extra_generic.values()): + extra_model3d_trace = merge_traces(*traces_extra) + extra_model3d_trace.update( + { + "legendgroup": legendgroup, + "showlegend": showlegend and ind == 0 and not trace, + "name": label, + } + ) + traces.append(extra_model3d_trace) + + if trace: + trace.update( + { + "legendgroup": legendgroup, + "showlegend": True if showlegend is None else showlegend, + } + ) + if legendtext is not None: + trace["name"] = legendtext + traces.append(trace) + + if np.array(input_obj.position).ndim > 1 and style.path.show: + scatter_path = make_path(input_obj, style, legendgroup, kwargs) + traces.append(scatter_path) + + if mag_arrows and getattr(input_obj, "magnetization", None) is not None: + traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) + out = (traces,) + if extra_backend is not False: + out += (path_traces_extra_specific_backend,) + return out[0] if len(out) == 1 else out + + +def clean_legendgroups(frames): + """removes legend duplicates for a plotly figure""" + for fr in frames: + legendgroups = [] + for tr in fr["data"]: + lg = tr.get("legendgroup", None) + if lg is not None and lg not in legendgroups: + legendgroups.append(lg) + elif lg is not None: # and tr.legendgrouptitle.text is None: + tr["showlegend"] = False + + +def process_animation_kwargs(obj_list, animation=False, **kwargs): + """Update animation kwargs""" + markers = [o for o in obj_list if isinstance(o, MagpyMarkers)] + flat_obj_list = format_obj_input([o for o in obj_list if o not in markers]) + flat_obj_list.extend(markers) + # set animation and animation_time + if isinstance(animation, numbers.Number) and not isinstance(animation, bool): + kwargs["animation_time"] = animation + animation = True + if ( + not any( + getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list + ) + and animation is not False + ): # check if some path exist for any object + animation = False + warnings.warn("No path to be animated detected, displaying standard plot") + + anim_def = Config.display.animation.copy() + anim_def.update({k[10:]: v for k, v in kwargs.items()}, _match_properties=False) + animation_kwargs = {f"animation_{k}": v for k, v in anim_def.as_dict().items()} + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")} + return kwargs, animation, animation_kwargs + + +def extract_animation_properties( + objs, + *, + animation_maxfps, + animation_time, + animation_fps, + animation_maxframes, + # pylint: disable=unused-argument + animation_slider, +): + """Exctract animation properties""" + path_lengths = [] + for obj in objs: + subobjs = [obj] + if getattr(obj, "_object_type", None) == "Collection": + subobjs.extend(obj.children) + for subobj in subobjs: + path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] + path_lengths.append(path_len) + + max_pl = max(path_lengths) + if animation_fps > animation_maxfps: + warnings.warn( + f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" + f" {animation_maxfps}. `animation_fps` will be set to" + f" {animation_maxfps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxfps`" + ) + animation_fps = animation_maxfps + + maxpos = min(animation_time * animation_fps, animation_maxframes) + + if max_pl <= maxpos: + path_indices = np.arange(max_pl) + else: + round_step = max_pl / (maxpos - 1) + ar = np.linspace(0, max_pl, max_pl, endpoint=False) + path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( + int + ) # downsampled indices + path_indices[-1] = ( + max_pl - 1 + ) # make sure the last frame is the last path position + + # calculate exponent of last frame index to avoid digit shift in + # frame number display during animation + exp = ( + np.log10(path_indices.max()).astype(int) + 1 + if path_indices.ndim != 0 and path_indices.max() > 0 + else 1 + ) + + frame_duration = int(animation_time * 1000 / path_indices.shape[0]) + new_fps = int(1000 / frame_duration) + if max_pl > animation_maxframes: + warnings.warn( + f"The number of frames ({max_pl}) is greater than the max allowed " + f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxframes`" + ) + + return path_indices, exp, frame_duration + + +def draw_frame( + obj_list_semi_flat, + colorsequence=None, + zoom=0.0, + autosize=None, + output="dict", + mag_arrows=False, + extra_backend=False, + **kwargs, +) -> Tuple: + """ + Creates traces from input `objs` and provided parameters, updates the size of objects like + Sensors and Dipoles in `kwargs` depending on the canvas size. + + Returns + ------- + traces_dicts, kwargs: dict, dict + returns the traces in a obj/traces_list dictionary and updated kwargs + """ + # pylint: disable=protected-access + if colorsequence is None: + colorsequence = Config.display.colorsequence + extra_backend_traces = [] + Sensor = _src.obj_classes.class_Sensor.Sensor + Dipole = _src.obj_classes.class_misc_Dipole.Dipole + traces_out = {} + # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. + # autosize is calculated from the other traces overall scene range + traces_to_resize = {} + flat_objs_props = get_flatten_objects_properties( + *obj_list_semi_flat, colorsequence=colorsequence + ) + for obj, params in flat_objs_props.items(): + params.update(kwargs) + if isinstance(obj, (Dipole, Sensor)): + traces_to_resize[obj] = {**params} + # temporary coordinates to be able to calculate ranges + x, y, z = obj._position.T + traces_out[obj] = [dict(x=x, y=y, z=z)] + else: + out_traces = get_generic_traces( + obj, + mag_arrows=mag_arrows, + extra_backend=extra_backend, + **params, + ) + if extra_backend is not False: + out_traces, ebt = out_traces + extra_backend_traces.extend(ebt) + traces_out[obj] = out_traces + traces = [t for tr in traces_out.values() for t in tr] + ranges = get_scene_ranges(*traces, zoom=zoom) + if autosize is None or autosize == "return": + autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor + for obj, params in traces_to_resize.items(): + out_traces = get_generic_traces( + obj, + autosize=autosize, + mag_arrows=mag_arrows, + extra_backend=extra_backend, + **params, + ) + if extra_backend is not False: + out_traces, ebt = out_traces + extra_backend_traces.extend(ebt) + traces_out[obj] = out_traces + if output == "list": + traces = [t for tr in traces_out.values() for t in tr] + traces_out = group_traces(*traces) + return traces_out, autosize, ranges, extra_backend_traces + + +def get_frames( + objs, + colorsequence=None, + zoom=1, + title=None, + animation=False, + mag_arrows=False, + extra_backend=False, + **kwargs, +): + """This is a helper function which generates frames with generic traces to be provided to + the chosen backend. According to a certain zoom level, all three space direction will be equal + and match the maximum of the ranges needed to display all objects, including their paths. + """ + # infer title if necessary + if objs: + style = getattr(objs[0], "style", None) + label = getattr(style, "label", None) + title = label if len(objs) == 1 else None + else: + title = "No objects to be displayed" + + # make sure the number of frames does not exceed the max frames and max frame rate + # downsample if necessary + kwargs, animation, animation_kwargs = process_animation_kwargs( + objs, animation=animation, **kwargs + ) + path_indices = [-1] + if animation: + path_indices, exp, frame_duration = extract_animation_properties( + objs, **animation_kwargs + ) + + # create frame for each path index or downsampled path index + frames = [] + autosize = "return" + title_str = title + for i, ind in enumerate(path_indices): + extra_backend_traces = [] + if animation: + kwargs["style_path_frames"] = [ind] + title = "Animation 3D - " if title is None else title + title_str = f"""{title}path index: {ind+1:0{exp}d}""" + traces, autosize_init, ranges, extra_backend_traces = draw_frame( + objs, + colorsequence, + zoom, + autosize=autosize, + output="list", + mag_arrows=mag_arrows, + extra_backend=extra_backend, + **kwargs, + ) + if i == 0: # get the dipoles and sensors autosize from first frame + autosize = autosize_init + frames.append( + dict( + data=traces, + name=str(ind + 1), + layout=dict(title=title_str), + extra_backend_traces=extra_backend_traces, + ) + ) + + clean_legendgroups(frames) + traces = [t for frame in frames for t in frame["data"]] + ranges = get_scene_ranges(*traces, zoom=zoom) + out = { + "frames": frames, + "ranges": ranges, + } + if animation: + out.update( + { + "frame_duration": frame_duration, + "path_indices": path_indices, + "animation_slider": animation_kwargs["animation_slider"], + } + ) + return out diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py new file mode 100644 index 000000000..6eca71e61 --- /dev/null +++ b/magpylib/_src/display/traces_utility.py @@ -0,0 +1,483 @@ +""" Display function codes""" +from functools import lru_cache +from itertools import cycle +from typing import Tuple + +import numpy as np +from scipy.spatial.transform import Rotation as RotScipy + +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.defaults.defaults_utility import linearize_dict +from magpylib._src.style import Markers + + +class MagpyMarkers: + """A class that stores markers 3D-coordinates""" + + _object_type = "Marker" + + def __init__(self, *markers): + self.style = Markers() + self.markers = np.array(markers) + + +# pylint: disable=too-many-branches +def place_and_orient_model3d( + model_kwargs, + model_args=None, + orientation=None, + position=None, + coordsargs=None, + scale=1, + return_vertices=False, + return_model_args=False, + **kwargs, +): + """places and orients mesh3d dict""" + if orientation is None and position is None: + return {**model_kwargs, **kwargs} + position = (0.0, 0.0, 0.0) if position is None else position + position = np.array(position, dtype=float) + new_model_dict = {} + if model_args is None: + model_args = () + new_model_args = list(model_args) + if model_args: + if coordsargs is None: # matplotlib default + coordsargs = dict(x="args[0]", y="args[1]", z="args[2]") + vertices = [] + if coordsargs is None: + coordsargs = {"x": "x", "y": "y", "z": "z"} + useargs = False + for k in "xyz": + key = coordsargs[k] + if key.startswith("args"): + useargs = True + ind = int(key[5]) + v = model_args[ind] + else: + if key in model_kwargs: + v = model_kwargs[key] + else: + raise ValueError( + "Rotating/Moving of provided model failed, trace dictionary " + f"has no argument {k!r}, use `coordsargs` to specify the names of the " + "coordinates to be used.\n" + "Matplotlib backends will set up coordsargs automatically if " + "the `args=(xs,ys,zs)` argument is provided." + ) + vertices.append(v) + + vertices = np.array(vertices) + + # sometimes traces come as (n,m,3) shape + vert_shape = vertices.shape + vertices = np.reshape(vertices, (3, -1)) + + vertices = vertices.T + + if orientation is not None: + vertices = orientation.apply(vertices) + new_vertices = (vertices * scale + position).T + new_vertices = np.reshape(new_vertices, vert_shape) + for i, k in enumerate("xyz"): + key = coordsargs[k] + if useargs: + ind = int(key[5]) + new_model_args[ind] = new_vertices[i] + else: + new_model_dict[key] = new_vertices[i] + new_model_kwargs = {**model_kwargs, **new_model_dict, **kwargs} + + out = (new_model_kwargs,) + if return_model_args: + out += (new_model_args,) + if return_vertices: + out += (new_vertices,) + return out[0] if len(out) == 1 else out + + +def draw_arrowed_line( + vec, pos, sign=1, arrow_size=1, arrow_pos=0.5, pivot="middle" +) -> Tuple: + """ + Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and + centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and + moved to position `pos`. + """ + norm = np.linalg.norm(vec) + nvec = np.array(vec) / norm + yaxis = np.array([0, 1, 0]) + cross = np.cross(nvec, yaxis) + dot = np.dot(nvec, yaxis) + n = np.linalg.norm(cross) + arrow_shift = arrow_pos - 0.5 + if dot == -1: + sign *= -1 + hy = sign * 0.1 * arrow_size + hx = 0.06 * arrow_size + anchor = ( + (0, -0.5, 0) + if pivot == "tip" + else (0, 0.5, 0) + if pivot == "tail" + else (0, 0, 0) + ) + arrow = ( + np.array( + [ + [0, -0.5, 0], + [0, arrow_shift, 0], + [-hx, arrow_shift - hy, 0], + [0, arrow_shift, 0], + [hx, arrow_shift - hy, 0], + [0, arrow_shift, 0], + [0, 0.5, 0], + ] + + np.array(anchor) + ) + * norm + ) + if n != 0: + t = np.arccos(dot) + R = RotScipy.from_rotvec(-t * cross / n) + arrow = R.apply(arrow) + x, y, z = (arrow + pos).T + return x, y, z + + +def draw_arrow_from_vertices(vertices, current, arrow_size): + """returns scatter coordinates of arrows between input vertices""" + vectors = np.diff(vertices, axis=0) + positions = vertices[:-1] + vectors / 2 + vertices = np.concatenate( + [ + draw_arrowed_line(vec, pos, np.sign(current), arrow_size=arrow_size) + for vec, pos in zip(vectors, positions) + ], + axis=1, + ) + + return vertices + + +def draw_arrowed_circle(current, diameter, arrow_size, vert): + """draws an oriented circle with an arrow""" + t = np.linspace(0, 2 * np.pi, vert) + x = np.cos(t) + y = np.sin(t) + if arrow_size != 0: + hy = 0.2 * np.sign(current) * arrow_size + hx = 0.15 * arrow_size + x = np.hstack([x, [1 + hx, 1, 1 - hx]]) + y = np.hstack([y, [-hy, 0, -hy]]) + x = x * diameter / 2 + y = y * diameter / 2 + z = np.zeros(x.shape) + vertices = np.array([x, y, z]) + return vertices + + +def get_rot_pos_from_path(obj, show_path=None): + """ + subsets orientations and positions depending on `show_path` value. + examples: + show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6] + returns rots[[1,2,6]], poss[[1,2,6]] + """ + # pylint: disable=protected-access + # pylint: disable=invalid-unary-operand-type + if show_path is None: + show_path = True + pos = getattr(obj, "_position", None) + if pos is None: + pos = obj.position + pos = np.array(pos) + orient = getattr(obj, "_orientation", None) + if orient is None: + orient = getattr(obj, "orientation", None) + if orient is None: + orient = RotScipy.from_rotvec([[0, 0, 1]]) + pos = np.array([pos]) if pos.ndim == 1 else pos + path_len = pos.shape[0] + if show_path is True or show_path is False or show_path == 0: + inds = np.array([-1]) + elif isinstance(show_path, int): + inds = np.arange(path_len, dtype=int)[::-show_path] + elif hasattr(show_path, "__iter__") and not isinstance(show_path, str): + inds = np.array(show_path) + inds[inds >= path_len] = path_len - 1 + inds = np.unique(inds) + if inds.size == 0: + inds = np.array([path_len - 1]) + rots = orient[inds] + poss = pos[inds] + return rots, poss, inds + + +def get_flatten_objects_properties( + *obj_list_semi_flat, + colorsequence=None, + color_cycle=None, + **parent_props, +): + """returns a flat dict -> (obj: display_props, ...) from nested collections""" + if colorsequence is None: + colorsequence = Config.display.colorsequence + if color_cycle is None: + color_cycle = cycle(colorsequence) + flat_objs = {} + for subobj in obj_list_semi_flat: + isCollection = getattr(subobj, "children", None) is not None + props = {**parent_props} + parent_color = parent_props.get("color", "!!!missing!!!") + if parent_color == "!!!missing!!!": + props["color"] = next(color_cycle) + if parent_props.get("legendgroup", None) is None: + props["legendgroup"] = f"{subobj}" + if parent_props.get("showlegend", None) is None: + props["showlegend"] = True + if parent_props.get("legendtext", None) is None: + legendtext = None + if isCollection: + legendtext = getattr(getattr(subobj, "style", None), "label", None) + legendtext = f"{subobj!r}" if legendtext is None else legendtext + props["legendtext"] = legendtext + flat_objs[subobj] = props + if isCollection: + if subobj.style.color is not None: + flat_objs[subobj]["color"] = subobj.style.color + flat_objs.update( + get_flatten_objects_properties( + *subobj.children, + colorsequence=colorsequence, + color_cycle=color_cycle, + **flat_objs[subobj], + ) + ) + return flat_objs + + +def merge_mesh3d(*traces): + """Merges a list of plotly mesh3d dictionaries. The `i,j,k` index parameters need to cumulate + the indices of each object in order to point to the right vertices in the concatenated + vertices. `x,y,z,i,j,k` are mandatory fields, the `intensity` and `facecolor` parameters also + get concatenated if they are present in all objects. All other parameter found in the + dictionary keys are taken from the first object, other keys from further objects are ignored. + """ + merged_trace = {} + L = np.array([0] + [len(b["x"]) for b in traces[:-1]]).cumsum() + for k in "ijk": + if k in traces[0]: + merged_trace[k] = np.hstack([b[k] + l for b, l in zip(traces, L)]) + for k in "xyz": + merged_trace[k] = np.concatenate([b[k] for b in traces]) + for k in ("intensity", "facecolor"): + if k in traces[0] and traces[0][k] is not None: + merged_trace[k] = np.hstack([b[k] for b in traces]) + for k, v in traces[0].items(): + if k not in merged_trace: + merged_trace[k] = v + return merged_trace + + +def merge_scatter3d(*traces): + """Merges a list of plotly scatter3d. `x,y,z` are mandatory fields and are concatenated with a + `None` vertex to prevent line connection between objects to be concatenated. Keys are taken + from the first object, other keys from further objects are ignored. + """ + merged_trace = {} + for k in "xyz": + merged_trace[k] = np.hstack([pts for b in traces for pts in [[None], b[k]]]) + for k, v in traces[0].items(): + if k not in merged_trace: + merged_trace[k] = v + return merged_trace + + +def merge_traces(*traces): + """Merges a list of plotly 3d-traces. Supported trace types are `mesh3d` and `scatter3d`. + All traces have be of the same type when merging. Keys are taken from the first object, other + keys from further objects are ignored. + """ + if len(traces) > 1: + if traces[0]["type"] == "mesh3d": + trace = merge_mesh3d(*traces) + elif traces[0]["type"] == "scatter3d": + trace = merge_scatter3d(*traces) + elif len(traces) == 1: + trace = traces[0] + else: + trace = [] + return trace + + +def getIntensity(vertices, axis) -> np.ndarray: + """Calculates the intensity values for vertices based on the distance of the vertices to + the mean vertices position in the provided axis direction. It can be used for plotting + fields on meshes. If `mag` See more infos here:https://plotly.com/python/3d-mesh/ + + Parameters + ---------- + vertices : ndarray, shape (n,3) + The n vertices of the mesh object. + axis : ndarray, shape (3,) + Direction vector. + + Returns + ------- + Intensity values: ndarray, shape (n,) + """ + p = np.array(vertices).T + pos = np.mean(p, axis=1) + m = np.array(axis) + intensity = (p[0] - pos[0]) * m[0] + (p[1] - pos[1]) * m[1] + (p[2] - pos[2]) * m[2] + # normalize to interval [0,1] (necessary for when merging mesh3d traces) + ptp = np.ptp(intensity) + ptp = ptp if ptp != 0 else 1 + intensity = (intensity - np.min(intensity)) / ptp + return intensity + + +@lru_cache(maxsize=32) +def getColorscale( + color_transition=0, + color_north="#E71111", # 'red' + color_middle="#DDDDDD", # 'grey' + color_south="#00B050", # 'green' +) -> Tuple: + """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array + containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named + color string. At minimum, a mapping for the lowest (0) and highest (1) values is required. + For example, `[[0, 'rgb(0,0,255)'], [1,'rgb(255,0,0)']]`. In this case the colorscale + is created depending on the north/middle/south poles colors. If the middle color is + None, the colorscale will only have north and south pole colors. + + Parameters + ---------- + color_transition : float, default=0.1 + A value between 0 and 1. Sets the smoothness of the color transitions from adjacent colors + visualization. + color_north : str, default=None + Magnetic north pole color. + color_middle : str, default=None + Color of area between south and north pole. + color_south : str, default=None + Magnetic north pole color. + + Returns + ------- + colorscale: list + Colorscale as list of tuples. + """ + if color_middle is False: + colorscale = ( + (0.0, color_south), + (0.5 * (1 - color_transition), color_south), + (0.5 * (1 + color_transition), color_north), + (1, color_north), + ) + else: + colorscale = ( + (0.0, color_south), + (0.2 - 0.2 * (color_transition), color_south), + (0.2 + 0.3 * (color_transition), color_middle), + (0.8 - 0.3 * (color_transition), color_middle), + (0.8 + 0.2 * (color_transition), color_north), + (1.0, color_north), + ) + return colorscale + + +def get_scene_ranges(*traces, zoom=1) -> np.ndarray: + """ + Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be + any plotly trace object or a dict, with x,y,z numbered parameters. + """ + if traces: + ranges = {k: [] for k in "xyz"} + for t in traces: + for k, v in ranges.items(): + v.extend( + [ + np.nanmin(np.array(t[k], dtype=float)), + np.nanmax(np.array(t[k], dtype=float)), + ] + ) + r = np.array([[np.nanmin(v), np.nanmax(v)] for v in ranges.values()]) + size = np.diff(r, axis=1) + size[size == 0] = 1 + m = size.max() / 2 + center = r.mean(axis=1) + ranges = np.array([center - m * (1 + zoom), center + m * (1 + zoom)]).T + else: + ranges = np.array([[-1.0, 1.0]] * 3) + return ranges + + +def group_traces(*traces): + """Group and merge mesh traces with similar properties. This drastically improves + browser rendering performance when displaying a lot of mesh3d objects.""" + mesh_groups = {} + common_keys = ["legendgroup", "opacity"] + spec_keys = { + "mesh3d": ["colorscale"], + "scatter3d": [ + "marker", + "line_dash", + "line_color", + "line_width", + "marker_color", + "marker_symbol", + "marker_size", + "mode", + ], + } + for tr in traces: + tr = linearize_dict( + tr, + separator="_", + ) + gr = [tr["type"]] + for k in common_keys + spec_keys[tr["type"]]: + try: + v = tr.get(k, "") + except AttributeError: + v = getattr(tr, k, "") + gr.append(str(v)) + gr = "".join(gr) + if gr not in mesh_groups: + mesh_groups[gr] = [] + mesh_groups[gr].append(tr) + + traces = [] + for key, gr in mesh_groups.items(): + if key.startswith("mesh3d") or key.startswith("scatter3d"): + tr = [merge_traces(*gr)] + else: + tr = gr + traces.extend(tr) + return traces + + +def subdivide_mesh_by_facecolor(trace): + """Subdivide a mesh into a list of meshes based on facecolor""" + facecolor = trace["facecolor"] + subtraces = [] + # pylint: disable=singleton-comparison + facecolor[facecolor == np.array(None)] = "black" + for color in np.unique(facecolor): + mask = facecolor == color + new_trace = trace.copy() + uniq = np.unique(np.hstack([trace[k][mask] for k in "ijk"])) + new_inds = np.arange(len(uniq)) + mapping_ar = np.zeros(uniq.max() + 1, dtype=new_inds.dtype) + mapping_ar[uniq] = new_inds + for k in "ijk": + new_trace[k] = mapping_ar[trace[k][mask]] + for k in "xyz": + new_trace[k] = new_trace[k][uniq] + new_trace["color"] = color + new_trace.pop("facecolor") + subtraces.append(new_trace) + return subtraces diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 243a969c5..229141711 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -7,13 +7,13 @@ from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings +from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibMissingInput from magpylib._src.utility import format_obj_input from magpylib._src.utility import Registered from magpylib._src.utility import wrong_obj_msg - ################################################################# ################################################################# # FUNDAMENTAL CHECKS @@ -411,12 +411,13 @@ def check_format_input_cylinder_segment(inp): def check_format_input_backend(inp): """checks show-backend input and returns Non if bad input value""" + backends = SUPPORTED_PLOTTING_BACKENDS if inp is None: inp = default_settings.display.backend - if inp in ("matplotlib", "plotly"): + if inp in backends: return inp raise MagpylibBadUserInput( - "Input parameter `backend` must be one of `('matplotlib', 'plotly', None)`.\n" + f"Input parameter `backend` must be one of `{backends+(None,)}`.\n" f"Instead received {inp}." ) diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 412d08f32..adfb87e16 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -285,7 +285,8 @@ def add_trace(self, trace=None, **kwargs): pairs, or a callable returning the equivalent dictionary. backend: str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -328,7 +329,8 @@ class Trace3d(MagicProperties): Parameters ---------- backend: str - Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -481,14 +483,16 @@ def coordsargs(self, val): @property def backend(self): - """Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`.""" + """Plotting backend corresponding to the trace. Can be one of + `['generic', 'matplotlib', 'plotly']`.""" return self._backend @backend.setter def backend(self, val): - assert val is None or val in SUPPORTED_PLOTTING_BACKENDS, ( + backends = ["generic"] + list(SUPPORTED_PLOTTING_BACKENDS) + assert val is None or val in backends, ( f"The `backend` property of {type(self).__name__} must be one of" - f"{SUPPORTED_PLOTTING_BACKENDS},\n" + f"{backends},\n" f"but received {repr(val)} instead." ) self._backend = val diff --git a/magpylib/graphics/model3d/__init__.py b/magpylib/graphics/model3d/__init__.py index 9fefd8ce5..f6ad0f308 100644 --- a/magpylib/graphics/model3d/__init__.py +++ b/magpylib/graphics/model3d/__init__.py @@ -13,7 +13,7 @@ "make_Prism", ] -from magpylib._src.display.base_traces import ( +from magpylib._src.display.traces_base import ( make_Arrow, make_Ellipsoid, make_Pyramid, diff --git a/tests/test_Coumpound_setters.py b/tests/test_Coumpound_setters.py index bb5c587f0..3bacec196 100644 --- a/tests/test_Coumpound_setters.py +++ b/tests/test_Coumpound_setters.py @@ -8,7 +8,7 @@ from scipy.spatial.transform import Rotation as R import magpylib as magpy -from magpylib._src.display.base_traces import make_Prism +from magpylib._src.display.traces_base import make_Prism magpy.defaults.display.backend = "plotly" diff --git a/tests/test_default_utils.py b/tests/test_default_utils.py index c43afd207..1f0c4d63e 100644 --- a/tests/test_default_utils.py +++ b/tests/test_default_utils.py @@ -107,7 +107,7 @@ def test_linearize_dict(): ((127, 127, 127), True, "#7f7f7f"), ("rgb(127, 127, 127)", True, "#7f7f7f"), ((0, 0, 0, 0), False, "#000000"), - ((.1, .2, .3), False, "#19334c"), + ((0.1, 0.2, 0.3), False, "#19334c"), ] + [(shortC, True, longC) for shortC, longC in COLORS_MATPLOTLIB_TO_PLOTLY.items()], ) diff --git a/tests/test_defaults.py b/tests/test_defaults.py index 84d88529c..f2f7c96da 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -3,9 +3,11 @@ import magpylib as magpy from magpylib._src.defaults.defaults_classes import DefaultConfig from magpylib._src.defaults.defaults_utility import LINESTYLES_MATPLOTLIB_TO_PLOTLY +from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.defaults.defaults_utility import SYMBOLS_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import DisplayStyle + bad_inputs = { "display_autosizefactor": (0,), # float>0 "display_animation_maxfps": (0,), # int>0 @@ -98,7 +100,7 @@ def test_defaults_bad_inputs(key, value, expected_errortype): "display_animation_time": (10,), # int>0 "display_animation_maxframes": (200,), # int>0 "display_animation_slider": (True, False), # bool - "display_backend": ("matplotlib", "plotly"), # str typo + "display_backend": tuple(SUPPORTED_PLOTTING_BACKENDS), # str typo "display_colorsequence": ( ["#2E91E5", "#0D2A63"], ["blue", "red"], diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py index 436595ad3..ca8e258a8 100644 --- a/tests/test_display_matplotlib.py +++ b/tests/test_display_matplotlib.py @@ -159,15 +159,6 @@ def test_circular_line_display(): assert x is None, "display test fail" -def test_matplotlib_animation_warning(): - """animation=True with matplotlib should raise UserWarning""" - ax = plt.subplot(projection="3d") - sens = magpy.Sensor(pixel=[(1, 2, 3), (2, 3, 4)]) - sens.move(np.linspace((0.4, 0.4, 0.4), (12.4, 12.4, 12.4), 33), start=-1) - with pytest.warns(UserWarning): - sens.show(canvas=ax, animation=True) - - def test_matplotlib_model3d_extra(): """test display extra model3d""" diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index ee45c0123..e17460ad8 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -3,7 +3,7 @@ import pytest import magpylib as magpy -from magpylib._src.display.plotly.plotly_display import get_plotly_traces +from magpylib._src.display.traces_generic import get_generic_traces from magpylib._src.exceptions import MagpylibBadUserInput from magpylib.magnet import Cuboid from magpylib.magnet import Cylinder @@ -164,38 +164,6 @@ def test_display_bad_style_kwargs(): magpy.show(canvas=fig, markers=[(1, 2, 3)], style_bad_style_kwarg=None) -def test_draw_unsupported_obj(): - """test if a object which is not directly supported by magpylib can be plotted""" - magpy.defaults.display.backend = "plotly" - - class UnkwnownNoPosition: - """Dummy Class""" - - class Unkwnown1DPosition: - """Dummy Class""" - - position = [0, 0, 0] - - class Unkwnown2DPosition: - """Dummy Class""" - - position = [[0, 0, 0]] - orientation = None - - with pytest.raises(AttributeError): - get_plotly_traces(UnkwnownNoPosition()) - - traces = get_plotly_traces(Unkwnown1DPosition) - assert ( - traces[0]["type"] == "scatter3d" - ), "make trace has failed, should be 'scatter3d'" - - traces = get_plotly_traces(Unkwnown2DPosition) - assert ( - traces[0]["type"] == "scatter3d" - ), "make trace has failed, should be 'scatter3d'" - - def test_extra_model3d(): """test diplay when object has an extra model object attached""" magpy.defaults.display.backend = "plotly" diff --git a/tests/test_display_utility.py b/tests/test_display_utility.py index 3f7c2581d..fd9f96e12 100644 --- a/tests/test_display_utility.py +++ b/tests/test_display_utility.py @@ -2,7 +2,7 @@ import pytest import magpylib as magpy -from magpylib._src.display.display_utility import draw_arrow_from_vertices +from magpylib._src.display.traces_utility import draw_arrow_from_vertices from magpylib._src.exceptions import MagpylibBadUserInput diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index ffa09d4dc..25d598ee5 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -1,8 +1,8 @@ import sys from unittest import mock -import pytest import numpy as np +import pytest import magpylib as magpy From f04c1ff78ed74056495d57ef04dc3ee57fa4f5c6 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 8 Jul 2022 20:47:11 +0200 Subject: [PATCH 183/207] fix bad pull from display-rework --- docs/examples/examples_13_3d_models.md | 89 +- magpylib/__init__.py | 1 - magpylib/_src/defaults/defaults_classes.py | 6 +- magpylib/_src/defaults/defaults_utility.py | 2 +- magpylib/_src/display/backend_matplotlib.py | 197 --- magpylib/_src/display/backend_plotly.py | 265 ---- .../{traces_base.py => base_traces.py} | 34 +- magpylib/_src/display/display.py | 62 +- ...atplotlib_old.py => display_matplotlib.py} | 315 +---- magpylib/_src/display/display_utility.py | 504 +++++++ magpylib/_src/display/plotly/__init__.py | 1 + .../_src/display/plotly/plotly_display.py | 1196 +++++++++++++++++ .../plotly_sensor_mesh.py} | 0 .../_src/display/plotly/plotly_utility.py | 147 ++ magpylib/_src/display/traces_generic.py | 974 -------------- magpylib/_src/display/traces_utility.py | 483 ------- magpylib/_src/input_checks.py | 7 +- magpylib/_src/style.py | 14 +- magpylib/graphics/model3d/__init__.py | 2 +- tests/test_Coumpound_setters.py | 2 +- tests/test_default_utils.py | 2 +- tests/test_defaults.py | 4 +- tests/test_display_matplotlib.py | 9 + tests/test_display_plotly.py | 34 +- tests/test_display_utility.py | 2 +- tests/test_getBH_interfaces.py | 2 +- 26 files changed, 2040 insertions(+), 2314 deletions(-) delete mode 100644 magpylib/_src/display/backend_matplotlib.py delete mode 100644 magpylib/_src/display/backend_plotly.py rename magpylib/_src/display/{traces_base.py => base_traces.py} (96%) rename magpylib/_src/display/{backend_matplotlib_old.py => display_matplotlib.py} (64%) create mode 100644 magpylib/_src/display/display_utility.py create mode 100644 magpylib/_src/display/plotly/__init__.py create mode 100644 magpylib/_src/display/plotly/plotly_display.py rename magpylib/_src/display/{sensor_mesh.py => plotly/plotly_sensor_mesh.py} (100%) create mode 100644 magpylib/_src/display/plotly/plotly_utility.py delete mode 100644 magpylib/_src/display/traces_generic.py delete mode 100644 magpylib/_src/display/traces_utility.py diff --git a/docs/examples/examples_13_3d_models.md b/docs/examples/examples_13_3d_models.md index 39ee4664e..f111317eb 100644 --- a/docs/examples/examples_13_3d_models.md +++ b/docs/examples/examples_13_3d_models.md @@ -18,20 +18,20 @@ kernelspec: (examples-own-3d-models)= ## Custom 3D models -Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. When using the `'generic'` backend, custom traces are automatically translated into any other backend. If a specific backend is used, it will only show when called with the corresponding backend. +Each Magpylib object has a default 3D representation that is displayed with `show`. Users can add a custom 3D model to any Magpylib object with help of the `style.model3d.add_trace` method. The new trace is stored in `style.model3d.data`. User-defined traces move with the object just like the default models do. The default trace can be hidden with the command `obj.model3d.showdefault=False`. The input `trace` is a dictionary which includes all necessary information for plotting or a `magpylib.graphics.Trace3d` object. A `trace` dictionary has the following keys: -1. `'backend'`: `'generic'`, `'matplotlib'` or `'plotly'` +1. `'backend'`: `'matplotlib'` or `'plotly'` 2. `'constructor'`: name of the plotting constructor from the respective backend, e.g. plotly `'Mesh3d'` or matplotlib `'plot_surface'` 3. `'args'`: default `None`, positional arguments handed to constructor 4. `'kwargs'`: default `None`, keyword arguments handed to constructor -5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the `'generic'` backend and Plotly backend, and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend. +5. `'coordsargs'`: tells magpylib which input corresponds to which coordinate direction, so that geometric representation becomes possible. By default `{'x': 'x', 'y': 'y', 'z': 'z'}` for the Plotly backend and `{'x': 'args[0]', 'y': 'args[1]', 'z': 'args[2]'}` for the Matplotlib backend. 6. `'show'`: default `True`, toggle if this trace should be displayed 7. `'scale'`: default 1, object geometric scaling factor 8. `'updatefunc'`: default `None`, updates the trace parameters when `show` is called. Used to generate dynamic traces. -The following example shows how a **generic** trace is constructed with `Mesh3d` and `Scatter3d`: +The following example shows how a **Plotly** trace is constructed with `Mesh3d` and `Scatter3d`: ```{code-cell} ipython3 import numpy as np @@ -40,7 +40,7 @@ import magpylib as magpy # Mesh3d trace ######################### trace_mesh3d = { - 'backend': 'generic', + 'backend': 'plotly', 'constructor': 'Mesh3d', 'kwargs': { 'x': (1, 0, -1, 0), @@ -59,7 +59,7 @@ coll.style.model3d.add_trace(trace_mesh3d) ts = np.linspace(0, 2*np.pi, 30) trace_scatter3d = { - 'backend': 'generic', + 'backend': 'plotly', 'constructor': 'Scatter3d', 'kwargs': { 'x': np.cos(ts), @@ -68,7 +68,7 @@ trace_scatter3d = { 'mode': 'lines', } } -dipole = magpy.misc.Dipole(moment=(0,0,1), style_label="'Scatter3d' trace", style_size=6) +dipole = magpy.misc.Dipole(moment=(0,0,1), style_label="'Scatter3d' trace") dipole.style.model3d.add_trace(trace_scatter3d) magpy.show(coll, dipole, backend='plotly') @@ -94,7 +94,7 @@ trace3.kwargs['z'] = np.cos(ts) dipole.style.model3d.add_trace(trace3) -dipole.show(dipole, backend='matplotlib') +dipole.show(dipole, backend='plotly') ``` **Matplotlib** plotting functions often use positional arguments for $(x,y,z)$ input, that are handed over from `args=(x,y,z)` in `trace`. The following examples show how to construct traces with `plot`, `plot_surface` and `plot_trisurf`: @@ -160,12 +160,12 @@ trace_trisurf = { mobius = magpy.misc.CustomSource(style_model3d_showdefault=False, position=(3,0,0)) mobius.style.model3d.add_trace(trace_trisurf) -magpy.show(magnet, ball, mobius, zoom=2) +magpy.show(magnet, ball, mobius) ``` ## Pre-defined 3D models -Automatic trace generators are provided for several basic 3D models in `magpylib.graphics.model3d`. If no backend is specified, it defaults back to `'generic'`. They can be used as follows, +Automatic trace generators are provided for several 3D models in `magpylib.graphics.model3d`. They can be used as follows, ```{code-cell} ipython3 import magpylib as magpy @@ -173,6 +173,7 @@ from magpylib.graphics import model3d # prism trace ################################### trace_prism = model3d.make_Prism( + backend='plotly', base=6, diameter=2, height=1, @@ -183,6 +184,7 @@ obj0.style.model3d.add_trace(trace_prism) # pyramid trace ################################# trace_pyramid = model3d.make_Pyramid( + backend='plotly', base=30, diameter=2, height=1, @@ -193,6 +195,7 @@ obj1.style.model3d.add_trace(trace_pyramid) # cuboid trace ################################## trace_cuboid = model3d.make_Cuboid( + backend='plotly', dimension=(2,2,2), position=(0,3,0), ) @@ -201,6 +204,7 @@ obj2.style.model3d.add_trace(trace_cuboid) # cylinder segment trace ######################## trace_cylinder_segment = model3d.make_CylinderSegment( + backend='plotly', dimension=(1, 2, 1, 140, 220), position=(1,0,-3), ) @@ -209,6 +213,7 @@ obj3.style.model3d.add_trace(trace_cylinder_segment) # ellipsoid trace ############################### trace_ellipsoid = model3d.make_Ellipsoid( + backend='plotly', dimension=(2,2,2), position=(0,0,3), ) @@ -217,6 +222,7 @@ obj4.style.model3d.add_trace(trace_ellipsoid) # arrow trace ################################### trace_arrow = model3d.make_Arrow( + backend='plotly', base=30, diameter=0.6, height=2, @@ -232,7 +238,7 @@ magpy.show(obj0, obj1, obj2, obj3, obj4, obj5, backend='plotly') ## Adding a CAD model -As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a generic Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. +As shown in {ref}`examples-3d-models`, it is possible to attach custom 3D model representations to any Magpylib object. In the example below we show how a standard CAD model can be transformed into a Magpylib graphic trace, and displayed by both `matplotlib` and `plotly` backends. ```{note} The code below requires installation of the `numpy-stl` package. @@ -245,20 +251,18 @@ import requests import numpy as np from stl import mesh # requires installation of numpy-stl import magpylib as magpy -from matplotlib.colors import to_hex -def bin_color_to_hex(x): - """ transform binary rgb into hex color""" +def get_stl_color(x): + """ transform stl_mesh attr to plotly color""" sb = f"{x:015b}"[::-1] - r = int(sb[:5], base=2)/31 - g = int(sb[5:10], base=2)/31 - b = int(sb[10:15], base=2)/31 - return to_hex((r,g,b)) - + r = int(255 / 31 * int(sb[:5], base=2)) + g = int(255 / 31 * int(sb[5:10], base=2)) + b = int(255 / 31 * int(sb[10:15], base=2)) + return f"rgb({r},{g},{b})" -def trace_from_stl(stl_file): +def trace_from_stl(stl_file, backend='matplotlib'): """ Generates a Magpylib 3D model trace dictionary from an *.stl file. backend: 'matplotlib' or 'plotly' @@ -274,14 +278,26 @@ def trace_from_stl(stl_file): k = np.take(ixr, [3 * k + 2 for k in range(p)]) x, y, z = vertices.T - # generate and return a generic trace which can be translated into any backend - colors = stl_mesh.attr.flatten() - facecolor = np.array([bin_color_to_hex(c) for c in colors]).T - trace = { - 'backend': 'generic', - 'constructor': 'mesh3d', - 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), - } + # generate and return Magpylib traces + if backend == 'matplotlib': + triangles = np.array([i, j, k]).T + trace = { + 'backend': 'matplotlib', + 'constructor': 'plot_trisurf', + 'args': (x, y, z), + 'kwargs': {'triangles': triangles}, + } + elif backend == 'plotly': + colors = stl_mesh.attr.flatten() + facecolor = np.array([get_stl_color(c) for c in colors]).T + trace = { + 'backend': 'plotly', + 'constructor': 'Mesh3d', + 'kwargs': dict(x=x, y=y, z=z, i=i, j=j, k=k, facecolor=facecolor), + } + else: + raise ValueError("Backend must be one of ['matplotlib', 'plotly'].") + return trace @@ -295,20 +311,21 @@ with tempfile.TemporaryDirectory() as temp: f.write(response.content) # create traces for both backends - trace = trace_from_stl(fn) + trace_mpl = trace_from_stl(fn, backend='matplotlib') + trace_ply = trace_from_stl(fn, backend='plotly') # create sensor and add CAD model sensor = magpy.Sensor(style_label='PG-SSO-3 package') -sensor.style.model3d.add_trace(trace) +sensor.style.model3d.add_trace(trace_mpl) +sensor.style.model3d.add_trace(trace_ply) # create magnet and sensor path magnet = magpy.magnet.Cylinder(magnetization=(0,0,100), dimension=(15,20)) sensor.position = np.linspace((-15,0,8), (-15,0,-4), 21) -sensor.rotate_from_angax(np.linspace(0, 180, 21), 'z', anchor=0, start=0) +sensor.rotate_from_angax(np.linspace(0, 200, 21), 'z', anchor=0, start=0) -# display with matplotlib and plotly backends -args = (sensor, magnet) -kwargs = dict(style_path_frames=5) -magpy.show(args, **kwargs, backend="matplotlib") -magpy.show(args, **kwargs, backend="plotly") +# display with both backends +magpy.show(sensor, magnet, style_path_frames=5, style_magnetization_show=False) +magpy.show(sensor, magnet, style_path_frames=5, backend="plotly") ``` + diff --git a/magpylib/__init__.py b/magpylib/__init__.py index 18aee2dc1..69d9ce4ea 100644 --- a/magpylib/__init__.py +++ b/magpylib/__init__.py @@ -49,7 +49,6 @@ ] # create interface to outside of package -from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib import magnet, current, misc, core, graphics from magpylib._src.defaults.defaults_classes import default_settings as defaults from magpylib._src.fields import getB, getH diff --git a/magpylib/_src/defaults/defaults_classes.py b/magpylib/_src/defaults/defaults_classes.py index 478d77901..6730d90d8 100644 --- a/magpylib/_src/defaults/defaults_classes.py +++ b/magpylib/_src/defaults/defaults_classes.py @@ -50,8 +50,7 @@ class Display(MagicProperties): ---------- backend: str, default='matplotlib' Defines the plotting backend to be used by default, if not explicitly set in the `display` - function (e.g. 'matplotlib', 'plotly'). - Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS + function. Can be one of `['matplotlib', 'plotly']` colorsequence: iterable, default= ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', @@ -81,8 +80,7 @@ class Display(MagicProperties): @property def backend(self): """plotting backend to be used by default, if not explicitly set in the `display` - function (e.g. 'matplotlib', 'plotly'). - Supported backends are defined in magpylib.SUPPORTED_PLOTTING_BACKENDS""" + function. Can be one of `['matplotlib', 'plotly']`""" return self._backend @backend.setter diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index 067b0fef5..d16687bd2 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -5,7 +5,7 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "matplotlib_old") +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly") SYMBOLS_MATPLOTLIB_TO_PLOTLY = { diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py deleted file mode 100644 index ee672156d..000000000 --- a/magpylib/_src/display/backend_matplotlib.py +++ /dev/null @@ -1,197 +0,0 @@ -import matplotlib.pyplot as plt -import numpy as np -from matplotlib.animation import FuncAnimation - -from magpylib._src.display.traces_generic import get_frames -from magpylib._src.display.traces_utility import place_and_orient_model3d -from magpylib._src.display.traces_utility import subdivide_mesh_by_facecolor - -# from magpylib._src.utility import format_obj_input - -SYMBOLS = {"circle": "o", "cross": "+", "diamond": "d", "square": "s", "x": "x"} - -LINE_STYLES = { - "solid": "-", - "dash": "--", - "dashdot": "-.", - "dot": (0, (1, 1)), - "longdash": "loosely dotted", - "longdashdot": "loosely dashdotted", -} - - -def generic_trace_to_matplotlib(trace): - """Transform a generic trace into a matplotlib trace""" - traces_mpl = [] - if trace["type"] == "mesh3d": - subtraces = [trace] - if trace.get("facecolor", None) is not None: - subtraces = subdivide_mesh_by_facecolor(trace) - for subtrace in subtraces: - x, y, z = np.array([subtrace[k] for k in "xyz"], dtype=float) - triangles = np.array([subtrace[k] for k in "ijk"]).T - traces_mpl.append( - { - "constructor": "plot_trisurf", - "args": (x, y, z), - "kwargs": { - "triangles": triangles, - "alpha": subtrace.get("opacity", None), - "color": subtrace.get("color", None), - }, - } - ) - elif trace["type"] == "scatter3d": - x, y, z = np.array([trace[k] for k in "xyz"], dtype=float) - mode = trace.get("mode", None) - props = { - k: trace.get(v[0], {}).get(v[1], trace.get("_".join(v), None)) - for k, v in { - "ls": ("line", "dash"), - "lw": ("line", "width"), - "color": ("line", "color"), - "marker": ("marker", "symbol"), - "mfc": ("marker", "color"), - "mec": ("marker", "color"), - "ms": ("marker", "size"), - }.items() - } - if "ls" in props: - props["ls"] = LINE_STYLES.get(props["ls"], "solid") - if "marker" in props: - props["marker"] = SYMBOLS.get(props["marker"], "x") - if mode is not None: - if "lines" not in mode: - props["ls"] = "" - if "markers" not in mode: - props["marker"] = None - if "text" in mode and trace.get("text", False): - for xs, ys, zs, txt in zip(x, y, z, trace["text"]): - traces_mpl.append( - { - "constructor": "text", - "args": (xs, ys, zs, txt), - } - ) - traces_mpl.append( - { - "constructor": "plot", - "args": (x, y, z), - "kwargs": { - **{k: v for k, v in props.items() if v is not None}, - "alpha": trace.get("opacity", 1), - }, - } - ) - else: - raise ValueError( - f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" - ) - return traces_mpl - - -def process_extra_trace(model): - "process extra trace attached to some magpylib object" - extr = model["model3d"] - model_kwargs = {"color": model["kwargs"]["color"]} - model_kwargs.update(extr.kwargs() if callable(extr.kwargs) else extr.kwargs) - model_args = extr.args() if callable(extr.args) else extr.args - trace3d = { - "constructor": extr.constructor, - "kwargs": model_kwargs, - "args": model_args, - } - kwargs, args, = place_and_orient_model3d( - model_kwargs=model_kwargs, - model_args=model_args, - orientation=model["orientation"], - position=model["position"], - coordsargs=extr.coordsargs, - scale=extr.scale, - return_model_args=True, - ) - trace3d["kwargs"].update(kwargs) - trace3d["args"] = args - return trace3d - - -def display_matplotlib( - *obj_list, - zoom=1, - canvas=None, - animation=False, - repeat=False, - colorsequence=None, - return_fig=False, - return_animation=False, - **kwargs, -): - - """Display objects and paths graphically using the matplotlib library.""" - data = get_frames( - objs=obj_list, - colorsequence=colorsequence, - zoom=zoom, - animation=animation, - mag_arrows=True, - extra_backend="matplotlib", - **kwargs, - ) - frames = data["frames"] - ranges = data["ranges"] - - for fr in frames: - new_data = [] - for tr in fr["data"]: - new_data.extend(generic_trace_to_matplotlib(tr)) - for model in fr["extra_backend_traces"]: - new_data.append(process_extra_trace(model)) - fr["data"] = new_data - - show_canvas = False - if canvas is None: - show_canvas = True - fig = plt.figure(dpi=80, figsize=(8, 8)) - ax = fig.add_subplot(111, projection="3d") - ax.set_box_aspect((1, 1, 1)) - else: - ax = canvas - fig = ax.get_figure() - - def draw_frame(ind): - for tr in frames[ind]["data"]: - constructor = tr["constructor"] - args = tr.get("args", ()) - kwargs = tr.get("kwargs", {}) - getattr(ax, constructor)(*args, **kwargs) - ax.set( - **{f"{k}label": f"{k} [mm]" for k in "xyz"}, - **{f"{k}lim": r for k, r in zip("xyz", ranges)}, - ) - - def animate(ind): - plt.cla() - draw_frame(ind) - return [ax] - - if len(frames) == 1: - draw_frame(0) - else: - anim = FuncAnimation( - fig, - animate, - frames=range(len(frames)), - interval=100, - blit=False, - repeat=repeat, - ) - out = () - if return_fig: - out += (fig,) - if return_animation and len(frames) != 1: - out += (anim,) - if show_canvas: - plt.show() - - if out: - return out[0] if len(out) == 1 else out diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py deleted file mode 100644 index ef1bfdd2d..000000000 --- a/magpylib/_src/display/backend_plotly.py +++ /dev/null @@ -1,265 +0,0 @@ -""" plotly draw-functionalities""" -# pylint: disable=C0302 -# pylint: disable=too-many-branches - -try: - import plotly.graph_objects as go -except ImportError as missing_module: # pragma: no cover - raise ModuleNotFoundError( - """In order to use the plotly plotting backend, you need to install plotly via pip or conda, - see https://github.com/plotly/plotly.py""" - ) from missing_module - -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.traces_generic import get_frames -from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.display.traces_utility import place_and_orient_model3d -from magpylib._src.display.traces_utility import get_scene_ranges -from magpylib._src.defaults.defaults_utility import SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY -from magpylib._src.style import LINESTYLES_MATPLOTLIB_TO_PLOTLY -from magpylib._src.style import SYMBOLS_MATPLOTLIB_TO_PLOTLY - - -def apply_fig_ranges(fig, ranges): - """This is a helper function which applies the ranges properties of the provided `fig` object - according to a certain zoom level. All three space direction will be equal and match the - maximum of the ranges needed to display all objects, including their paths. - - Parameters - ---------- - ranges: array of dimension=(3,2) - min and max graph range - - zoom: float, default = 1 - When zoom=0 all objects are just inside the 3D-axes. - - Returns - ------- - None: NoneType - """ - fig.update_scenes( - **{ - f"{k}axis": dict(range=ranges[i], autorange=False, title=f"{k} [mm]") - for i, k in enumerate("xyz") - }, - aspectratio={k: 1 for k in "xyz"}, - aspectmode="manual", - camera_eye={"x": 1, "y": -1.5, "z": 1.4}, - ) - - -def animate_path( - fig, - frames, - path_indices, - frame_duration, - animation_slider=False, - update_layout=True, -): - """This is a helper function which attaches plotly frames to the provided `fig` object - according to a certain zoom level. All three space direction will be equal and match the - maximum of the ranges needed to display all objects, including their paths. - """ - fps = int(1000 / frame_duration) - if animation_slider: - sliders_dict = { - "active": 0, - "yanchor": "top", - "font": {"size": 10}, - "xanchor": "left", - "currentvalue": { - "prefix": f"Fps={fps}, Path index: ", - "visible": True, - "xanchor": "right", - }, - "pad": {"b": 10, "t": 10}, - "len": 0.9, - "x": 0.1, - "y": 0, - "steps": [], - } - - buttons_dict = { - "buttons": [ - { - "args": [ - None, - { - "frame": {"duration": frame_duration}, - "transition": {"duration": 0}, - "fromcurrent": True, - }, - ], - "label": "Play", - "method": "animate", - }, - { - "args": [[None], {"frame": {"duration": 0}, "mode": "immediate"}], - "label": "Pause", - "method": "animate", - }, - ], - "direction": "left", - "pad": {"r": 10, "t": 20}, - "showactive": False, - "type": "buttons", - "x": 0.1, - "xanchor": "right", - "y": 0, - "yanchor": "top", - } - - for ind in path_indices: - if animation_slider: - slider_step = { - "args": [ - [str(ind + 1)], - { - "frame": {"duration": 0, "redraw": True}, - "mode": "immediate", - }, - ], - "label": str(ind + 1), - "method": "animate", - } - sliders_dict["steps"].append(slider_step) - - # update fig - fig.frames = frames - frame0 = fig.frames[0] - fig.add_traces(frame0.data) - title = frame0.layout.title.text - if update_layout: - fig.update_layout( - height=None, - title=title, - ) - fig.update_layout( - updatemenus=[buttons_dict], - sliders=[sliders_dict] if animation_slider else None, - ) - - -def generic_trace_to_plotly(trace): - """Transform a generic trace into a plotly trace""" - if trace["type"] == "scatter3d": - if "line_width" in trace: - trace["line_width"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] - dash = trace.get("line_dash", None) - if dash is not None: - trace["line_dash"] = LINESTYLES_MATPLOTLIB_TO_PLOTLY.get(dash, dash) - symb = trace.get("marker_symbol", None) - if symb is not None: - trace["marker_symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) - if "marker_size" in trace: - trace["marker_size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] - return trace - - -def process_extra_trace(model): - "process extra trace attached to some magpylib object" - extr = model["model3d"] - kwargs = model["kwargs"] - trace3d = {**kwargs} - ttype = extr.constructor.lower() - trace_kwargs = extr.kwargs() if callable(extr.kwargs) else extr.kwargs - trace3d.update({"type": ttype, **trace_kwargs}) - if ttype == "scatter3d": - for k in ("marker", "line"): - trace3d[f"{k}_color"] = trace3d.get(f"{k}_color", kwargs["color"]) - trace3d.pop("color", None) - elif ttype == "mesh3d": - trace3d["showscale"] = trace3d.get("showscale", False) - trace3d["color"] = trace3d.get("color", kwargs["color"]) - trace3d.update( - linearize_dict( - place_and_orient_model3d( - model_kwargs=trace3d, - orientation=model["orientation"], - position=model["position"], - scale=extr.scale, - ), - separator="_", - ) - ) - return trace3d - - -def extract_layout_kwargs(kwargs): - """Extract layout kwargs""" - layout = kwargs.pop("layout", {}) - layout_kwargs = {k[7:]: v for k, v in kwargs.items() if k.startswith("layout")} - kwargs = {k: v for k, v in kwargs.items() if not k.startswith("layout")} - layout.update(layout_kwargs) - return layout, kwargs - - -def display_plotly( - *obj_list, - zoom=1, - canvas=None, - renderer=None, - animation=False, - colorsequence=None, - return_fig=False, - update_layout=True, - **kwargs, -): - - """Display objects and paths graphically using the plotly library.""" - - fig = canvas - show_fig = False - extra_data = False - if fig is None: - if not return_fig: - show_fig = True - fig = go.Figure() - - if colorsequence is None: - colorsequence = Config.display.colorsequence - - layout, kwargs = extract_layout_kwargs(kwargs) - data = get_frames( - objs=obj_list, - colorsequence=colorsequence, - zoom=zoom, - animation=animation, - extra_backend="plotly", - **kwargs, - ) - frames = data["frames"] - for fr in frames: - new_data = [] - for tr in fr["data"]: - new_data.append(generic_trace_to_plotly(tr)) - for model in fr["extra_backend_traces"]: - extra_data = True - new_data.append(process_extra_trace(model)) - fr["data"] = new_data - fr.pop("extra_backend_traces", None) - with fig.batch_update(): - if len(frames) == 1: - fig.add_traces(frames[0]["data"]) - else: - animation_slider = data.get("animation_slider", False) - animate_path( - fig, - frames, - data["path_indices"], - data["frame_duration"], - animation_slider=animation_slider, - update_layout=update_layout, - ) - ranges = data["ranges"] - if extra_data: - ranges = get_scene_ranges(*frames[0]["data"], zoom=zoom) - if update_layout: - apply_fig_ranges(fig, ranges) - fig.update_layout(legend_itemsizing="constant") - fig.update_layout(layout) - - if return_fig and not show_fig: - return fig - if show_fig: - fig.show(renderer=renderer) diff --git a/magpylib/_src/display/traces_base.py b/magpylib/_src/display/base_traces.py similarity index 96% rename from magpylib/_src/display/traces_base.py rename to magpylib/_src/display/base_traces.py index 44fed8bc9..3d74f342f 100644 --- a/magpylib/_src/display/traces_base.py +++ b/magpylib/_src/display/base_traces.py @@ -3,8 +3,8 @@ import numpy as np -from magpylib._src.display.traces_utility import merge_mesh3d -from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.display_utility import place_and_orient_model3d +from magpylib._src.display.plotly.plotly_utility import merge_mesh3d def base_validator(name, value, conditions): @@ -40,7 +40,7 @@ def get_model(trace, *, backend, show, scale, kwargs): def make_Cuboid( - backend="generic", + backend, dimension=(1.0, 1.0, 1.0), position=None, orientation=None, @@ -54,8 +54,7 @@ def make_Cuboid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. dimension : 3-tuple, default=(1,1,1) Length of the cuboid sides `x,y,z`. @@ -98,7 +97,7 @@ def make_Cuboid( def make_Prism( - backend="generic", + backend, base=3, diameter=1.0, height=1.0, @@ -115,8 +114,7 @@ def make_Prism( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. base : int, default=6 Number of vertices of the base in the xy-plane. @@ -188,7 +186,7 @@ def make_Prism( def make_Ellipsoid( - backend="generic", + backend, dimension=(1.0, 1.0, 1.0), vert=15, position=None, @@ -204,8 +202,7 @@ def make_Ellipsoid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. dimension : tuple, default=(1.0, 1.0, 1.0) Dimension in the `x,y,z` directions. @@ -269,7 +266,7 @@ def make_Ellipsoid( def make_CylinderSegment( - backend="generic", + backend, dimension=(1.0, 2.0, 1.0, 0.0, 90.0), vert=50, position=None, @@ -285,8 +282,7 @@ def make_CylinderSegment( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. dimension: array_like, shape (5,), default=`None` Dimension/Size of the cylinder segment of the form (r1, r2, h, phi1, phi2) @@ -369,7 +365,7 @@ def make_CylinderSegment( def make_Pyramid( - backend="generic", + backend, base=3, diameter=1, height=1, @@ -387,8 +383,7 @@ def make_Pyramid( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. base : int, default=30 Number of vertices of the cone base. @@ -450,7 +445,7 @@ def make_Pyramid( def make_Arrow( - backend="generic", + backend, base=3, diameter=0.3, height=1, @@ -468,8 +463,7 @@ def make_Arrow( Parameters ---------- backend : str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. base : int, default=30 Number of vertices of the arrow base. diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py index 04d8ce22b..945463bfe 100644 --- a/magpylib/_src/display/display.py +++ b/magpylib/_src/display/display.py @@ -1,7 +1,7 @@ """ Display function codes""" -from importlib import import_module +import warnings -from magpylib._src.display.traces_generic import MagpyMarkers +from magpylib._src.display.display_matplotlib import display_matplotlib from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_backend @@ -19,7 +19,6 @@ def show( markers=None, backend=None, canvas=None, - return_fig=False, **kwargs, ): """Display objects and paths graphically. @@ -44,7 +43,7 @@ def show( Display position markers in the global coordinate system. backend: string, default=`None` - Define plotting backend. Must be one of `'matplotlib'`, `'plotly'`. If not + Define plotting backend. Must be one of `'matplotlib'` or `'plotly'`. If not set, parameter will default to `magpylib.defaults.display.backend` which is `'matplotlib'` by installation default. @@ -54,14 +53,9 @@ def show( - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. By default a new canvas is created and immediately displayed. - canvas: bool, default=False - If True, the function call returns the figure object. - - with matplotlib: `matplotlib.figure.Figure`. - - with plotly: `plotly.graph_objects.Figure` or `plotly.graph_objects.FigureWidget`. - Returns ------- - `None` or figure object + `None`: NoneType Examples -------- @@ -127,19 +121,39 @@ def show( allow_None=True, ) - # pylint: disable=import-outside-toplevel - display_func = getattr( - import_module(f"magpylib._src.display.backend_{backend}"), f"display_{backend}" + check_input_zoom(zoom) + check_input_animation(animation) + check_format_input_vector( + markers, + dims=(2,), + shape_m1=3, + sig_name="markers", + sig_type="array_like of shape (n,3)", + allow_None=True, ) - if markers is not None and markers: - obj_list_semi_flat = list(obj_list_semi_flat) + [MagpyMarkers(*markers)] - - return display_func( - *obj_list_semi_flat, - zoom=zoom, - canvas=canvas, - animation=animation, - return_fig=return_fig, - **kwargs, - ) + if backend == "matplotlib": + if animation is not False: + msg = "The matplotlib backend does not support animation at the moment.\n" + msg += "Use `backend=plotly` instead." + warnings.warn(msg) + # animation = False + display_matplotlib( + *obj_list_semi_flat, + markers=markers, + zoom=zoom, + axis=canvas, + **kwargs, + ) + elif backend == "plotly": + # pylint: disable=import-outside-toplevel + from magpylib._src.display.plotly.plotly_display import display_plotly + + display_plotly( + *obj_list_semi_flat, + markers=markers, + zoom=zoom, + fig=canvas, + animation=animation, + **kwargs, + ) diff --git a/magpylib/_src/display/backend_matplotlib_old.py b/magpylib/_src/display/display_matplotlib.py similarity index 64% rename from magpylib/_src/display/backend_matplotlib_old.py rename to magpylib/_src/display/display_matplotlib.py index 144681143..049b4aaa6 100644 --- a/magpylib/_src/display/backend_matplotlib_old.py +++ b/magpylib/_src/display/display_matplotlib.py @@ -1,280 +1,24 @@ """ matplotlib draw-functionalities""" -import warnings - import matplotlib.pyplot as plt import numpy as np from mpl_toolkits.mplot3d.art3d import Poly3DCollection from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.traces_utility import draw_arrow_from_vertices -from magpylib._src.display.traces_utility import draw_arrowed_circle -from magpylib._src.display.traces_utility import get_flatten_objects_properties -from magpylib._src.display.traces_utility import get_rot_pos_from_path -from magpylib._src.display.traces_utility import MagpyMarkers -from magpylib._src.display.traces_utility import place_and_orient_model3d +from magpylib._src.display.display_utility import draw_arrow_from_vertices +from magpylib._src.display.display_utility import draw_arrowed_circle +from magpylib._src.display.display_utility import faces_cuboid +from magpylib._src.display.display_utility import faces_cylinder +from magpylib._src.display.display_utility import faces_cylinder_segment +from magpylib._src.display.display_utility import faces_sphere +from magpylib._src.display.display_utility import get_flatten_objects_properties +from magpylib._src.display.display_utility import get_rot_pos_from_path +from magpylib._src.display.display_utility import MagpyMarkers +from magpylib._src.display.display_utility import place_and_orient_model3d +from magpylib._src.display.display_utility import system_size from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style -def faces_cuboid(src, show_path): - """ - compute vertices and faces of Cuboid input for plotting - takes Cuboid source - returns vert, faces - returns all faces when show_path=all - """ - # pylint: disable=protected-access - a, b, c = src.dimension - vert0 = np.array( - ( - (0, 0, 0), - (a, 0, 0), - (0, b, 0), - (0, 0, c), - (a, b, 0), - (a, 0, c), - (0, b, c), - (a, b, c), - ) - ) - vert0 = vert0 - src.dimension / 2 - - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - faces = [] - for rot, pos in zip(rots, poss): - vert = rot.apply(vert0) + pos - faces += [ - [vert[0], vert[1], vert[4], vert[2]], - [vert[0], vert[1], vert[5], vert[3]], - [vert[0], vert[2], vert[6], vert[3]], - [vert[7], vert[6], vert[2], vert[4]], - [vert[7], vert[6], vert[3], vert[5]], - [vert[7], vert[5], vert[1], vert[4]], - ] - return faces - - -def faces_cylinder(src, show_path): - """ - Compute vertices and faces of Cylinder input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder faces - r, h2 = src.dimension / 2 - hs = np.array([-h2, h2]) - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - faces = [ - np.array( - [ - (r * np.cos(p1), r * np.sin(p1), h2), - (r * np.cos(p1), r * np.sin(p1), -h2), - (r * np.cos(p2), r * np.sin(p2), -h2), - (r * np.cos(p2), r * np.sin(p2), h2), - ] - ) - for p1, p2 in zip(phis, phis2) - ] - faces += [ - np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_cylinder_segment(src, show_path): - """ - Compute vertices and faces of CylinderSegment for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder segment faces - r1, r2, h, phi1, phi2 = src.dimension - res_tile = ( - int((phi2 - phi1) / 360 * 2 * res) + 2 - ) # resolution used for tile curved surface - phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi - phis2 = np.roll(phis, 1) - faces = [ - np.array( - [ # inner curved surface - (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), - (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # outer curved surface - (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), - (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # sides - (r1 * np.cos(p), r1 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), -h / 2), - (r1 * np.cos(p), r1 * np.sin(p), -h / 2), - ] - ) - for p in [phis[0], phis[-1]] - ] - faces += [ - np.array( # top surface - [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] - ) - ] - faces += [ - np.array( # bottom surface - [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] - ) - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_sphere(src, show_path): - """ - Compute vertices and faces of Sphere input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate sphere faces - r = src.diameter / 2 - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - ths = np.linspace(0, np.pi, res) - faces = [ - r - * np.array( - [ - (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), - (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), - ] - ) - for p, p2 in zip(phis, phis2) - for t1, t2 in zip(ths[1:-2], ths[2:-1]) - ] - faces += [ - r - * np.array( - [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] - ) - for th in [ths[1], ths[-2]] - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def system_size(points): - """compute system size for display""" - # determine min/max from all to generate aspect=1 plot - if points: - - # bring (n,m,3) point dimensions (e.g. from plot_surface body) - # to correct (n,3) shape - for i, p in enumerate(points): - if p.ndim == 3: - points[i] = np.reshape(p, (-1, 3)) - - pts = np.vstack(points) - xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] - ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] - zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] - - xsize = xs[1] - xs[0] - ysize = ys[1] - ys[0] - zsize = zs[1] - zs[0] - - xcenter = (xs[1] + xs[0]) / 2 - ycenter = (ys[1] + ys[0]) / 2 - zcenter = (zs[1] + zs[0]) / 2 - - size = max([xsize, ysize, zsize]) - - limx0 = xcenter + size / 2 - limx1 = xcenter - size / 2 - limy0 = ycenter + size / 2 - limy1 = ycenter - size / 2 - limz0 = zcenter + size / 2 - limz1 = zcenter - size / 2 - else: - limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 - return limx0, limx1, limy0, limy1, limz0, limz1 - - def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): """draw direction of magnetization of faced magnets @@ -574,30 +318,29 @@ def draw_model3d_extra(obj, style, show_path, ax, color): return points -def display_matplotlib_old( +def display_matplotlib( *obj_list_semi_flat, - canvas=None, + axis=None, markers=None, zoom=0, - colorsequence=None, - animation=False, + color_sequence=None, **kwargs, ): - """Display objects and paths graphically with the matplotlib backend.""" + """ + Display objects and paths graphically with the matplotlib backend. + + - axis: matplotlib axis3d object + - markers: list of marker positions + - path: bool / int / list of ints + - zoom: zoom level, 0=tight boundaries + - color_sequence: list of colors for object coloring + """ # pylint: disable=protected-access # pylint: disable=too-many-branches # pylint: disable=too-many-statements # apply config default values if None # create or set plotting axis - - if animation is not False: - msg = "The matplotlib backend does not support animation at the moment.\n" - msg += "Use `backend=plotly` instead." - warnings.warn(msg) - # animation = False - - axis = canvas if axis is None: fig = plt.figure(dpi=80, figsize=(8, 8)) ax = fig.add_subplot(111, projection="3d") @@ -613,10 +356,8 @@ def display_matplotlib_old( points = [] dipoles = [] sensors = [] - markers_list = [o for o in obj_list_semi_flat if isinstance(o, MagpyMarkers)] - obj_list_semi_flat = [o for o in obj_list_semi_flat if o not in markers_list] flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, colorsequence=colorsequence + *obj_list_semi_flat, color_sequence=color_sequence ) for obj, props in flat_objs_props.items(): color = props["color"] @@ -709,10 +450,10 @@ def display_matplotlib_old( ) # markers ------------------------------------------------------- - if markers_list: - markers_instance = markers_list[0] - style = get_style(markers_instance, Config, **kwargs) - markers = np.array(markers_instance.markers) + if markers is not None and markers: + m = MagpyMarkers() + style = get_style(m, Config, **kwargs) + markers = np.array(markers) s = style.marker draw_markers(markers, ax, s.color, s.symbol, s.size) points += [markers] diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py new file mode 100644 index 000000000..8804f5763 --- /dev/null +++ b/magpylib/_src/display/display_utility.py @@ -0,0 +1,504 @@ +""" Display function codes""" +from itertools import cycle +from typing import Tuple + +import numpy as np +from scipy.spatial.transform import Rotation as RotScipy + +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.style import Markers +from magpylib._src.utility import Registered + + +@Registered(kind="nonmodel", family="markers") +class MagpyMarkers: + """A class that stores markers 3D-coordinates""" + + def __init__(self, *markers): + self.style = Markers() + self.markers = np.array(markers) + + +# pylint: disable=too-many-branches +def place_and_orient_model3d( + model_kwargs, + model_args=None, + orientation=None, + position=None, + coordsargs=None, + scale=1, + return_vertices=False, + return_model_args=False, + **kwargs, +): + """places and orients mesh3d dict""" + if orientation is None and position is None: + return {**model_kwargs, **kwargs} + position = (0.0, 0.0, 0.0) if position is None else position + position = np.array(position, dtype=float) + new_model_dict = {} + if model_args is None: + model_args = () + new_model_args = list(model_args) + if model_args: + if coordsargs is None: # matplotlib default + coordsargs = dict(x="args[0]", y="args[1]", z="args[2]") + vertices = [] + if coordsargs is None: + coordsargs = {"x": "x", "y": "y", "z": "z"} + useargs = False + for k in "xyz": + key = coordsargs[k] + if key.startswith("args"): + useargs = True + ind = int(key[5]) + v = model_args[ind] + else: + if key in model_kwargs: + v = model_kwargs[key] + else: + raise ValueError( + "Rotating/Moving of provided model failed, trace dictionary " + f"has no argument {k!r}, use `coordsargs` to specify the names of the " + "coordinates to be used.\n" + "Matplotlib backends will set up coordsargs automatically if " + "the `args=(xs,ys,zs)` argument is provided." + ) + vertices.append(v) + + vertices = np.array(vertices) + + # sometimes traces come as (n,m,3) shape + vert_shape = vertices.shape + vertices = np.reshape(vertices, (3, -1)) + + vertices = vertices.T + + if orientation is not None: + vertices = orientation.apply(vertices) + new_vertices = (vertices * scale + position).T + new_vertices = np.reshape(new_vertices, vert_shape) + for i, k in enumerate("xyz"): + key = coordsargs[k] + if useargs: + ind = int(key[5]) + new_model_args[ind] = new_vertices[i] + else: + new_model_dict[key] = new_vertices[i] + new_model_kwargs = {**model_kwargs, **new_model_dict, **kwargs} + + out = (new_model_kwargs,) + if return_model_args: + out += (new_model_args,) + if return_vertices: + out += (new_vertices,) + return out[0] if len(out) == 1 else out + + +def draw_arrowed_line(vec, pos, sign=1, arrow_size=1) -> Tuple: + """ + Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and + centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and + moved to position `pos`. + """ + norm = np.linalg.norm(vec) + nvec = np.array(vec) / norm + yaxis = np.array([0, 1, 0]) + cross = np.cross(nvec, yaxis) + dot = np.dot(nvec, yaxis) + n = np.linalg.norm(cross) + if dot == -1: + sign *= -1 + hy = sign * 0.1 * arrow_size + hx = 0.06 * arrow_size + arrow = ( + np.array( + [ + [0, -0.5, 0], + [0, 0, 0], + [-hx, 0 - hy, 0], + [0, 0, 0], + [hx, 0 - hy, 0], + [0, 0, 0], + [0, 0.5, 0], + ] + ) + * norm + ) + if n != 0: + t = np.arccos(dot) + R = RotScipy.from_rotvec(-t * cross / n) + arrow = R.apply(arrow) + x, y, z = (arrow + pos).T + return x, y, z + + +def draw_arrow_from_vertices(vertices, current, arrow_size): + """returns scatter coordinates of arrows between input vertices""" + vectors = np.diff(vertices, axis=0) + positions = vertices[:-1] + vectors / 2 + vertices = np.concatenate( + [ + draw_arrowed_line(vec, pos, np.sign(current), arrow_size=arrow_size) + for vec, pos in zip(vectors, positions) + ], + axis=1, + ) + + return vertices + + +def draw_arrowed_circle(current, diameter, arrow_size, vert): + """draws an oriented circle with an arrow""" + t = np.linspace(0, 2 * np.pi, vert) + x = np.cos(t) + y = np.sin(t) + if arrow_size != 0: + hy = 0.2 * np.sign(current) * arrow_size + hx = 0.15 * arrow_size + x = np.hstack([x, [1 + hx, 1, 1 - hx]]) + y = np.hstack([y, [-hy, 0, -hy]]) + x = x * diameter / 2 + y = y * diameter / 2 + z = np.zeros(x.shape) + vertices = np.array([x, y, z]) + return vertices + + +def get_rot_pos_from_path(obj, show_path=None): + """ + subsets orientations and positions depending on `show_path` value. + examples: + show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6] + returns rots[[1,2,6]], poss[[1,2,6]] + """ + # pylint: disable=protected-access + # pylint: disable=invalid-unary-operand-type + if show_path is None: + show_path = True + pos = getattr(obj, "_position", None) + if pos is None: + pos = obj.position + pos = np.array(pos) + orient = getattr(obj, "_orientation", None) + if orient is None: + orient = getattr(obj, "orientation", None) + if orient is None: + orient = RotScipy.from_rotvec([[0, 0, 1]]) + pos = np.array([pos]) if pos.ndim == 1 else pos + path_len = pos.shape[0] + if show_path is True or show_path is False or show_path == 0: + inds = np.array([-1]) + elif isinstance(show_path, int): + inds = np.arange(path_len, dtype=int)[::-show_path] + elif hasattr(show_path, "__iter__") and not isinstance(show_path, str): + inds = np.array(show_path) + inds[inds >= path_len] = path_len - 1 + inds = np.unique(inds) + if inds.size == 0: + inds = np.array([path_len - 1]) + rots = orient[inds] + poss = pos[inds] + return rots, poss, inds + + +def faces_cuboid(src, show_path): + """ + compute vertices and faces of Cuboid input for plotting + takes Cuboid source + returns vert, faces + returns all faces when show_path=all + """ + # pylint: disable=protected-access + a, b, c = src.dimension + vert0 = np.array( + ( + (0, 0, 0), + (a, 0, 0), + (0, b, 0), + (0, 0, c), + (a, b, 0), + (a, 0, c), + (0, b, c), + (a, b, c), + ) + ) + vert0 = vert0 - src.dimension / 2 + + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + faces = [] + for rot, pos in zip(rots, poss): + vert = rot.apply(vert0) + pos + faces += [ + [vert[0], vert[1], vert[4], vert[2]], + [vert[0], vert[1], vert[5], vert[3]], + [vert[0], vert[2], vert[6], vert[3]], + [vert[7], vert[6], vert[2], vert[4]], + [vert[7], vert[6], vert[3], vert[5]], + [vert[7], vert[5], vert[1], vert[4]], + ] + return faces + + +def faces_cylinder(src, show_path): + """ + Compute vertices and faces of Cylinder input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder faces + r, h2 = src.dimension / 2 + hs = np.array([-h2, h2]) + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + faces = [ + np.array( + [ + (r * np.cos(p1), r * np.sin(p1), h2), + (r * np.cos(p1), r * np.sin(p1), -h2), + (r * np.cos(p2), r * np.sin(p2), -h2), + (r * np.cos(p2), r * np.sin(p2), h2), + ] + ) + for p1, p2 in zip(phis, phis2) + ] + faces += [ + np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_cylinder_segment(src, show_path): + """ + Compute vertices and faces of CylinderSegment for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder segment faces + r1, r2, h, phi1, phi2 = src.dimension + res_tile = ( + int((phi2 - phi1) / 360 * 2 * res) + 2 + ) # resolution used for tile curved surface + phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi + phis2 = np.roll(phis, 1) + faces = [ + np.array( + [ # inner curved surface + (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), + (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # outer curved surface + (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), + (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # sides + (r1 * np.cos(p), r1 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), -h / 2), + (r1 * np.cos(p), r1 * np.sin(p), -h / 2), + ] + ) + for p in [phis[0], phis[-1]] + ] + faces += [ + np.array( # top surface + [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] + ) + ] + faces += [ + np.array( # bottom surface + [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] + ) + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_sphere(src, show_path): + """ + Compute vertices and faces of Sphere input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate sphere faces + r = src.diameter / 2 + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + ths = np.linspace(0, np.pi, res) + faces = [ + r + * np.array( + [ + (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), + (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), + ] + ) + for p, p2 in zip(phis, phis2) + for t1, t2 in zip(ths[1:-2], ths[2:-1]) + ] + faces += [ + r + * np.array( + [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] + ) + for th in [ths[1], ths[-2]] + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def system_size(points): + """compute system size for display""" + # determine min/max from all to generate aspect=1 plot + if points: + + # bring (n,m,3) point dimensions (e.g. from plot_surface body) + # to correct (n,3) shape + for i, p in enumerate(points): + if p.ndim == 3: + points[i] = np.reshape(p, (-1, 3)) + + pts = np.vstack(points) + xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] + ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] + zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] + + xsize = xs[1] - xs[0] + ysize = ys[1] - ys[0] + zsize = zs[1] - zs[0] + + xcenter = (xs[1] + xs[0]) / 2 + ycenter = (ys[1] + ys[0]) / 2 + zcenter = (zs[1] + zs[0]) / 2 + + size = max([xsize, ysize, zsize]) + + limx0 = xcenter + size / 2 + limx1 = xcenter - size / 2 + limy0 = ycenter + size / 2 + limy1 = ycenter - size / 2 + limz0 = zcenter + size / 2 + limz1 = zcenter - size / 2 + else: + limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 + return limx0, limx1, limy0, limy1, limz0, limz1 + + +def get_flatten_objects_properties( + *obj_list_semi_flat, + color_sequence=None, + color_cycle=None, + **parent_props, +): + """returns a flat dict -> (obj: display_props, ...) from nested collections""" + if color_sequence is None: + color_sequence = Config.display.colorsequence + if color_cycle is None: + color_cycle = cycle(color_sequence) + flat_objs = {} + for subobj in obj_list_semi_flat: + isCollection = getattr(subobj, "children", None) is not None + props = {**parent_props} + parent_color = parent_props.get("color", "!!!missing!!!") + if parent_color == "!!!missing!!!": + props["color"] = next(color_cycle) + if parent_props.get("legendgroup", None) is None: + props["legendgroup"] = f"{subobj}" + if parent_props.get("showlegend", None) is None: + props["showlegend"] = True + if parent_props.get("legendtext", None) is None: + legendtext = None + if isCollection: + legendtext = getattr(getattr(subobj, "style", None), "label", None) + legendtext = f"{subobj!r}" if legendtext is None else legendtext + props["legendtext"] = legendtext + flat_objs[subobj] = props + if isCollection: + if subobj.style.color is not None: + flat_objs[subobj]["color"] = subobj.style.color + flat_objs.update( + get_flatten_objects_properties( + *subobj.children, + color_sequence=color_sequence, + color_cycle=color_cycle, + **flat_objs[subobj], + ) + ) + return flat_objs diff --git a/magpylib/_src/display/plotly/__init__.py b/magpylib/_src/display/plotly/__init__.py new file mode 100644 index 000000000..21c94b0be --- /dev/null +++ b/magpylib/_src/display/plotly/__init__.py @@ -0,0 +1 @@ +"""display.plotly""" diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py new file mode 100644 index 000000000..f8e962212 --- /dev/null +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -0,0 +1,1196 @@ +""" plotly draw-functionalities""" +# pylint: disable=C0302 +# pylint: disable=too-many-branches +import numbers +import warnings +from itertools import combinations +from typing import Tuple + +try: + import plotly.graph_objects as go +except ImportError as missing_module: # pragma: no cover + raise ModuleNotFoundError( + """In order to use the plotly plotting backend, you need to install plotly via pip or conda, + see https://github.com/plotly/plotly.py""" + ) from missing_module + +import numpy as np +from scipy.spatial.transform import Rotation as RotScipy +from magpylib import _src +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.display.plotly.plotly_sensor_mesh import get_sensor_mesh +from magpylib._src.style import ( + get_style, + LINESTYLES_MATPLOTLIB_TO_PLOTLY, + SYMBOLS_MATPLOTLIB_TO_PLOTLY, +) +from magpylib._src.display.display_utility import ( + get_rot_pos_from_path, + MagpyMarkers, + draw_arrow_from_vertices, + draw_arrowed_circle, + place_and_orient_model3d, + get_flatten_objects_properties, +) +from magpylib._src.defaults.defaults_utility import ( + SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY, + linearize_dict, +) + +from magpylib._src.input_checks import check_excitations +from magpylib._src.utility import unit_prefix, format_obj_input +from magpylib._src.display.base_traces import ( + make_Cuboid as make_BaseCuboid, + make_CylinderSegment as make_BaseCylinderSegment, + make_Ellipsoid as make_BaseEllipsoid, + make_Prism as make_BasePrism, + # make_Pyramid as make_BasePyramid, + make_Arrow as make_BaseArrow, +) +from magpylib._src.display.plotly.plotly_utility import ( + merge_mesh3d, + merge_traces, + getColorscale, + getIntensity, + clean_legendgroups, +) + + +def make_Line( + current=0.0, + vertices=((-1.0, 0.0, 0.0), (1.0, 0.0, 0.0)), + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly scatter3d parameters for a Line current in a dictionary based on the + provided arguments + """ + default_suffix = ( + f" ({unit_prefix(current)}A)" + if current is not None + else " (Current not initialized)" + ) + name, name_suffix = get_name_and_suffix("Line", default_suffix, style) + show_arrows = style.arrow.show + arrow_size = style.arrow.size + if show_arrows: + vertices = draw_arrow_from_vertices(vertices, current, arrow_size) + else: + vertices = np.array(vertices).T + if orientation is not None: + vertices = orientation.apply(vertices.T).T + x, y, z = (vertices.T + position).T + line_width = style.arrow.width * SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] + line = dict( + type="scatter3d", + x=x, + y=y, + z=z, + name=f"""{name}{name_suffix}""", + mode="lines", + line_width=line_width, + line_color=color, + ) + return {**line, **kwargs} + + +def make_Loop( + current=0.0, + diameter=1.0, + position=(0.0, 0.0, 0.0), + vert=50, + orientation=None, + color=None, + style=None, + **kwargs, +): + """ + Creates the plotly scatter3d parameters for a Loop current in a dictionary based on the + provided arguments + """ + default_suffix = ( + f" ({unit_prefix(current)}A)" + if current is not None + else " (Current not initialized)" + ) + name, name_suffix = get_name_and_suffix("Loop", default_suffix, style) + arrow_size = style.arrow.size if style.arrow.show else 0 + vertices = draw_arrowed_circle(current, diameter, arrow_size, vert) + if orientation is not None: + vertices = orientation.apply(vertices.T).T + x, y, z = (vertices.T + position).T + line_width = style.arrow.width * SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] + circular = dict( + type="scatter3d", + x=x, + y=y, + z=z, + name=f"""{name}{name_suffix}""", + mode="lines", + line_width=line_width, + line_color=color, + ) + return {**circular, **kwargs} + + +def make_DefaultTrace( + obj, + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly scatter3d parameters for an object with no specifically supported + representation. The object will be represented by a scatter point and text above with object + name. + """ + + default_suffix = "" + name, name_suffix = get_name_and_suffix( + f"{type(obj).__name__}", default_suffix, style + ) + vertices = np.array([position]) + if orientation is not None: + vertices = orientation.apply(vertices).T + x, y, z = vertices + trace = dict( + type="scatter3d", + x=x, + y=y, + z=z, + name=f"""{name}{name_suffix}""", + text=name, + mode="markers+text", + marker_size=10, + marker_color=color, + marker_symbol="diamond", + ) + return {**trace, **kwargs} + + +def make_Dipole( + moment=(0.0, 0.0, 1.0), + position=(0.0, 0.0, 0.0), + orientation=None, + style=None, + autosize=None, + **kwargs, +) -> dict: + """ + Creates the plotly mesh3d parameters for a Loop current in a dictionary based on the + provided arguments + """ + moment_mag = np.linalg.norm(moment) + default_suffix = f" (moment={unit_prefix(moment_mag)}mT mm³)" + name, name_suffix = get_name_and_suffix("Dipole", default_suffix, style) + size = style.size + if autosize is not None: + size *= autosize + dipole = make_BaseArrow( + "plotly-dict", base=10, diameter=0.3 * size, height=size, pivot=style.pivot + ) + nvec = np.array(moment) / moment_mag + zaxis = np.array([0, 0, 1]) + cross = np.cross(nvec, zaxis) + dot = np.dot(nvec, zaxis) + n = np.linalg.norm(cross) + t = np.arccos(dot) + vec = -t * cross / n if n != 0 else (0, 0, 0) + mag_orient = RotScipy.from_rotvec(vec) + orientation = orientation * mag_orient + mag = np.array((0, 0, 1)) + return _update_mag_mesh( + dipole, + name, + name_suffix, + mag, + orientation, + position, + style, + **kwargs, + ) + + +def make_Cuboid( + mag=(0.0, 0.0, 1000.0), + dimension=(1.0, 1.0, 1.0), + position=(0.0, 0.0, 0.0), + orientation=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly mesh3d parameters for a Cuboid Magnet in a dictionary based on the + provided arguments + """ + d = [unit_prefix(d / 1000) for d in dimension] + default_suffix = f" ({d[0]}m|{d[1]}m|{d[2]}m)" + name, name_suffix = get_name_and_suffix("Cuboid", default_suffix, style) + cuboid = make_BaseCuboid("plotly-dict", dimension=dimension) + return _update_mag_mesh( + cuboid, + name, + name_suffix, + mag, + orientation, + position, + style, + **kwargs, + ) + + +def make_Cylinder( + mag=(0.0, 0.0, 1000.0), + base=50, + diameter=1.0, + height=1.0, + position=(0.0, 0.0, 0.0), + orientation=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly mesh3d parameters for a Cylinder Magnet in a dictionary based on the + provided arguments + """ + d = [unit_prefix(d / 1000) for d in (diameter, height)] + default_suffix = f" (D={d[0]}m, H={d[1]}m)" + name, name_suffix = get_name_and_suffix("Cylinder", default_suffix, style) + cylinder = make_BasePrism( + "plotly-dict", + base=base, + diameter=diameter, + height=height, + ) + return _update_mag_mesh( + cylinder, + name, + name_suffix, + mag, + orientation, + position, + style, + **kwargs, + ) + + +def make_CylinderSegment( + mag=(0.0, 0.0, 1000.0), + dimension=(1.0, 2.0, 1.0, 0.0, 90.0), + position=(0.0, 0.0, 0.0), + orientation=None, + vert=25, + style=None, + **kwargs, +): + """ + Creates the plotly mesh3d parameters for a Cylinder Segment Magnet in a dictionary based on the + provided arguments + """ + d = [unit_prefix(d / (1000 if i < 3 else 1)) for i, d in enumerate(dimension)] + default_suffix = f" (r={d[0]}m|{d[1]}m, h={d[2]}m, φ={d[3]}°|{d[4]}°)" + name, name_suffix = get_name_and_suffix("CylinderSegment", default_suffix, style) + cylinder_segment = make_BaseCylinderSegment( + "plotly-dict", dimension=dimension, vert=vert + ) + return _update_mag_mesh( + cylinder_segment, + name, + name_suffix, + mag, + orientation, + position, + style, + **kwargs, + ) + + +def make_Sphere( + mag=(0.0, 0.0, 1000.0), + vert=15, + diameter=1, + position=(0.0, 0.0, 0.0), + orientation=None, + style=None, + **kwargs, +) -> dict: + """ + Creates the plotly mesh3d parameters for a Sphere Magnet in a dictionary based on the + provided arguments + """ + default_suffix = f" (D={unit_prefix(diameter / 1000)}m)" + name, name_suffix = get_name_and_suffix("Sphere", default_suffix, style) + vert = min(max(vert, 3), 20) + sphere = make_BaseEllipsoid("plotly-dict", vert=vert, dimension=[diameter] * 3) + return _update_mag_mesh( + sphere, + name, + name_suffix, + mag, + orientation, + position, + style, + **kwargs, + ) + + +def make_Pixels(positions, size=1) -> dict: + """ + Creates the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size + For now, only "cube" shape is provided. + """ + pixels = [ + make_BaseCuboid("plotly-dict", position=p, dimension=[size] * 3) + for p in positions + ] + return merge_mesh3d(*pixels) + + +def make_Sensor( + pixel=(0.0, 0.0, 0.0), + dimension=(1.0, 1.0, 1.0), + position=(0.0, 0.0, 0.0), + orientation=None, + color=None, + style=None, + autosize=None, + **kwargs, +): + """ + Creates the plotly mesh3d parameters for a Sensor object in a dictionary based on the + provided arguments + + size_pixels: float, default=1 + A positive number. Adjusts automatic display size of sensor pixels. When set to 0, + pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum + distance between any pixel of the same sensor, equal to `size_pixel`. + """ + pixel = np.array(pixel).reshape((-1, 3)) + default_suffix = ( + f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)""" + if pixel.ndim != 1 + else "" + ) + name, name_suffix = get_name_and_suffix("Sensor", default_suffix, style) + style_arrows = style.arrows.as_dict(flatten=True, separator="_") + sensor = get_sensor_mesh(**style_arrows, center_color=color) + vertices = np.array([sensor[k] for k in "xyz"]).T + if color is not None: + sensor["facecolor"][sensor["facecolor"] == "rgb(238,238,238)"] = color + dim = np.array( + [dimension] * 3 if isinstance(dimension, (float, int)) else dimension[:3], + dtype=float, + ) + if autosize is not None: + dim *= autosize + if pixel.shape[0] == 1: + dim_ext = dim + else: + hull_dim = pixel.max(axis=0) - pixel.min(axis=0) + dim_ext = max(np.mean(dim), np.min(hull_dim)) + cube_mask = (vertices < 1).all(axis=1) + vertices[cube_mask] = 0 * vertices[cube_mask] + vertices[~cube_mask] = dim_ext * vertices[~cube_mask] + vertices /= 2 # sensor_mesh vertices are of length 2 + x, y, z = vertices.T + sensor.update(x=x, y=y, z=z) + meshes_to_merge = [sensor] + if pixel.shape[0] != 1: + pixel_color = style.pixel.color + pixel_size = style.pixel.size + combs = np.array(list(combinations(pixel, 2))) + vecs = np.diff(combs, axis=1) + dists = np.linalg.norm(vecs, axis=2) + pixel_dim = np.min(dists) / 2 + if pixel_size > 0: + pixel_dim *= pixel_size + pixels_mesh = make_Pixels(positions=pixel, size=pixel_dim) + pixels_mesh["facecolor"] = np.repeat(pixel_color, len(pixels_mesh["i"])) + meshes_to_merge.append(pixels_mesh) + hull_pos = 0.5 * (pixel.max(axis=0) + pixel.min(axis=0)) + hull_dim[hull_dim == 0] = pixel_dim / 2 + hull_mesh = make_BaseCuboid( + "plotly-dict", position=hull_pos, dimension=hull_dim + ) + hull_mesh["facecolor"] = np.repeat(color, len(hull_mesh["i"])) + meshes_to_merge.append(hull_mesh) + sensor = merge_mesh3d(*meshes_to_merge) + return _update_mag_mesh( + sensor, name, name_suffix, orientation=orientation, position=position, **kwargs + ) + + +def _update_mag_mesh( + mesh_dict, + name, + name_suffix, + magnetization=None, + orientation=None, + position=None, + style=None, + **kwargs, +): + """ + Updates an existing plotly mesh3d dictionary of an object which has a magnetic vector. The + object gets colorized, positioned and oriented based on provided arguments + """ + if hasattr(style, "magnetization"): + color = style.magnetization.color + if magnetization is not None and style.magnetization.show: + vertices = np.array([mesh_dict[k] for k in "xyz"]).T + color_middle = color.middle + if color.mode == "tricycle": + color_middle = kwargs.get("color", None) + elif color.mode == "bicolor": + color_middle = False + mesh_dict["colorscale"] = getColorscale( + color_transition=color.transition, + color_north=color.north, + color_middle=color_middle, + color_south=color.south, + ) + mesh_dict["intensity"] = getIntensity( + vertices=vertices, + axis=magnetization, + ) + mesh_dict = place_and_orient_model3d( + model_kwargs=mesh_dict, + orientation=orientation, + position=position, + showscale=False, + name=f"{name}{name_suffix}", + ) + return {**mesh_dict, **kwargs} + + +def get_name_and_suffix(default_name, default_suffix, style): + """provides legend entry based on name and suffix""" + name = default_name if style.label is None else style.label + if style.description.show and style.description.text is None: + name_suffix = default_suffix + elif not style.description.show: + name_suffix = "" + else: + name_suffix = f" ({style.description.text})" + return name, name_suffix + + +def get_plotly_traces( + input_obj, + color=None, + autosize=None, + legendgroup=None, + showlegend=None, + legendtext=None, + **kwargs, +) -> list: + """ + This is a helper function providing the plotly traces for any object of the magpylib library. If + the object is not supported, the trace representation will fall back to a single scatter point + with the object name marked above it. + + - If the object has a path (multiple positions), the function will return both the object trace + and the corresponding path trace. The legend entry of the path trace will be hidden but both + traces will share the same `legendgroup` so that a legend entry click will hide/show both traces + at once. From the user's perspective, the traces will be merged. + + - The argument caught by the kwargs dictionary must all be arguments supported both by + `scatter3d` and `mesh3d` plotly objects, otherwise an error will be raised. + """ + + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + # pylint: disable=too-many-nested-blocks + + Sensor = _src.obj_classes.class_Sensor.Sensor + Cuboid = _src.obj_classes.class_magnet_Cuboid.Cuboid + Cylinder = _src.obj_classes.class_magnet_Cylinder.Cylinder + CylinderSegment = _src.obj_classes.class_magnet_CylinderSegment.CylinderSegment + Sphere = _src.obj_classes.class_magnet_Sphere.Sphere + Dipole = _src.obj_classes.class_misc_Dipole.Dipole + Loop = _src.obj_classes.class_current_Loop.Loop + Line = _src.obj_classes.class_current_Line.Line + + # parse kwargs into style and non style args + style = get_style(input_obj, Config, **kwargs) + kwargs = {k: v for k, v in kwargs.items() if not k.startswith("style")} + kwargs["style"] = style + style_color = getattr(style, "color", None) + kwargs["color"] = style_color if style_color is not None else color + kwargs["opacity"] = style.opacity + legendgroup = f"{input_obj}" if legendgroup is None else legendgroup + + if hasattr(style, "magnetization"): + if style.magnetization.show: + check_excitations([input_obj]) + + if hasattr(style, "arrow"): + if style.arrow.show: + check_excitations([input_obj]) + + traces = [] + if isinstance(input_obj, MagpyMarkers): + x, y, z = input_obj.markers.T + marker = style.as_dict()["marker"] + symb = marker["symbol"] + marker["symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) + marker["size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] + default_name = "Marker" if len(x) == 1 else "Markers" + default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" + name, name_suffix = get_name_and_suffix(default_name, default_suffix, style) + trace = go.Scatter3d( + name=f"{name}{name_suffix}", + x=x, + y=y, + z=z, + marker=marker, + mode="markers", + opacity=style.opacity, + ) + traces.append(trace) + else: + if isinstance(input_obj, Sensor): + kwargs.update( + dimension=getattr(input_obj, "dimension", style.size), + pixel=getattr(input_obj, "pixel", (0.0, 0.0, 0.0)), + autosize=autosize, + ) + make_func = make_Sensor + elif isinstance(input_obj, Cuboid): + kwargs.update( + mag=input_obj.magnetization, + dimension=input_obj.dimension, + ) + make_func = make_Cuboid + elif isinstance(input_obj, Cylinder): + base = 50 + kwargs.update( + mag=input_obj.magnetization, + diameter=input_obj.dimension[0], + height=input_obj.dimension[1], + base=base, + ) + make_func = make_Cylinder + elif isinstance(input_obj, CylinderSegment): + vert = 50 + kwargs.update( + mag=input_obj.magnetization, + dimension=input_obj.dimension, + vert=vert, + ) + make_func = make_CylinderSegment + elif isinstance(input_obj, Sphere): + kwargs.update( + mag=input_obj.magnetization, + diameter=input_obj.diameter, + ) + make_func = make_Sphere + elif isinstance(input_obj, Dipole): + kwargs.update( + moment=input_obj.moment, + autosize=autosize, + ) + make_func = make_Dipole + elif isinstance(input_obj, Line): + kwargs.update( + vertices=input_obj.vertices, + current=input_obj.current, + ) + make_func = make_Line + elif isinstance(input_obj, Loop): + kwargs.update( + diameter=input_obj.diameter, + current=input_obj.current, + ) + make_func = make_Loop + elif getattr(input_obj, "children", None) is not None: + make_func = None + else: + kwargs.update(obj=input_obj) + make_func = make_DefaultTrace + + path_traces = [] + path_traces_extra = {} + extra_model3d_traces = ( + style.model3d.data if style.model3d.data is not None else [] + ) + rots, poss, _ = get_rot_pos_from_path(input_obj, style.path.frames) + for orient, pos in zip(rots, poss): + if style.model3d.showdefault and make_func is not None: + path_traces.append( + make_func(position=pos, orientation=orient, **kwargs) + ) + for extr in extra_model3d_traces: + if extr.show: + extr.update(extr.updatefunc()) + if extr.backend == "plotly": + trace3d = {} + ttype = extr.constructor.lower() + obj_extr_trace = ( + extr.kwargs() if callable(extr.kwargs) else extr.kwargs + ) + obj_extr_trace = {"type": ttype, **obj_extr_trace} + if ttype == "mesh3d": + trace3d["showscale"] = False + if "facecolor" in obj_extr_trace: + ttype = "mesh3d_facecolor" + if ttype == "scatter3d": + trace3d["marker_color"] = kwargs["color"] + trace3d["line_color"] = kwargs["color"] + else: + trace3d["color"] = kwargs["color"] + trace3d.update( + linearize_dict( + place_and_orient_model3d( + model_kwargs=obj_extr_trace, + orientation=orient, + position=pos, + scale=extr.scale, + ), + separator="_", + ) + ) + if ttype not in path_traces_extra: + path_traces_extra[ttype] = [] + path_traces_extra[ttype].append(trace3d) + trace = merge_traces(*path_traces) + for ind, traces_extra in enumerate(path_traces_extra.values()): + extra_model3d_trace = merge_traces(*traces_extra) + label = ( + input_obj.style.label + if input_obj.style.label is not None + else str(type(input_obj).__name__) + ) + extra_model3d_trace.update( + { + "legendgroup": legendgroup, + "showlegend": showlegend and ind == 0 and not trace, + "name": label, + } + ) + traces.append(extra_model3d_trace) + + if trace: + trace.update( + { + "legendgroup": legendgroup, + "showlegend": True if showlegend is None else showlegend, + } + ) + if legendtext is not None: + trace["name"] = legendtext + traces.append(trace) + + if np.array(input_obj.position).ndim > 1 and style.path.show: + scatter_path = make_path(input_obj, style, legendgroup, kwargs) + traces.append(scatter_path) + + return traces + + +def make_path(input_obj, style, legendgroup, kwargs): + """draw obj path based on path style properties""" + x, y, z = np.array(input_obj.position).T + txt_kwargs = ( + {"mode": "markers+text+lines", "text": list(range(len(x)))} + if style.path.numbering + else {"mode": "markers+lines"} + ) + marker = style.path.marker.as_dict() + symb = marker["symbol"] + marker["symbol"] = SYMBOLS_MATPLOTLIB_TO_PLOTLY.get(symb, symb) + marker["color"] = kwargs["color"] if marker["color"] is None else marker["color"] + marker["size"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["marker_size"] + line = style.path.line.as_dict() + dash = line["style"] + line["dash"] = LINESTYLES_MATPLOTLIB_TO_PLOTLY.get(dash, dash) + line["color"] = kwargs["color"] if line["color"] is None else line["color"] + line["width"] *= SIZE_FACTORS_MATPLOTLIB_TO_PLOTLY["line_width"] + line = {k: v for k, v in line.items() if k != "style"} + scatter_path = dict( + type="scatter3d", + x=x, + y=y, + z=z, + name=f"Path: {input_obj}", + showlegend=False, + legendgroup=legendgroup, + marker=marker, + line=line, + **txt_kwargs, + opacity=kwargs["opacity"], + ) + return scatter_path + + +def draw_frame( + obj_list_semi_flat, color_sequence, zoom, autosize=None, output="dict", **kwargs +) -> Tuple: + """ + Creates traces from input `objs` and provided parameters, updates the size of objects like + Sensors and Dipoles in `kwargs` depending on the canvas size. + + Returns + ------- + traces_dicts, kwargs: dict, dict + returns the traces in a obj/traces_list dictionary and updated kwargs + """ + # pylint: disable=protected-access + return_autosize = False + Sensor = _src.obj_classes.class_Sensor.Sensor + Dipole = _src.obj_classes.class_misc_Dipole.Dipole + traces_out = {} + # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. + # autosize is calculated from the other traces overall scene range + traces_to_resize = {} + flat_objs_props = get_flatten_objects_properties( + *obj_list_semi_flat, color_sequence=color_sequence + ) + for obj, params in flat_objs_props.items(): + params.update(kwargs) + if isinstance(obj, (Dipole, Sensor)): + traces_to_resize[obj] = {**params} + # temporary coordinates to be able to calculate ranges + x, y, z = obj._position.T + traces_out[obj] = [dict(x=x, y=y, z=z)] + else: + traces_out[obj] = get_plotly_traces(obj, **params) + traces = [t for tr in traces_out.values() for t in tr] + ranges = get_scene_ranges(*traces, zoom=zoom) + if autosize is None or autosize == "return": + if autosize == "return": + return_autosize = True + autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor + for obj, params in traces_to_resize.items(): + traces_out[obj] = get_plotly_traces(obj, autosize=autosize, **params) + if output == "list": + traces = [t for tr in traces_out.values() for t in tr] + traces_out = group_traces(*traces) + if return_autosize: + res = traces_out, autosize + else: + res = traces_out + return res + + +def group_traces(*traces): + """Group and merge mesh traces with similar properties. This drastically improves + browser rendering performance when displaying a lot of mesh3d objects.""" + mesh_groups = {} + common_keys = ["legendgroup", "opacity"] + spec_keys = {"mesh3d": ["colorscale"], "scatter3d": ["marker", "line"]} + for tr in traces: + gr = [tr["type"]] + for k in common_keys + spec_keys[tr["type"]]: + try: + v = tr.get(k, "") + except AttributeError: + v = getattr(tr, k, "") + gr.append(str(v)) + gr = "".join(gr) + if gr not in mesh_groups: + mesh_groups[gr] = [] + mesh_groups[gr].append(tr) + + traces = [] + for key, gr in mesh_groups.items(): + if key.startswith("mesh3d") or key.startswith("scatter3d"): + tr = [merge_traces(*gr)] + else: + tr = gr + traces.extend(tr) + return traces + + +def apply_fig_ranges(fig, ranges=None, zoom=None): + """This is a helper function which applies the ranges properties of the provided `fig` object + according to a certain zoom level. All three space direction will be equal and match the + maximum of the ranges needed to display all objects, including their paths. + + Parameters + ---------- + ranges: array of dimension=(3,2) + min and max graph range + + zoom: float, default = 1 + When zoom=0 all objects are just inside the 3D-axes. + + Returns + ------- + None: NoneType + """ + if ranges is None: + frames = fig.frames if fig.frames else [fig] + traces = [t for frame in frames for t in frame.data] + ranges = get_scene_ranges(*traces, zoom=zoom) + fig.update_scenes( + **{ + f"{k}axis": dict(range=ranges[i], autorange=False, title=f"{k} [mm]") + for i, k in enumerate("xyz") + }, + aspectratio={k: 1 for k in "xyz"}, + aspectmode="manual", + camera_eye={"x": 1, "y": -1.5, "z": 1.4}, + ) + + +def get_scene_ranges(*traces, zoom=1) -> np.ndarray: + """ + Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be + any plotly trace object or a dict, with x,y,z numbered parameters. + """ + if traces: + ranges = {k: [] for k in "xyz"} + for t in traces: + for k, v in ranges.items(): + v.extend( + [ + np.nanmin(np.array(t[k], dtype=float)), + np.nanmax(np.array(t[k], dtype=float)), + ] + ) + r = np.array([[np.nanmin(v), np.nanmax(v)] for v in ranges.values()]) + size = np.diff(r, axis=1) + size[size == 0] = 1 + m = size.max() / 2 + center = r.mean(axis=1) + ranges = np.array([center - m * (1 + zoom), center + m * (1 + zoom)]).T + else: + ranges = np.array([[-1.0, 1.0]] * 3) + return ranges + + +def animate_path( + fig, + objs, + color_sequence=None, + zoom=1, + title="3D-Paths Animation", + animation_time=3, + animation_fps=30, + animation_maxfps=50, + animation_maxframes=200, + animation_slider=False, + **kwargs, +): + """This is a helper function which attaches plotly frames to the provided `fig` object + according to a certain zoom level. All three space direction will be equal and match the + maximum of the ranges needed to display all objects, including their paths. + + Parameters + ---------- + animation_time: float, default = 3 + Sets the animation duration + + animation_fps: float, default = 30 + This sets the maximum allowed frame rate. In case of path positions needed to be displayed + exceeds the `animation_fps` the path position will be downsampled to be lower or equal + the `animation_fps`. This is mainly depending on the pc/browser performance and is set to + 50 by default to avoid hanging the animation process. + + animation_slider: bool, default = False + if True, an interactive slider will be displayed and stay in sync with the animation + + title: str, default = "3D-Paths Animation" + When zoom=0 all objects are just inside the 3D-axes. + + color_sequence: list or array_like, iterable, default= + ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', + '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', + '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', + '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] + An iterable of color values used to cycle trough for every object displayed. + A color and may be specified as: + - A hex string (e.g. '#ff0000') + - An rgb/rgba string (e.g. 'rgb(255,0,0)') + - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') + - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') + - A named CSS color + + Returns + ------- + None: NoneTyp + """ + # make sure the number of frames does not exceed the max frames and max frame rate + # downsample if necessary + path_lengths = [] + for obj in objs: + subobjs = [obj] + if getattr(obj, "_object_type", None) == "Collection": + subobjs.extend(obj.children) + for subobj in subobjs: + path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] + path_lengths.append(path_len) + + max_pl = max(path_lengths) + if animation_fps > animation_maxfps: + warnings.warn( + f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" + f" {animation_maxfps}. `animation_fps` will be set to {animation_maxfps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxfps`" + ) + animation_fps = animation_maxfps + + maxpos = min(animation_time * animation_fps, animation_maxframes) + + if max_pl <= maxpos: + path_indices = np.arange(max_pl) + else: + round_step = max_pl / (maxpos - 1) + ar = np.linspace(0, max_pl, max_pl, endpoint=False) + path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( + int + ) # downsampled indices + path_indices[-1] = ( + max_pl - 1 + ) # make sure the last frame is the last path position + + # calculate exponent of last frame index to avoid digit shift in + # frame number display during animation + exp = ( + np.log10(path_indices.max()).astype(int) + 1 + if path_indices.ndim != 0 and path_indices.max() > 0 + else 1 + ) + + frame_duration = int(animation_time * 1000 / path_indices.shape[0]) + new_fps = int(1000 / frame_duration) + if max_pl > animation_maxframes: + warnings.warn( + f"The number of frames ({max_pl}) is greater than the max allowed " + f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " + f"You can modify the default value by setting it in " + "`magpylib.defaults.display.animation.maxframes`" + ) + + if animation_slider: + sliders_dict = { + "active": 0, + "yanchor": "top", + "font": {"size": 10}, + "xanchor": "left", + "currentvalue": { + "prefix": f"Fps={new_fps}, Path index: ", + "visible": True, + "xanchor": "right", + }, + "pad": {"b": 10, "t": 10}, + "len": 0.9, + "x": 0.1, + "y": 0, + "steps": [], + } + + buttons_dict = { + "buttons": [ + { + "args": [ + None, + { + "frame": {"duration": frame_duration}, + "transition": {"duration": 0}, + "fromcurrent": True, + }, + ], + "label": "Play", + "method": "animate", + }, + { + "args": [[None], {"frame": {"duration": 0}, "mode": "immediate"}], + "label": "Pause", + "method": "animate", + }, + ], + "direction": "left", + "pad": {"r": 10, "t": 20}, + "showactive": False, + "type": "buttons", + "x": 0.1, + "xanchor": "right", + "y": 0, + "yanchor": "top", + } + + # create frame for each path index or downsampled path index + frames = [] + autosize = "return" + for i, ind in enumerate(path_indices): + kwargs["style_path_frames"] = [ind] + frame = draw_frame( + objs, + color_sequence, + zoom, + autosize=autosize, + output="list", + **kwargs, + ) + if i == 0: # get the dipoles and sensors autosize from first frame + traces, autosize = frame + else: + traces = frame + frames.append( + go.Frame( + data=traces, + name=str(ind + 1), + layout=dict(title=f"""{title} - path index: {ind+1:0{exp}d}"""), + ) + ) + if animation_slider: + slider_step = { + "args": [ + [str(ind + 1)], + { + "frame": {"duration": 0, "redraw": True}, + "mode": "immediate", + }, + ], + "label": str(ind + 1), + "method": "animate", + } + sliders_dict["steps"].append(slider_step) + + # update fig + fig.frames = frames + fig.add_traces(frames[0].data) + fig.update_layout( + height=None, + title=title, + updatemenus=[buttons_dict], + sliders=[sliders_dict] if animation_slider else None, + ) + apply_fig_ranges(fig, zoom=zoom) + + +def display_plotly( + *obj_list, + markers=None, + zoom=1, + fig=None, + renderer=None, + animation=False, + color_sequence=None, + **kwargs, +): + + """ + Display objects and paths graphically using the plotly library. + + Parameters + ---------- + objects: sources, collections or sensors + Objects to be displayed. + + markers: array_like, None, shape (N,3), default=None + Display position markers in the global CS. By default no marker is displayed. + + zoom: float, default = 1 + Adjust plot zoom-level. When zoom=0 all objects are just inside the 3D-axes. + + fig: plotly Figure, default=None + Display graphical output in a given figure: + - plotly.graph_objects.Figure + - plotly.graph_objects.FigureWidget + By default a new `Figure` is created and displayed. + + renderer: str. default=None, + The renderers framework is a flexible approach for displaying plotly.py figures in a variety + of contexts. + Available renderers are: + ['plotly_mimetype', 'jupyterlab', 'nteract', 'vscode', + 'notebook', 'notebook_connected', 'kaggle', 'azure', 'colab', + 'cocalc', 'databricks', 'json', 'png', 'jpeg', 'jpg', 'svg', + 'pdf', 'browser', 'firefox', 'chrome', 'chromium', 'iframe', + 'iframe_connected', 'sphinx_gallery', 'sphinx_gallery_png'] + + title: str, default = "3D-Paths Animation" + When zoom=0 all objects are just inside the 3D-axes. + + color_sequence: list or array_like, iterable, default= + ['#2E91E5', '#E15F99', '#1CA71C', '#FB0D0D', '#DA16FF', '#222A2A', + '#B68100', '#750D86', '#EB663B', '#511CFB', '#00A08B', '#FB00D1', + '#FC0080', '#B2828D', '#6C7C32', '#778AAE', '#862A16', '#A777F1', + '#620042', '#1616A7', '#DA60CA', '#6C4516', '#0D2A63', '#AF0038'] + An iterable of color values used to cycle trough for every object displayed. + A color and may be specified as: + - A hex string (e.g. '#ff0000') + - An rgb/rgba string (e.g. 'rgb(255,0,0)') + - An hsl/hsla string (e.g. 'hsl(0,100%,50%)') + - An hsv/hsva string (e.g. 'hsv(0,100%,100%)') + - A named CSS color + + Returns + ------- + None: NoneType + """ + + flat_obj_list = format_obj_input(obj_list) + + show_fig = False + if fig is None: + show_fig = True + fig = go.Figure() + + # set animation and animation_time + if isinstance(animation, numbers.Number) and not isinstance(animation, bool): + kwargs["animation_time"] = animation + animation = True + if ( + not any( + getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list + ) + and animation is not False + ): # check if some path exist for any object + animation = False + warnings.warn("No path to be animated detected, displaying standard plot") + + animation_kwargs = { + k: v for k, v in kwargs.items() if k.split("_")[0] == "animation" + } + if animation is False: + kwargs = {k: v for k, v in kwargs.items() if k not in animation_kwargs} + else: + for k, v in Config.display.animation.as_dict().items(): + anim_key = f"animation_{k}" + if kwargs.get(anim_key, None) is None: + kwargs[anim_key] = v + + if obj_list: + style = getattr(obj_list[0], "style", None) + label = getattr(style, "label", None) + title = label if len(obj_list) == 1 else None + else: + title = "No objects to be displayed" + + if markers is not None and markers: + obj_list = list(obj_list) + [MagpyMarkers(*markers)] + + if color_sequence is None: + color_sequence = Config.display.colorsequence + + with fig.batch_update(): + if animation is not False: + title = "3D-Paths Animation" if title is None else title + animate_path( + fig=fig, + objs=obj_list, + color_sequence=color_sequence, + zoom=zoom, + title=title, + **kwargs, + ) + else: + traces = draw_frame(obj_list, color_sequence, zoom, output="list", **kwargs) + fig.add_traces(traces) + fig.update_layout(title_text=title) + apply_fig_ranges(fig, zoom=zoom) + clean_legendgroups(fig) + fig.update_layout(legend_itemsizing="constant") + if show_fig: + fig.show(renderer=renderer) diff --git a/magpylib/_src/display/sensor_mesh.py b/magpylib/_src/display/plotly/plotly_sensor_mesh.py similarity index 100% rename from magpylib/_src/display/sensor_mesh.py rename to magpylib/_src/display/plotly/plotly_sensor_mesh.py diff --git a/magpylib/_src/display/plotly/plotly_utility.py b/magpylib/_src/display/plotly/plotly_utility.py new file mode 100644 index 000000000..92cebb96e --- /dev/null +++ b/magpylib/_src/display/plotly/plotly_utility.py @@ -0,0 +1,147 @@ +"""utility functions for plotly backend""" +import numpy as np + + +def merge_mesh3d(*traces): + """Merges a list of plotly mesh3d dictionaries. The `i,j,k` index parameters need to cumulate + the indices of each object in order to point to the right vertices in the concatenated + vertices. `x,y,z,i,j,k` are mandatory fields, the `intensity` and `facecolor` parameters also + get concatenated if they are present in all objects. All other parameter found in the + dictionary keys are taken from the first object, other keys from further objects are ignored. + """ + merged_trace = {} + L = np.array([0] + [len(b["x"]) for b in traces[:-1]]).cumsum() + for k in "ijk": + if k in traces[0]: + merged_trace[k] = np.hstack([b[k] + l for b, l in zip(traces, L)]) + for k in "xyz": + merged_trace[k] = np.concatenate([b[k] for b in traces]) + for k in ("intensity", "facecolor"): + if k in traces[0] and traces[0][k] is not None: + merged_trace[k] = np.hstack([b[k] for b in traces]) + for k, v in traces[0].items(): + if k not in merged_trace: + merged_trace[k] = v + return merged_trace + + +def merge_scatter3d(*traces): + """Merges a list of plotly scatter3d. `x,y,z` are mandatory fields and are concatenated with a + `None` vertex to prevent line connection between objects to be concatenated. Keys are taken + from the first object, other keys from further objects are ignored. + """ + merged_trace = {} + for k in "xyz": + merged_trace[k] = np.hstack([pts for b in traces for pts in [[None], b[k]]]) + for k, v in traces[0].items(): + if k not in merged_trace: + merged_trace[k] = v + return merged_trace + + +def merge_traces(*traces): + """Merges a list of plotly 3d-traces. Supported trace types are `mesh3d` and `scatter3d`. + All traces have be of the same type when merging. Keys are taken from the first object, other + keys from further objects are ignored. + """ + if len(traces) > 1: + if traces[0]["type"] == "mesh3d": + trace = merge_mesh3d(*traces) + elif traces[0]["type"] == "scatter3d": + trace = merge_scatter3d(*traces) + elif len(traces) == 1: + trace = traces[0] + else: + trace = [] + return trace + + +def getIntensity(vertices, axis) -> np.ndarray: + """Calculates the intensity values for vertices based on the distance of the vertices to + the mean vertices position in the provided axis direction. It can be used for plotting + fields on meshes. If `mag` See more infos here:https://plotly.com/python/3d-mesh/ + + Parameters + ---------- + vertices : ndarray, shape (n,3) + The n vertices of the mesh object. + axis : ndarray, shape (3,) + Direction vector. + + Returns + ------- + Intensity values: ndarray, shape (n,) + """ + p = np.array(vertices).T + pos = np.mean(p, axis=1) + m = np.array(axis) + intensity = (p[0] - pos[0]) * m[0] + (p[1] - pos[1]) * m[1] + (p[2] - pos[2]) * m[2] + # normalize to interval [0,1] (necessary for when merging mesh3d traces) + ptp = np.ptp(intensity) + ptp = ptp if ptp != 0 else 1 + intensity = (intensity - np.min(intensity)) / ptp + return intensity + + +def getColorscale( + color_transition=0, + color_north="#E71111", # 'red' + color_middle="#DDDDDD", # 'grey' + color_south="#00B050", # 'green' +) -> list: + """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array + containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named + color string. At minimum, a mapping for the lowest (0) and highest (1) values is required. + For example, `[[0, 'rgb(0,0,255)'], [1,'rgb(255,0,0)']]`. In this case the colorscale + is created depending on the north/middle/south poles colors. If the middle color is + None, the colorscale will only have north and south pole colors. + + Parameters + ---------- + color_transition : float, default=0.1 + A value between 0 and 1. Sets the smoothness of the color transitions from adjacent colors + visualization. + color_north : str, default=None + Magnetic north pole color. + color_middle : str, default=None + Color of area between south and north pole. + color_south : str, default=None + Magnetic north pole color. + + Returns + ------- + colorscale: list + Colorscale as list of tuples. + """ + if color_middle is False: + colorscale = [ + [0.0, color_south], + [0.5 * (1 - color_transition), color_south], + [0.5 * (1 + color_transition), color_north], + [1, color_north], + ] + else: + colorscale = [ + [0.0, color_south], + [0.2 - 0.2 * (color_transition), color_south], + [0.2 + 0.3 * (color_transition), color_middle], + [0.8 - 0.3 * (color_transition), color_middle], + [0.8 + 0.2 * (color_transition), color_north], + [1.0, color_north], + ] + return colorscale + + +def clean_legendgroups(fig): + """removes legend duplicates""" + frames = [fig.data] + if fig.frames: + data_list = [f["data"] for f in fig.frames] + frames.extend(data_list) + for f in frames: + legendgroups = [] + for t in f: + if t.legendgroup not in legendgroups and t.legendgroup is not None: + legendgroups.append(t.legendgroup) + elif t.legendgroup is not None and t.legendgrouptitle.text is None: + t.showlegend = False diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py deleted file mode 100644 index 7537adfb8..000000000 --- a/magpylib/_src/display/traces_generic.py +++ /dev/null @@ -1,974 +0,0 @@ -"""Generic trace drawing functionalities""" -# pylint: disable=C0302 -# pylint: disable=too-many-branches -import numbers -import warnings -from itertools import combinations -from typing import Tuple - -import numpy as np -from scipy.spatial.transform import Rotation as RotScipy - -from magpylib import _src -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.display.sensor_mesh import get_sensor_mesh -from magpylib._src.display.traces_base import make_Arrow as make_BaseArrow -from magpylib._src.display.traces_base import make_Cuboid as make_BaseCuboid -from magpylib._src.display.traces_base import ( - make_CylinderSegment as make_BaseCylinderSegment, -) -from magpylib._src.display.traces_base import make_Ellipsoid as make_BaseEllipsoid -from magpylib._src.display.traces_base import make_Prism as make_BasePrism -from magpylib._src.display.traces_utility import draw_arrow_from_vertices -from magpylib._src.display.traces_utility import draw_arrowed_circle -from magpylib._src.display.traces_utility import draw_arrowed_line -from magpylib._src.display.traces_utility import get_flatten_objects_properties -from magpylib._src.display.traces_utility import get_rot_pos_from_path -from magpylib._src.display.traces_utility import get_scene_ranges -from magpylib._src.display.traces_utility import getColorscale -from magpylib._src.display.traces_utility import getIntensity -from magpylib._src.display.traces_utility import group_traces -from magpylib._src.display.traces_utility import MagpyMarkers -from magpylib._src.display.traces_utility import merge_mesh3d -from magpylib._src.display.traces_utility import merge_traces -from magpylib._src.display.traces_utility import place_and_orient_model3d -from magpylib._src.input_checks import check_excitations -from magpylib._src.style import get_style -from magpylib._src.utility import format_obj_input -from magpylib._src.utility import unit_prefix - -AUTOSIZE_OBJECTS = ("Sensor", "Dipole") - - -def make_DefaultTrace( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - **kwargs, -) -> dict: - """ - Creates the plotly scatter3d parameters for an object with no specifically supported - representation. The object will be represented by a scatter point and text above with object - name. - """ - style = obj.style if style is None else style - trace = dict( - type="scatter3d", - x=[0.0], - y=[0.0], - z=[0.0], - mode="markers+text", - marker_size=10, - marker_color=color, - marker_symbol="diamond", - ) - update_trace_name(trace, f"{type(obj).__name__}", "", style) - trace["text"] = trace["name"] - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Line( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - **kwargs, -) -> dict: - """ - Creates the plotly scatter3d parameters for a Line current in a dictionary based on the - provided arguments. - """ - style = obj.style if style is None else style - current = obj.current - vertices = obj.vertices - show_arrows = style.arrow.show - arrow_size = style.arrow.size - if show_arrows: - vertices = draw_arrow_from_vertices(vertices, current, arrow_size) - else: - vertices = np.array(vertices).T - x, y, z = vertices - trace = dict( - type="scatter3d", - x=x, - y=y, - z=z, - mode="lines", - line_width=style.arrow.width, - line_color=color, - ) - default_suffix = ( - f" ({unit_prefix(current)}A)" - if current is not None - else " (Current not initialized)" - ) - update_trace_name(trace, "Line", default_suffix, style) - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Loop( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - vertices=50, - **kwargs, -): - """ - Creates the plotly scatter3d parameters for a Loop current in a dictionary based on the - provided arguments. - """ - style = obj.style if style is None else style - current = obj.current - diameter = obj.diameter - arrow_size = style.arrow.size if style.arrow.show else 0 - vertices = draw_arrowed_circle(current, diameter, arrow_size, vertices) - x, y, z = vertices - trace = dict( - type="scatter3d", - x=x, - y=y, - z=z, - mode="lines", - line_width=style.arrow.width, - line_color=color, - ) - default_suffix = ( - f" ({unit_prefix(current)}A)" - if current is not None - else " (Current not initialized)" - ) - update_trace_name(trace, "Loop", default_suffix, style) - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Dipole( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - autosize=None, - **kwargs, -) -> dict: - """ - Create the plotly mesh3d parameters for a Loop current in a dictionary based on the - provided arguments. - """ - style = obj.style if style is None else style - moment = obj.moment - moment_mag = np.linalg.norm(moment) - size = style.size - if autosize is not None: - size *= autosize - trace = make_BaseArrow( - "plotly-dict", - base=10, - diameter=0.3 * size, - height=size, - pivot=style.pivot, - color=color, - ) - default_suffix = f" (moment={unit_prefix(moment_mag)}mT mm³)" - update_trace_name(trace, "Dipole", default_suffix, style) - nvec = np.array(moment) / moment_mag - zaxis = np.array([0, 0, 1]) - cross = np.cross(nvec, zaxis) - n = np.linalg.norm(cross) - if n == 0: - n = 1 - cross = np.array([-np.sign(nvec[-1]), 0, 0]) - dot = np.dot(nvec, zaxis) - t = np.arccos(dot) - vec = -t * cross / n - mag_orient = RotScipy.from_rotvec(vec) - orientation = orientation * mag_orient - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Cuboid( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - **kwargs, -) -> dict: - """ - Create the plotly mesh3d parameters for a Cuboid Magnet in a dictionary based on the - provided arguments. - """ - style = obj.style if style is None else style - dimension = obj.dimension - d = [unit_prefix(d / 1000) for d in dimension] - trace = make_BaseCuboid("plotly-dict", dimension=dimension, color=color) - default_suffix = f" ({d[0]}m|{d[1]}m|{d[2]}m)" - update_trace_name(trace, "Cuboid", default_suffix, style) - update_magnet_mesh( - trace, mag_style=style.magnetization, magnetization=obj.magnetization - ) - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Cylinder( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - base=50, - **kwargs, -) -> dict: - """ - Create the plotly mesh3d parameters for a Cylinder Magnet in a dictionary based on the - provided arguments. - """ - style = obj.style if style is None else style - diameter, height = obj.dimension - d = [unit_prefix(d / 1000) for d in (diameter, height)] - trace = make_BasePrism( - "plotly-dict", base=base, diameter=diameter, height=height, color=color - ) - default_suffix = f" (D={d[0]}m, H={d[1]}m)" - update_trace_name(trace, "Cylinder", default_suffix, style) - update_magnet_mesh( - trace, mag_style=style.magnetization, magnetization=obj.magnetization - ) - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_CylinderSegment( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - vertices=25, - **kwargs, -): - """ - Create the plotly mesh3d parameters for a Cylinder Segment Magnet in a dictionary based on the - provided arguments. - """ - style = obj.style if style is None else style - dimension = obj.dimension - d = [unit_prefix(d / (1000 if i < 3 else 1)) for i, d in enumerate(dimension)] - trace = make_BaseCylinderSegment( - "plotly-dict", dimension=dimension, vert=vertices, color=color - ) - default_suffix = f" (r={d[0]}m|{d[1]}m, h={d[2]}m, φ={d[3]}°|{d[4]}°)" - update_trace_name(trace, "CylinderSegment", default_suffix, style) - update_magnet_mesh( - trace, mag_style=style.magnetization, magnetization=obj.magnetization - ) - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Sphere( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - vertices=15, - **kwargs, -) -> dict: - """ - Create the plotly mesh3d parameters for a Sphere Magnet in a dictionary based on the - provided arguments. - """ - style = obj.style if style is None else style - diameter = obj.diameter - vertices = min(max(vertices, 3), 20) - trace = make_BaseEllipsoid( - "plotly-dict", vert=vertices, dimension=[diameter] * 3, color=color - ) - default_suffix = f" (D={unit_prefix(diameter / 1000)}m)" - update_trace_name(trace, "Sphere", default_suffix, style) - update_magnet_mesh( - trace, mag_style=style.magnetization, magnetization=obj.magnetization - ) - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Pixels(positions, size=1) -> dict: - """ - Create the plotly mesh3d parameters for Sensor pixels based on pixel positions and chosen size - For now, only "cube" shape is provided. - """ - pixels = [ - make_BaseCuboid("plotly-dict", position=p, dimension=[size] * 3) - for p in positions - ] - return merge_mesh3d(*pixels) - - -def make_Sensor( - obj, - position=(0.0, 0.0, 0.0), - orientation=None, - color=None, - style=None, - autosize=None, - **kwargs, -): - """ - Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the - provided arguments. - - size_pixels: float, default=1 - A positive number. Adjusts automatic display size of sensor pixels. When set to 0, - pixels will be hidden, when greater than 0, pixels will occupy half the ratio of the minimum - distance between any pixel of the same sensor, equal to `size_pixel`. - """ - style = obj.style if style is None else style - dimension = getattr(obj, "dimension", style.size) - pixel = obj.pixel - pixel = np.array(pixel).reshape((-1, 3)) - style_arrows = style.arrows.as_dict(flatten=True, separator="_") - sensor = get_sensor_mesh(**style_arrows, center_color=color) - vertices = np.array([sensor[k] for k in "xyz"]).T - if color is not None: - sensor["facecolor"][sensor["facecolor"] == "rgb(238,238,238)"] = color - dim = np.array( - [dimension] * 3 if isinstance(dimension, (float, int)) else dimension[:3], - dtype=float, - ) - if autosize is not None: - dim *= autosize - if pixel.shape[0] == 1: - dim_ext = dim - else: - hull_dim = pixel.max(axis=0) - pixel.min(axis=0) - dim_ext = max(np.mean(dim), np.min(hull_dim)) - cube_mask = (vertices < 1).all(axis=1) - vertices[cube_mask] = 0 * vertices[cube_mask] - vertices[~cube_mask] = dim_ext * vertices[~cube_mask] - vertices /= 2 # sensor_mesh vertices are of length 2 - x, y, z = vertices.T - sensor.update(x=x, y=y, z=z) - meshes_to_merge = [sensor] - if pixel.shape[0] != 1: - pixel_color = style.pixel.color - pixel_size = style.pixel.size - combs = np.array(list(combinations(pixel, 2))) - vecs = np.diff(combs, axis=1) - dists = np.linalg.norm(vecs, axis=2) - pixel_dim = np.min(dists) / 2 - if pixel_size > 0: - pixel_dim *= pixel_size - pixels_mesh = make_Pixels(positions=pixel, size=pixel_dim) - pixels_mesh["facecolor"] = np.repeat(pixel_color, len(pixels_mesh["i"])) - meshes_to_merge.append(pixels_mesh) - hull_pos = 0.5 * (pixel.max(axis=0) + pixel.min(axis=0)) - hull_dim[hull_dim == 0] = pixel_dim / 2 - hull_mesh = make_BaseCuboid( - "plotly-dict", position=hull_pos, dimension=hull_dim - ) - hull_mesh["facecolor"] = np.repeat(color, len(hull_mesh["i"])) - meshes_to_merge.append(hull_mesh) - trace = merge_mesh3d(*meshes_to_merge) - default_suffix = ( - f""" ({'x'.join(str(p) for p in pixel.shape[:-1])} pixels)""" - if pixel.ndim != 1 - else "" - ) - update_trace_name(trace, "Sensor", default_suffix, style) - return place_and_orient_model3d( - trace, orientation=orientation, position=position, **kwargs - ) - - -def make_Marker(obj, color=None, style=None, **kwargs): - """Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the - provided arguments.""" - style = obj.style if style is None else style - x, y, z = obj.markers.T - marker_kwargs = { - f"marker_{k}": v - for k, v in style.marker.as_dict(flatten=True, separator="_").items() - } - if marker_kwargs["marker_color"] is None: - marker_kwargs["marker_color"] = ( - style.color if style.color is not None else color - ) - trace = dict( - type="scatter3d", - x=x, - y=y, - z=z, - mode="markers", - **marker_kwargs, - **kwargs, - ) - default_name = "Marker" if len(x) == 1 else "Markers" - default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" - update_trace_name(trace, default_name, default_suffix, style) - return trace - - -def update_magnet_mesh(mesh_dict, mag_style=None, magnetization=None): - """ - Updates an existing plotly mesh3d dictionary of an object which has a magnetic vector. The - object gets colorized, positioned and oriented based on provided arguments. - """ - mag_color = mag_style.color - if magnetization is not None and mag_style.show: - vertices = np.array([mesh_dict[k] for k in "xyz"]).T - color_middle = mag_color.middle - if mag_color.mode == "tricycle": - color_middle = mesh_dict["color"] - elif mag_color.mode == "bicolor": - color_middle = False - mesh_dict["colorscale"] = getColorscale( - color_transition=mag_color.transition, - color_north=mag_color.north, - color_middle=color_middle, - color_south=mag_color.south, - ) - mesh_dict["intensity"] = getIntensity( - vertices=vertices, - axis=magnetization, - ) - mesh_dict["showscale"] = False - return mesh_dict - - -def update_trace_name(trace, default_name, default_suffix, style): - """provides legend entry based on name and suffix""" - name = default_name if style.label is None else style.label - if style.description.show and style.description.text is None: - name_suffix = default_suffix - elif not style.description.show: - name_suffix = "" - else: - name_suffix = f" ({style.description.text})" - trace.update(name=f"{name}{name_suffix}") - return trace - - -def make_mag_arrows(obj, style, legendgroup, kwargs): - """draw direction of magnetization of faced magnets - - Parameters - ---------- - - faced_objects(list of src objects): with magnetization vector to be drawn - - colors: colors of faced_objects - - show_path(bool or int): draw on every position where object is displayed - """ - # pylint: disable=protected-access - - # add src attributes position and orientation depending on show_path - rots, _, inds = get_rot_pos_from_path(obj, style.path.frames) - - # vector length, color and magnetization - if obj._object_type in ("Cuboid", "Cylinder"): - length = 1.8 * np.amax(obj.dimension) - elif obj._object_type == "CylinderSegment": - length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h - else: - length = 1.8 * obj.diameter # Sphere - length *= style.magnetization.size - mag = obj.magnetization - # collect all draw positions and directions - points = [] - for rot, ind in zip(rots, inds): - pos = getattr(obj, "_barycenter", obj._position)[ind] - direc = mag / (np.linalg.norm(mag) + 1e-6) * length - vec = rot.apply(direc) - pts = draw_arrowed_line(vec, pos, sign=1, arrow_pos=1, pivot="tail") - points.append(pts) - # insert empty point to avoid connecting line between arrows - points = np.array(points) - points = np.insert(points, points.shape[-1], np.nan, axis=2) - # remove last nan after insert with [:-1] - x, y, z = np.concatenate(points.swapaxes(1, 2))[:-1].T - trace = { - "type": "scatter3d", - "mode": "lines", - "line_color": kwargs["color"], - "opacity": kwargs["opacity"], - "x": x, - "y": y, - "z": z, - "legendgroup": legendgroup, - "showlegend": False, - } - return trace - - -def make_path(input_obj, style, legendgroup, kwargs): - """draw obj path based on path style properties""" - x, y, z = np.array(input_obj.position).T - txt_kwargs = ( - {"mode": "markers+text+lines", "text": list(range(len(x)))} - if style.path.numbering - else {"mode": "markers+lines"} - ) - marker = style.path.marker.as_dict() - marker["symbol"] = marker["symbol"] - marker["color"] = kwargs["color"] if marker["color"] is None else marker["color"] - line = style.path.line.as_dict() - line["dash"] = line["style"] - line["color"] = kwargs["color"] if line["color"] is None else line["color"] - line = {k: v for k, v in line.items() if k != "style"} - scatter_path = dict( - type="scatter3d", - x=x, - y=y, - z=z, - name=f"Path: {input_obj}", - showlegend=False, - legendgroup=legendgroup, - **{f"marker_{k}": v for k, v in marker.items()}, - **{f"line_{k}": v for k, v in line.items()}, - **txt_kwargs, - opacity=kwargs["opacity"], - ) - return scatter_path - - -def get_generic_traces( - input_obj, - make_func=None, - color=None, - autosize=None, - legendgroup=None, - showlegend=None, - legendtext=None, - mag_arrows=False, - extra_backend=False, - **kwargs, -) -> list: - """ - This is a helper function providing the plotly traces for any object of the magpylib library. If - the object is not supported, the trace representation will fall back to a single scatter point - with the object name marked above it. - - - If the object has a path (multiple positions), the function will return both the object trace - and the corresponding path trace. The legend entry of the path trace will be hidden but both - traces will share the same `legendgroup` so that a legend entry click will hide/show both traces - at once. From the user's perspective, the traces will be merged. - - - The argument caught by the kwargs dictionary must all be arguments supported both by - `scatter3d` and `mesh3d` plotly objects, otherwise an error will be raised. - """ - - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - # pylint: disable=too-many-nested-blocks - - # parse kwargs into style and non style args - style = get_style(input_obj, Config, **kwargs) - kwargs = {k: v for k, v in kwargs.items() if not k.startswith("style")} - kwargs["style"] = style - style_color = getattr(style, "color", None) - kwargs["color"] = style_color if style_color is not None else color - kwargs["opacity"] = style.opacity - legendgroup = f"{input_obj}" if legendgroup is None else legendgroup - - # check excitations validity - for param in ("magnetization", "arrow"): - if getattr(getattr(style, param, None), "show", False): - check_excitations([input_obj]) - - label = getattr(getattr(input_obj, "style", None), "label", None) - label = label if label is not None else str(type(input_obj).__name__) - - object_type = getattr(input_obj, "_object_type", None) - if object_type != "Collection": - make_func = globals().get(f"make_{object_type}", make_DefaultTrace) - make_func_kwargs = kwargs.copy() - if object_type in AUTOSIZE_OBJECTS: - make_func_kwargs["autosize"] = autosize - - traces = [] - path_traces = [] - path_traces_extra_generic = {} - path_traces_extra_specific_backend = [] - has_path = hasattr(input_obj, "position") and hasattr(input_obj, "orientation") - if not has_path: - traces = [make_func(input_obj, **make_func_kwargs)] - out = (traces,) - if extra_backend is not False: - out += (path_traces_extra_specific_backend,) - return out[0] if len(out) == 1 else out - - extra_model3d_traces = style.model3d.data if style.model3d.data is not None else [] - orientations, positions, _ = get_rot_pos_from_path(input_obj, style.path.frames) - for pos_orient_ind, (orient, pos) in enumerate(zip(orientations, positions)): - if style.model3d.showdefault and make_func is not None: - path_traces.append( - make_func( - input_obj, position=pos, orientation=orient, **make_func_kwargs - ) - ) - for extr in extra_model3d_traces: - if extr.show: - extr.update(extr.updatefunc()) - if extr.backend == "generic": - trace3d = {"opacity": kwargs["opacity"]} - ttype = extr.constructor.lower() - obj_extr_trace = ( - extr.kwargs() if callable(extr.kwargs) else extr.kwargs - ) - obj_extr_trace = {"type": ttype, **obj_extr_trace} - if ttype == "scatter3d": - for k in ("marker", "line"): - trace3d[f"{k}_color"] = trace3d.get( - f"{k}_color", kwargs["color"] - ) - elif ttype == "mesh3d": - trace3d["showscale"] = trace3d.get("showscale", False) - if "facecolor" in obj_extr_trace: - ttype = "mesh3d_facecolor" - trace3d["color"] = trace3d.get("color", kwargs["color"]) - else: - raise ValueError( - f"{ttype} is not supported, only 'scatter3d' and 'mesh3d' are" - ) - trace3d.update( - linearize_dict( - place_and_orient_model3d( - model_kwargs=obj_extr_trace, - orientation=orient, - position=pos, - scale=extr.scale, - ), - separator="_", - ) - ) - if ttype not in path_traces_extra_generic: - path_traces_extra_generic[ttype] = [] - path_traces_extra_generic[ttype].append(trace3d) - elif extr.backend == extra_backend: - showleg = ( - showlegend - and pos_orient_ind == 0 - and not style.model3d.showdefault - ) - showleg = True if showleg is None else showleg - trace3d = { - "model3d": extr, - "position": pos, - "orientation": orient, - "kwargs": { - "opacity": kwargs["opacity"], - "color": kwargs["color"], - "legendgroup": legendgroup, - "name": label, - "showlegend": showleg, - }, - } - path_traces_extra_specific_backend.append(trace3d) - trace = merge_traces(*path_traces) - for ind, traces_extra in enumerate(path_traces_extra_generic.values()): - extra_model3d_trace = merge_traces(*traces_extra) - extra_model3d_trace.update( - { - "legendgroup": legendgroup, - "showlegend": showlegend and ind == 0 and not trace, - "name": label, - } - ) - traces.append(extra_model3d_trace) - - if trace: - trace.update( - { - "legendgroup": legendgroup, - "showlegend": True if showlegend is None else showlegend, - } - ) - if legendtext is not None: - trace["name"] = legendtext - traces.append(trace) - - if np.array(input_obj.position).ndim > 1 and style.path.show: - scatter_path = make_path(input_obj, style, legendgroup, kwargs) - traces.append(scatter_path) - - if mag_arrows and getattr(input_obj, "magnetization", None) is not None: - traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) - out = (traces,) - if extra_backend is not False: - out += (path_traces_extra_specific_backend,) - return out[0] if len(out) == 1 else out - - -def clean_legendgroups(frames): - """removes legend duplicates for a plotly figure""" - for fr in frames: - legendgroups = [] - for tr in fr["data"]: - lg = tr.get("legendgroup", None) - if lg is not None and lg not in legendgroups: - legendgroups.append(lg) - elif lg is not None: # and tr.legendgrouptitle.text is None: - tr["showlegend"] = False - - -def process_animation_kwargs(obj_list, animation=False, **kwargs): - """Update animation kwargs""" - markers = [o for o in obj_list if isinstance(o, MagpyMarkers)] - flat_obj_list = format_obj_input([o for o in obj_list if o not in markers]) - flat_obj_list.extend(markers) - # set animation and animation_time - if isinstance(animation, numbers.Number) and not isinstance(animation, bool): - kwargs["animation_time"] = animation - animation = True - if ( - not any( - getattr(obj, "position", np.array([])).ndim > 1 for obj in flat_obj_list - ) - and animation is not False - ): # check if some path exist for any object - animation = False - warnings.warn("No path to be animated detected, displaying standard plot") - - anim_def = Config.display.animation.copy() - anim_def.update({k[10:]: v for k, v in kwargs.items()}, _match_properties=False) - animation_kwargs = {f"animation_{k}": v for k, v in anim_def.as_dict().items()} - kwargs = {k: v for k, v in kwargs.items() if not k.startswith("animation")} - return kwargs, animation, animation_kwargs - - -def extract_animation_properties( - objs, - *, - animation_maxfps, - animation_time, - animation_fps, - animation_maxframes, - # pylint: disable=unused-argument - animation_slider, -): - """Exctract animation properties""" - path_lengths = [] - for obj in objs: - subobjs = [obj] - if getattr(obj, "_object_type", None) == "Collection": - subobjs.extend(obj.children) - for subobj in subobjs: - path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] - path_lengths.append(path_len) - - max_pl = max(path_lengths) - if animation_fps > animation_maxfps: - warnings.warn( - f"The set `animation_fps` at {animation_fps} is greater than the max allowed of" - f" {animation_maxfps}. `animation_fps` will be set to" - f" {animation_maxfps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxfps`" - ) - animation_fps = animation_maxfps - - maxpos = min(animation_time * animation_fps, animation_maxframes) - - if max_pl <= maxpos: - path_indices = np.arange(max_pl) - else: - round_step = max_pl / (maxpos - 1) - ar = np.linspace(0, max_pl, max_pl, endpoint=False) - path_indices = np.unique(np.floor(ar / round_step) * round_step).astype( - int - ) # downsampled indices - path_indices[-1] = ( - max_pl - 1 - ) # make sure the last frame is the last path position - - # calculate exponent of last frame index to avoid digit shift in - # frame number display during animation - exp = ( - np.log10(path_indices.max()).astype(int) + 1 - if path_indices.ndim != 0 and path_indices.max() > 0 - else 1 - ) - - frame_duration = int(animation_time * 1000 / path_indices.shape[0]) - new_fps = int(1000 / frame_duration) - if max_pl > animation_maxframes: - warnings.warn( - f"The number of frames ({max_pl}) is greater than the max allowed " - f"of {animation_maxframes}. The `animation_fps` will be set to {new_fps}. " - f"You can modify the default value by setting it in " - "`magpylib.defaults.display.animation.maxframes`" - ) - - return path_indices, exp, frame_duration - - -def draw_frame( - obj_list_semi_flat, - colorsequence=None, - zoom=0.0, - autosize=None, - output="dict", - mag_arrows=False, - extra_backend=False, - **kwargs, -) -> Tuple: - """ - Creates traces from input `objs` and provided parameters, updates the size of objects like - Sensors and Dipoles in `kwargs` depending on the canvas size. - - Returns - ------- - traces_dicts, kwargs: dict, dict - returns the traces in a obj/traces_list dictionary and updated kwargs - """ - # pylint: disable=protected-access - if colorsequence is None: - colorsequence = Config.display.colorsequence - extra_backend_traces = [] - Sensor = _src.obj_classes.class_Sensor.Sensor - Dipole = _src.obj_classes.class_misc_Dipole.Dipole - traces_out = {} - # dipoles and sensors use autosize, the trace building has to be put at the back of the queue. - # autosize is calculated from the other traces overall scene range - traces_to_resize = {} - flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, colorsequence=colorsequence - ) - for obj, params in flat_objs_props.items(): - params.update(kwargs) - if isinstance(obj, (Dipole, Sensor)): - traces_to_resize[obj] = {**params} - # temporary coordinates to be able to calculate ranges - x, y, z = obj._position.T - traces_out[obj] = [dict(x=x, y=y, z=z)] - else: - out_traces = get_generic_traces( - obj, - mag_arrows=mag_arrows, - extra_backend=extra_backend, - **params, - ) - if extra_backend is not False: - out_traces, ebt = out_traces - extra_backend_traces.extend(ebt) - traces_out[obj] = out_traces - traces = [t for tr in traces_out.values() for t in tr] - ranges = get_scene_ranges(*traces, zoom=zoom) - if autosize is None or autosize == "return": - autosize = np.mean(np.diff(ranges)) / Config.display.autosizefactor - for obj, params in traces_to_resize.items(): - out_traces = get_generic_traces( - obj, - autosize=autosize, - mag_arrows=mag_arrows, - extra_backend=extra_backend, - **params, - ) - if extra_backend is not False: - out_traces, ebt = out_traces - extra_backend_traces.extend(ebt) - traces_out[obj] = out_traces - if output == "list": - traces = [t for tr in traces_out.values() for t in tr] - traces_out = group_traces(*traces) - return traces_out, autosize, ranges, extra_backend_traces - - -def get_frames( - objs, - colorsequence=None, - zoom=1, - title=None, - animation=False, - mag_arrows=False, - extra_backend=False, - **kwargs, -): - """This is a helper function which generates frames with generic traces to be provided to - the chosen backend. According to a certain zoom level, all three space direction will be equal - and match the maximum of the ranges needed to display all objects, including their paths. - """ - # infer title if necessary - if objs: - style = getattr(objs[0], "style", None) - label = getattr(style, "label", None) - title = label if len(objs) == 1 else None - else: - title = "No objects to be displayed" - - # make sure the number of frames does not exceed the max frames and max frame rate - # downsample if necessary - kwargs, animation, animation_kwargs = process_animation_kwargs( - objs, animation=animation, **kwargs - ) - path_indices = [-1] - if animation: - path_indices, exp, frame_duration = extract_animation_properties( - objs, **animation_kwargs - ) - - # create frame for each path index or downsampled path index - frames = [] - autosize = "return" - title_str = title - for i, ind in enumerate(path_indices): - extra_backend_traces = [] - if animation: - kwargs["style_path_frames"] = [ind] - title = "Animation 3D - " if title is None else title - title_str = f"""{title}path index: {ind+1:0{exp}d}""" - traces, autosize_init, ranges, extra_backend_traces = draw_frame( - objs, - colorsequence, - zoom, - autosize=autosize, - output="list", - mag_arrows=mag_arrows, - extra_backend=extra_backend, - **kwargs, - ) - if i == 0: # get the dipoles and sensors autosize from first frame - autosize = autosize_init - frames.append( - dict( - data=traces, - name=str(ind + 1), - layout=dict(title=title_str), - extra_backend_traces=extra_backend_traces, - ) - ) - - clean_legendgroups(frames) - traces = [t for frame in frames for t in frame["data"]] - ranges = get_scene_ranges(*traces, zoom=zoom) - out = { - "frames": frames, - "ranges": ranges, - } - if animation: - out.update( - { - "frame_duration": frame_duration, - "path_indices": path_indices, - "animation_slider": animation_kwargs["animation_slider"], - } - ) - return out diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py deleted file mode 100644 index 6eca71e61..000000000 --- a/magpylib/_src/display/traces_utility.py +++ /dev/null @@ -1,483 +0,0 @@ -""" Display function codes""" -from functools import lru_cache -from itertools import cycle -from typing import Tuple - -import numpy as np -from scipy.spatial.transform import Rotation as RotScipy - -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.style import Markers - - -class MagpyMarkers: - """A class that stores markers 3D-coordinates""" - - _object_type = "Marker" - - def __init__(self, *markers): - self.style = Markers() - self.markers = np.array(markers) - - -# pylint: disable=too-many-branches -def place_and_orient_model3d( - model_kwargs, - model_args=None, - orientation=None, - position=None, - coordsargs=None, - scale=1, - return_vertices=False, - return_model_args=False, - **kwargs, -): - """places and orients mesh3d dict""" - if orientation is None and position is None: - return {**model_kwargs, **kwargs} - position = (0.0, 0.0, 0.0) if position is None else position - position = np.array(position, dtype=float) - new_model_dict = {} - if model_args is None: - model_args = () - new_model_args = list(model_args) - if model_args: - if coordsargs is None: # matplotlib default - coordsargs = dict(x="args[0]", y="args[1]", z="args[2]") - vertices = [] - if coordsargs is None: - coordsargs = {"x": "x", "y": "y", "z": "z"} - useargs = False - for k in "xyz": - key = coordsargs[k] - if key.startswith("args"): - useargs = True - ind = int(key[5]) - v = model_args[ind] - else: - if key in model_kwargs: - v = model_kwargs[key] - else: - raise ValueError( - "Rotating/Moving of provided model failed, trace dictionary " - f"has no argument {k!r}, use `coordsargs` to specify the names of the " - "coordinates to be used.\n" - "Matplotlib backends will set up coordsargs automatically if " - "the `args=(xs,ys,zs)` argument is provided." - ) - vertices.append(v) - - vertices = np.array(vertices) - - # sometimes traces come as (n,m,3) shape - vert_shape = vertices.shape - vertices = np.reshape(vertices, (3, -1)) - - vertices = vertices.T - - if orientation is not None: - vertices = orientation.apply(vertices) - new_vertices = (vertices * scale + position).T - new_vertices = np.reshape(new_vertices, vert_shape) - for i, k in enumerate("xyz"): - key = coordsargs[k] - if useargs: - ind = int(key[5]) - new_model_args[ind] = new_vertices[i] - else: - new_model_dict[key] = new_vertices[i] - new_model_kwargs = {**model_kwargs, **new_model_dict, **kwargs} - - out = (new_model_kwargs,) - if return_model_args: - out += (new_model_args,) - if return_vertices: - out += (new_vertices,) - return out[0] if len(out) == 1 else out - - -def draw_arrowed_line( - vec, pos, sign=1, arrow_size=1, arrow_pos=0.5, pivot="middle" -) -> Tuple: - """ - Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and - centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and - moved to position `pos`. - """ - norm = np.linalg.norm(vec) - nvec = np.array(vec) / norm - yaxis = np.array([0, 1, 0]) - cross = np.cross(nvec, yaxis) - dot = np.dot(nvec, yaxis) - n = np.linalg.norm(cross) - arrow_shift = arrow_pos - 0.5 - if dot == -1: - sign *= -1 - hy = sign * 0.1 * arrow_size - hx = 0.06 * arrow_size - anchor = ( - (0, -0.5, 0) - if pivot == "tip" - else (0, 0.5, 0) - if pivot == "tail" - else (0, 0, 0) - ) - arrow = ( - np.array( - [ - [0, -0.5, 0], - [0, arrow_shift, 0], - [-hx, arrow_shift - hy, 0], - [0, arrow_shift, 0], - [hx, arrow_shift - hy, 0], - [0, arrow_shift, 0], - [0, 0.5, 0], - ] - + np.array(anchor) - ) - * norm - ) - if n != 0: - t = np.arccos(dot) - R = RotScipy.from_rotvec(-t * cross / n) - arrow = R.apply(arrow) - x, y, z = (arrow + pos).T - return x, y, z - - -def draw_arrow_from_vertices(vertices, current, arrow_size): - """returns scatter coordinates of arrows between input vertices""" - vectors = np.diff(vertices, axis=0) - positions = vertices[:-1] + vectors / 2 - vertices = np.concatenate( - [ - draw_arrowed_line(vec, pos, np.sign(current), arrow_size=arrow_size) - for vec, pos in zip(vectors, positions) - ], - axis=1, - ) - - return vertices - - -def draw_arrowed_circle(current, diameter, arrow_size, vert): - """draws an oriented circle with an arrow""" - t = np.linspace(0, 2 * np.pi, vert) - x = np.cos(t) - y = np.sin(t) - if arrow_size != 0: - hy = 0.2 * np.sign(current) * arrow_size - hx = 0.15 * arrow_size - x = np.hstack([x, [1 + hx, 1, 1 - hx]]) - y = np.hstack([y, [-hy, 0, -hy]]) - x = x * diameter / 2 - y = y * diameter / 2 - z = np.zeros(x.shape) - vertices = np.array([x, y, z]) - return vertices - - -def get_rot_pos_from_path(obj, show_path=None): - """ - subsets orientations and positions depending on `show_path` value. - examples: - show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6] - returns rots[[1,2,6]], poss[[1,2,6]] - """ - # pylint: disable=protected-access - # pylint: disable=invalid-unary-operand-type - if show_path is None: - show_path = True - pos = getattr(obj, "_position", None) - if pos is None: - pos = obj.position - pos = np.array(pos) - orient = getattr(obj, "_orientation", None) - if orient is None: - orient = getattr(obj, "orientation", None) - if orient is None: - orient = RotScipy.from_rotvec([[0, 0, 1]]) - pos = np.array([pos]) if pos.ndim == 1 else pos - path_len = pos.shape[0] - if show_path is True or show_path is False or show_path == 0: - inds = np.array([-1]) - elif isinstance(show_path, int): - inds = np.arange(path_len, dtype=int)[::-show_path] - elif hasattr(show_path, "__iter__") and not isinstance(show_path, str): - inds = np.array(show_path) - inds[inds >= path_len] = path_len - 1 - inds = np.unique(inds) - if inds.size == 0: - inds = np.array([path_len - 1]) - rots = orient[inds] - poss = pos[inds] - return rots, poss, inds - - -def get_flatten_objects_properties( - *obj_list_semi_flat, - colorsequence=None, - color_cycle=None, - **parent_props, -): - """returns a flat dict -> (obj: display_props, ...) from nested collections""" - if colorsequence is None: - colorsequence = Config.display.colorsequence - if color_cycle is None: - color_cycle = cycle(colorsequence) - flat_objs = {} - for subobj in obj_list_semi_flat: - isCollection = getattr(subobj, "children", None) is not None - props = {**parent_props} - parent_color = parent_props.get("color", "!!!missing!!!") - if parent_color == "!!!missing!!!": - props["color"] = next(color_cycle) - if parent_props.get("legendgroup", None) is None: - props["legendgroup"] = f"{subobj}" - if parent_props.get("showlegend", None) is None: - props["showlegend"] = True - if parent_props.get("legendtext", None) is None: - legendtext = None - if isCollection: - legendtext = getattr(getattr(subobj, "style", None), "label", None) - legendtext = f"{subobj!r}" if legendtext is None else legendtext - props["legendtext"] = legendtext - flat_objs[subobj] = props - if isCollection: - if subobj.style.color is not None: - flat_objs[subobj]["color"] = subobj.style.color - flat_objs.update( - get_flatten_objects_properties( - *subobj.children, - colorsequence=colorsequence, - color_cycle=color_cycle, - **flat_objs[subobj], - ) - ) - return flat_objs - - -def merge_mesh3d(*traces): - """Merges a list of plotly mesh3d dictionaries. The `i,j,k` index parameters need to cumulate - the indices of each object in order to point to the right vertices in the concatenated - vertices. `x,y,z,i,j,k` are mandatory fields, the `intensity` and `facecolor` parameters also - get concatenated if they are present in all objects. All other parameter found in the - dictionary keys are taken from the first object, other keys from further objects are ignored. - """ - merged_trace = {} - L = np.array([0] + [len(b["x"]) for b in traces[:-1]]).cumsum() - for k in "ijk": - if k in traces[0]: - merged_trace[k] = np.hstack([b[k] + l for b, l in zip(traces, L)]) - for k in "xyz": - merged_trace[k] = np.concatenate([b[k] for b in traces]) - for k in ("intensity", "facecolor"): - if k in traces[0] and traces[0][k] is not None: - merged_trace[k] = np.hstack([b[k] for b in traces]) - for k, v in traces[0].items(): - if k not in merged_trace: - merged_trace[k] = v - return merged_trace - - -def merge_scatter3d(*traces): - """Merges a list of plotly scatter3d. `x,y,z` are mandatory fields and are concatenated with a - `None` vertex to prevent line connection between objects to be concatenated. Keys are taken - from the first object, other keys from further objects are ignored. - """ - merged_trace = {} - for k in "xyz": - merged_trace[k] = np.hstack([pts for b in traces for pts in [[None], b[k]]]) - for k, v in traces[0].items(): - if k not in merged_trace: - merged_trace[k] = v - return merged_trace - - -def merge_traces(*traces): - """Merges a list of plotly 3d-traces. Supported trace types are `mesh3d` and `scatter3d`. - All traces have be of the same type when merging. Keys are taken from the first object, other - keys from further objects are ignored. - """ - if len(traces) > 1: - if traces[0]["type"] == "mesh3d": - trace = merge_mesh3d(*traces) - elif traces[0]["type"] == "scatter3d": - trace = merge_scatter3d(*traces) - elif len(traces) == 1: - trace = traces[0] - else: - trace = [] - return trace - - -def getIntensity(vertices, axis) -> np.ndarray: - """Calculates the intensity values for vertices based on the distance of the vertices to - the mean vertices position in the provided axis direction. It can be used for plotting - fields on meshes. If `mag` See more infos here:https://plotly.com/python/3d-mesh/ - - Parameters - ---------- - vertices : ndarray, shape (n,3) - The n vertices of the mesh object. - axis : ndarray, shape (3,) - Direction vector. - - Returns - ------- - Intensity values: ndarray, shape (n,) - """ - p = np.array(vertices).T - pos = np.mean(p, axis=1) - m = np.array(axis) - intensity = (p[0] - pos[0]) * m[0] + (p[1] - pos[1]) * m[1] + (p[2] - pos[2]) * m[2] - # normalize to interval [0,1] (necessary for when merging mesh3d traces) - ptp = np.ptp(intensity) - ptp = ptp if ptp != 0 else 1 - intensity = (intensity - np.min(intensity)) / ptp - return intensity - - -@lru_cache(maxsize=32) -def getColorscale( - color_transition=0, - color_north="#E71111", # 'red' - color_middle="#DDDDDD", # 'grey' - color_south="#00B050", # 'green' -) -> Tuple: - """Provides the colorscale for a plotly mesh3d trace. The colorscale must be an array - containing arrays mapping a normalized value to an rgb, rgba, hex, hsl, hsv, or named - color string. At minimum, a mapping for the lowest (0) and highest (1) values is required. - For example, `[[0, 'rgb(0,0,255)'], [1,'rgb(255,0,0)']]`. In this case the colorscale - is created depending on the north/middle/south poles colors. If the middle color is - None, the colorscale will only have north and south pole colors. - - Parameters - ---------- - color_transition : float, default=0.1 - A value between 0 and 1. Sets the smoothness of the color transitions from adjacent colors - visualization. - color_north : str, default=None - Magnetic north pole color. - color_middle : str, default=None - Color of area between south and north pole. - color_south : str, default=None - Magnetic north pole color. - - Returns - ------- - colorscale: list - Colorscale as list of tuples. - """ - if color_middle is False: - colorscale = ( - (0.0, color_south), - (0.5 * (1 - color_transition), color_south), - (0.5 * (1 + color_transition), color_north), - (1, color_north), - ) - else: - colorscale = ( - (0.0, color_south), - (0.2 - 0.2 * (color_transition), color_south), - (0.2 + 0.3 * (color_transition), color_middle), - (0.8 - 0.3 * (color_transition), color_middle), - (0.8 + 0.2 * (color_transition), color_north), - (1.0, color_north), - ) - return colorscale - - -def get_scene_ranges(*traces, zoom=1) -> np.ndarray: - """ - Returns 3x2 array of the min and max ranges in x,y,z directions of input traces. Traces can be - any plotly trace object or a dict, with x,y,z numbered parameters. - """ - if traces: - ranges = {k: [] for k in "xyz"} - for t in traces: - for k, v in ranges.items(): - v.extend( - [ - np.nanmin(np.array(t[k], dtype=float)), - np.nanmax(np.array(t[k], dtype=float)), - ] - ) - r = np.array([[np.nanmin(v), np.nanmax(v)] for v in ranges.values()]) - size = np.diff(r, axis=1) - size[size == 0] = 1 - m = size.max() / 2 - center = r.mean(axis=1) - ranges = np.array([center - m * (1 + zoom), center + m * (1 + zoom)]).T - else: - ranges = np.array([[-1.0, 1.0]] * 3) - return ranges - - -def group_traces(*traces): - """Group and merge mesh traces with similar properties. This drastically improves - browser rendering performance when displaying a lot of mesh3d objects.""" - mesh_groups = {} - common_keys = ["legendgroup", "opacity"] - spec_keys = { - "mesh3d": ["colorscale"], - "scatter3d": [ - "marker", - "line_dash", - "line_color", - "line_width", - "marker_color", - "marker_symbol", - "marker_size", - "mode", - ], - } - for tr in traces: - tr = linearize_dict( - tr, - separator="_", - ) - gr = [tr["type"]] - for k in common_keys + spec_keys[tr["type"]]: - try: - v = tr.get(k, "") - except AttributeError: - v = getattr(tr, k, "") - gr.append(str(v)) - gr = "".join(gr) - if gr not in mesh_groups: - mesh_groups[gr] = [] - mesh_groups[gr].append(tr) - - traces = [] - for key, gr in mesh_groups.items(): - if key.startswith("mesh3d") or key.startswith("scatter3d"): - tr = [merge_traces(*gr)] - else: - tr = gr - traces.extend(tr) - return traces - - -def subdivide_mesh_by_facecolor(trace): - """Subdivide a mesh into a list of meshes based on facecolor""" - facecolor = trace["facecolor"] - subtraces = [] - # pylint: disable=singleton-comparison - facecolor[facecolor == np.array(None)] = "black" - for color in np.unique(facecolor): - mask = facecolor == color - new_trace = trace.copy() - uniq = np.unique(np.hstack([trace[k][mask] for k in "ijk"])) - new_inds = np.arange(len(uniq)) - mapping_ar = np.zeros(uniq.max() + 1, dtype=new_inds.dtype) - mapping_ar[uniq] = new_inds - for k in "ijk": - new_trace[k] = mapping_ar[trace[k][mask]] - for k in "xyz": - new_trace[k] = new_trace[k][uniq] - new_trace["color"] = color - new_trace.pop("facecolor") - subtraces.append(new_trace) - return subtraces diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 229141711..243a969c5 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -7,13 +7,13 @@ from magpylib import _src from magpylib._src.defaults.defaults_classes import default_settings -from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibMissingInput from magpylib._src.utility import format_obj_input from magpylib._src.utility import Registered from magpylib._src.utility import wrong_obj_msg + ################################################################# ################################################################# # FUNDAMENTAL CHECKS @@ -411,13 +411,12 @@ def check_format_input_cylinder_segment(inp): def check_format_input_backend(inp): """checks show-backend input and returns Non if bad input value""" - backends = SUPPORTED_PLOTTING_BACKENDS if inp is None: inp = default_settings.display.backend - if inp in backends: + if inp in ("matplotlib", "plotly"): return inp raise MagpylibBadUserInput( - f"Input parameter `backend` must be one of `{backends+(None,)}`.\n" + "Input parameter `backend` must be one of `('matplotlib', 'plotly', None)`.\n" f"Instead received {inp}." ) diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index adfb87e16..412d08f32 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -285,8 +285,7 @@ def add_trace(self, trace=None, **kwargs): pairs, or a callable returning the equivalent dictionary. backend: str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -329,8 +328,7 @@ class Trace3d(MagicProperties): Parameters ---------- backend: str - Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`. + Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`. constructor: str Model constructor function or method to be called to build a 3D-model object @@ -483,16 +481,14 @@ def coordsargs(self, val): @property def backend(self): - """Plotting backend corresponding to the trace. Can be one of - `['generic', 'matplotlib', 'plotly']`.""" + """Plotting backend corresponding to the trace. Can be one of `['matplotlib', 'plotly']`.""" return self._backend @backend.setter def backend(self, val): - backends = ["generic"] + list(SUPPORTED_PLOTTING_BACKENDS) - assert val is None or val in backends, ( + assert val is None or val in SUPPORTED_PLOTTING_BACKENDS, ( f"The `backend` property of {type(self).__name__} must be one of" - f"{backends},\n" + f"{SUPPORTED_PLOTTING_BACKENDS},\n" f"but received {repr(val)} instead." ) self._backend = val diff --git a/magpylib/graphics/model3d/__init__.py b/magpylib/graphics/model3d/__init__.py index f6ad0f308..9fefd8ce5 100644 --- a/magpylib/graphics/model3d/__init__.py +++ b/magpylib/graphics/model3d/__init__.py @@ -13,7 +13,7 @@ "make_Prism", ] -from magpylib._src.display.traces_base import ( +from magpylib._src.display.base_traces import ( make_Arrow, make_Ellipsoid, make_Pyramid, diff --git a/tests/test_Coumpound_setters.py b/tests/test_Coumpound_setters.py index 3bacec196..bb5c587f0 100644 --- a/tests/test_Coumpound_setters.py +++ b/tests/test_Coumpound_setters.py @@ -8,7 +8,7 @@ from scipy.spatial.transform import Rotation as R import magpylib as magpy -from magpylib._src.display.traces_base import make_Prism +from magpylib._src.display.base_traces import make_Prism magpy.defaults.display.backend = "plotly" diff --git a/tests/test_default_utils.py b/tests/test_default_utils.py index 1f0c4d63e..c43afd207 100644 --- a/tests/test_default_utils.py +++ b/tests/test_default_utils.py @@ -107,7 +107,7 @@ def test_linearize_dict(): ((127, 127, 127), True, "#7f7f7f"), ("rgb(127, 127, 127)", True, "#7f7f7f"), ((0, 0, 0, 0), False, "#000000"), - ((0.1, 0.2, 0.3), False, "#19334c"), + ((.1, .2, .3), False, "#19334c"), ] + [(shortC, True, longC) for shortC, longC in COLORS_MATPLOTLIB_TO_PLOTLY.items()], ) diff --git a/tests/test_defaults.py b/tests/test_defaults.py index f2f7c96da..84d88529c 100644 --- a/tests/test_defaults.py +++ b/tests/test_defaults.py @@ -3,11 +3,9 @@ import magpylib as magpy from magpylib._src.defaults.defaults_classes import DefaultConfig from magpylib._src.defaults.defaults_utility import LINESTYLES_MATPLOTLIB_TO_PLOTLY -from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.defaults.defaults_utility import SYMBOLS_MATPLOTLIB_TO_PLOTLY from magpylib._src.style import DisplayStyle - bad_inputs = { "display_autosizefactor": (0,), # float>0 "display_animation_maxfps": (0,), # int>0 @@ -100,7 +98,7 @@ def test_defaults_bad_inputs(key, value, expected_errortype): "display_animation_time": (10,), # int>0 "display_animation_maxframes": (200,), # int>0 "display_animation_slider": (True, False), # bool - "display_backend": tuple(SUPPORTED_PLOTTING_BACKENDS), # str typo + "display_backend": ("matplotlib", "plotly"), # str typo "display_colorsequence": ( ["#2E91E5", "#0D2A63"], ["blue", "red"], diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py index ca8e258a8..436595ad3 100644 --- a/tests/test_display_matplotlib.py +++ b/tests/test_display_matplotlib.py @@ -159,6 +159,15 @@ def test_circular_line_display(): assert x is None, "display test fail" +def test_matplotlib_animation_warning(): + """animation=True with matplotlib should raise UserWarning""" + ax = plt.subplot(projection="3d") + sens = magpy.Sensor(pixel=[(1, 2, 3), (2, 3, 4)]) + sens.move(np.linspace((0.4, 0.4, 0.4), (12.4, 12.4, 12.4), 33), start=-1) + with pytest.warns(UserWarning): + sens.show(canvas=ax, animation=True) + + def test_matplotlib_model3d_extra(): """test display extra model3d""" diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index e17460ad8..ee45c0123 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -3,7 +3,7 @@ import pytest import magpylib as magpy -from magpylib._src.display.traces_generic import get_generic_traces +from magpylib._src.display.plotly.plotly_display import get_plotly_traces from magpylib._src.exceptions import MagpylibBadUserInput from magpylib.magnet import Cuboid from magpylib.magnet import Cylinder @@ -164,6 +164,38 @@ def test_display_bad_style_kwargs(): magpy.show(canvas=fig, markers=[(1, 2, 3)], style_bad_style_kwarg=None) +def test_draw_unsupported_obj(): + """test if a object which is not directly supported by magpylib can be plotted""" + magpy.defaults.display.backend = "plotly" + + class UnkwnownNoPosition: + """Dummy Class""" + + class Unkwnown1DPosition: + """Dummy Class""" + + position = [0, 0, 0] + + class Unkwnown2DPosition: + """Dummy Class""" + + position = [[0, 0, 0]] + orientation = None + + with pytest.raises(AttributeError): + get_plotly_traces(UnkwnownNoPosition()) + + traces = get_plotly_traces(Unkwnown1DPosition) + assert ( + traces[0]["type"] == "scatter3d" + ), "make trace has failed, should be 'scatter3d'" + + traces = get_plotly_traces(Unkwnown2DPosition) + assert ( + traces[0]["type"] == "scatter3d" + ), "make trace has failed, should be 'scatter3d'" + + def test_extra_model3d(): """test diplay when object has an extra model object attached""" magpy.defaults.display.backend = "plotly" diff --git a/tests/test_display_utility.py b/tests/test_display_utility.py index fd9f96e12..3f7c2581d 100644 --- a/tests/test_display_utility.py +++ b/tests/test_display_utility.py @@ -2,7 +2,7 @@ import pytest import magpylib as magpy -from magpylib._src.display.traces_utility import draw_arrow_from_vertices +from magpylib._src.display.display_utility import draw_arrow_from_vertices from magpylib._src.exceptions import MagpylibBadUserInput diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index 25d598ee5..ffa09d4dc 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -1,8 +1,8 @@ import sys from unittest import mock -import numpy as np import pytest +import numpy as np import magpylib as magpy From 20674c50cdd6181b455b59b8591b276903e7b4ad Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 12 Jul 2022 11:37:39 +0200 Subject: [PATCH 184/207] pylint --- magpylib/_src/display/backend_plotly.py | 1 + 1 file changed, 1 insertion(+) diff --git a/magpylib/_src/display/backend_plotly.py b/magpylib/_src/display/backend_plotly.py index ef1bfdd2d..bd94d08cc 100644 --- a/magpylib/_src/display/backend_plotly.py +++ b/magpylib/_src/display/backend_plotly.py @@ -263,3 +263,4 @@ def display_plotly( return fig if show_fig: fig.show(renderer=renderer) + return None From b6204fb957b01dcb6aee3a672a6325d9a9914d0b Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Fri, 15 Jul 2022 14:17:11 +0200 Subject: [PATCH 185/207] draft getbdict vert feature --- magpylib/_src/fields/field_wrap_BH.py | 31 ++++++++++++------- .../_src/obj_classes/class_current_Line.py | 2 +- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index 82bef194a..ef9e277c6 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -417,15 +417,20 @@ def getBH_dict_level2( # evaluation vector lengths vec_lengths = [] + ragged_seq = {} for key, val in kwargs.items(): - try: + if ( + not np.isscalar(val) + and not np.isscalar(val[0]) + and any(len(o) != len(val[0]) for o in val) + ): + ragged_seq[key] = True + val = np.array([np.array(v, dtype=float) for v in val], dtype="object") + else: + ragged_seq[key] = False val = np.array(val, dtype=float) - except TypeError as err: - raise MagpylibBadUserInput( - f"{key} input must be array-like.\n" f"Instead received {val}" - ) from err - tdim = Registered.source_kwargs_ndim[source_type].get(key, 1) - if val.ndim == tdim: + expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) + if val.ndim == expected_dim: vec_lengths.append(len(val)) kwargs[key] = val @@ -438,12 +443,14 @@ def getBH_dict_level2( # tile 1D inputs and replace original values in kwargs for key, val in kwargs.items(): - tdim = Registered.source_kwargs_ndim[source_type].get(key, 1) - if val.ndim < tdim: - if tdim == 2: - kwargs[key] = np.tile(val, (vec_len, 1)) - elif tdim == 1: + expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) + if val.ndim < expected_dim: + if expected_dim == 1: kwargs[key] = np.array([val] * vec_len) + elif ragged_seq[key]: + kwargs[key] = np.array([np.tile(v, (vec_len, 1)) for v in val], dtype='object') + else: + kwargs[key] = np.tile(val, (vec_len, 1)) else: kwargs[key] = val diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index d572eced9..8254f47f3 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -16,7 +16,7 @@ field_func=current_vertices_field, source_kwargs_ndim={ "current": 1, - "vertices": 2, + "vertices": 3, "segment_start": 2, "segment_end": 2, }, From 3a269663bcfd84d323e7859b18b00cf27246ffbc Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 15 Jul 2022 23:28:30 +0200 Subject: [PATCH 186/207] fix tests --- magpylib/_src/fields/field_wrap_BH.py | 84 +++++++++++++---- magpylib/_src/fields/field_wrap_info.txt | 41 --------- tests/test_default_utils.py | 2 +- tests/test_exceptions.py | 109 ++++------------------- tests/test_getBH_interfaces.py | 2 +- 5 files changed, 84 insertions(+), 154 deletions(-) delete mode 100644 magpylib/_src/fields/field_wrap_info.txt diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index ef9e277c6..1feab8b96 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -1,3 +1,46 @@ +"""Field computation structure: + +level0:(field_BH_XXX.py files) + - pure vectorized field computations from literature + - all computations in source CS + - distinguish B/H + +level1(getBH_level1): + - apply transformation to global CS + - select correct level0 src_type computation + - input dict, no input checks ! + +level2(getBHv_level2): <--- DIRECT ACCESS TO FIELD COMPUTATION FORMULAS, INPUT = DICT OF ARRAYS + - input dict checks (unknowns) + - secure user inputs + - check input for mandatory information + - set missing input variables to default values + - tile 1D inputs + +level2(getBH_level2): <--- COMPUTE FIELDS FROM SOURCES + - input dict checks (unknowns) + - secure user inputs + - group similar sources for combined computation + - generate vector input format for getBH_level1 + - adjust Bfield output format to (pos_obs, path, sources) input format + +level3(getB, getH, getB_dict, getH_dict): <--- USER INTERFACE + - docstrings + - separated B and H + - transform input into dict for level2 + +level4(src.getB, src.getH): <--- USER INTERFACE + - docstrings + - calling level3 getB, getH directly from sources + +level3(getBH_from_sensor): + - adjust output format to (senors, path, sources) input format + +level4(getB_from_sensor, getH_from_sensor): <--- USER INTERFACE + +level5(sens.getB, sens.getH): <--- USER INTERFACE +""" +import numbers from itertools import product from typing import Callable @@ -416,30 +459,35 @@ def getBH_dict_level2( kwargs["orientation"] = orientation.as_quat() # evaluation vector lengths - vec_lengths = [] + vec_lengths = {} ragged_seq = {} for key, val in kwargs.items(): - if ( - not np.isscalar(val) - and not np.isscalar(val[0]) - and any(len(o) != len(val[0]) for o in val) - ): - ragged_seq[key] = True - val = np.array([np.array(v, dtype=float) for v in val], dtype="object") - else: - ragged_seq[key] = False - val = np.array(val, dtype=float) + try: + if ( + not isinstance(val, numbers.Number) + and not isinstance(val[0], numbers.Number) + and any(len(o) != len(val[0]) for o in val) + ): + ragged_seq[key] = True + val = np.array([np.array(v, dtype=float) for v in val], dtype="object") + else: + ragged_seq[key] = False + val = np.array(val, dtype=float) + except TypeError as err: + raise MagpylibBadUserInput( + f"{key} input must be array-like.\n" f"Instead received {val}" + ) from err expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) - if val.ndim == expected_dim: - vec_lengths.append(len(val)) + if val.ndim == expected_dim or ragged_seq[key]: + vec_lengths[key] = len(val) kwargs[key] = val - if len(set(vec_lengths)) > 1: + if len(set(vec_lengths.values())) > 1: raise MagpylibBadUserInput( "Input array lengths must be 1 or of a similar length.\n" - f"Instead received {set(vec_lengths)}" + f"Instead received lengths {vec_lengths}" ) - vec_len = max(vec_lengths, default=1) + vec_len = max(vec_lengths.values(), default=1) # tile 1D inputs and replace original values in kwargs for key, val in kwargs.items(): @@ -448,7 +496,9 @@ def getBH_dict_level2( if expected_dim == 1: kwargs[key] = np.array([val] * vec_len) elif ragged_seq[key]: - kwargs[key] = np.array([np.tile(v, (vec_len, 1)) for v in val], dtype='object') + kwargs[key] = np.array( + [np.tile(v, (vec_len, 1)) for v in val], dtype="object" + ) else: kwargs[key] = np.tile(val, (vec_len, 1)) else: diff --git a/magpylib/_src/fields/field_wrap_info.txt b/magpylib/_src/fields/field_wrap_info.txt deleted file mode 100644 index 86fe1cc42..000000000 --- a/magpylib/_src/fields/field_wrap_info.txt +++ /dev/null @@ -1,41 +0,0 @@ -Field computation structure: - -level0:(field_BH_XXX.py files) - - pure vectorized field computations from literature - - all computations in source CS - - distinguish B/H - -level1(getBH_level1): - - apply transformation to global CS - - select correct level0 src_type computation - - input dict, no input checks ! - -level2(getBHv_level2): <--- DIRECT ACCESS TO FIELD COMPUTATION FORMULAS, INPUT = DICT OF ARRAYS - - input dict checks (unknowns) - - secure user inputs - - check input for mandatory information - - set missing input variables to default values - - tile 1D inputs - -level2(getBH_level2): <--- COMPUTE FIELDS FROM SOURCES - - input dict checks (unknowns) - - secure user inputs - - group similar sources for combined computation - - generate vector input format for getBH_level1 - - adjust Bfield output format to (pos_obs, path, sources) input format - -level3(getB, getH, getB_dict, getH_dict): <--- USER INTERFACE - - docstrings - - separated B and H - - transform input into dict for level2 - -level4(src.getB, src.getH): <--- USER INTERFACE - - docstrings - - calling level3 getB, getH directly from sources - -level3(getBH_from_sensor): - - adjust output format to (senors, path, sources) input format - -level4(getB_from_sensor, getH_from_sensor): <--- USER INTERFACE - -level5(sens.getB, sens.getH): <--- USER INTERFACE \ No newline at end of file diff --git a/tests/test_default_utils.py b/tests/test_default_utils.py index c43afd207..1f0c4d63e 100644 --- a/tests/test_default_utils.py +++ b/tests/test_default_utils.py @@ -107,7 +107,7 @@ def test_linearize_dict(): ((127, 127, 127), True, "#7f7f7f"), ("rgb(127, 127, 127)", True, "#7f7f7f"), ((0, 0, 0, 0), False, "#000000"), - ((.1, .2, .3), False, "#19334c"), + ((0.1, 0.2, 0.3), False, "#19334c"), ] + [(shortC, True, longC) for shortC, longC in COLORS_MATPLOTLIB_TO_PLOTLY.items()], ) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 91ddac72d..6ea7c0deb 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -12,6 +12,8 @@ from magpylib._src.utility import format_src_inputs from magpylib._src.utility import test_path_format as tpf +GETBH_KWARGS = {"sumup": False, "squeeze": True, "pixel_agg": None, "output": "ndarray"} + def getBHv_unknown_source_type(): """unknown source type""" @@ -21,11 +23,8 @@ def getBHv_unknown_source_type(): magnetization=(1, 0, 0), dimension=(0, 2, 1, 0, 360), position=(0, 0, -0.5), - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", field="B", + **GETBH_KWARGS ) @@ -67,74 +66,35 @@ def getBHv_missing_input1(): """missing field""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cuboid", - observers=x, - magnetization=x, - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cuboid", observers=x, magnetization=x, dimension=x, **GETBH_KWARGS ) def getBHv_missing_input2(): """missing source_type""" x = np.array([(1, 2, 3)]) - getBH_level2( - observers=x, - field="B", - magnetization=x, - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - ) + getBH_level2(observers=x, field="B", magnetization=x, dimension=x, **GETBH_KWARGS) def getBHv_missing_input3(): """missing observer""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cuboid", - field="B", - magnetization=x, - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cuboid", field="B", magnetization=x, dimension=x, **GETBH_KWARGS ) def getBHv_missing_input4_cuboid(): """missing Cuboid mag""" x = np.array([(1, 2, 3)]) - getBH_level2( - sources="Cuboid", - observers=x, - field="B", - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - ) + getBH_level2(sources="Cuboid", observers=x, field="B", dimension=x, **GETBH_KWARGS) def getBHv_missing_input5_cuboid(): """missing Cuboid dim""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cuboid", - observers=x, - field="B", - magnetization=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cuboid", observers=x, field="B", magnetization=x, **GETBH_KWARGS ) @@ -143,14 +103,7 @@ def getBHv_missing_input4_cyl(): x = np.array([(1, 2, 3)]) y = np.array([(1, 2)]) getBH_level2( - sources="Cylinder", - observers=x, - field="B", - dimension=y, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cylinder", observers=x, field="B", dimension=y, **GETBH_KWARGS ) @@ -158,44 +111,21 @@ def getBHv_missing_input5_cyl(): """missing Cylinder dim""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cylinder", - observers=x, - field="B", - magnetization=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cylinder", observers=x, field="B", magnetization=x, **GETBH_KWARGS ) def getBHv_missing_input4_sphere(): """missing Sphere mag""" x = np.array([(1, 2, 3)]) - getBH_level2( - sources="Sphere", - observers=x, - field="B", - dimension=1, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - ) + getBH_level2(sources="Sphere", observers=x, field="B", dimension=1, **GETBH_KWARGS) def getBHv_missing_input5_sphere(): """missing Sphere dim""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Sphere", - observers=x, - field="B", - magnetization=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Sphere", observers=x, field="B", magnetization=x, **GETBH_KWARGS ) @@ -210,10 +140,7 @@ def getBHv_bad_input1(): field="B", magnetization=x2, dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + **GETBH_KWARGS ) @@ -226,10 +153,7 @@ def getBHv_bad_input2(): field="B", magnetization=x, dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + **GETBH_KWARGS ) @@ -243,10 +167,7 @@ def getBHv_bad_input3(): field="B", magnetization=x, dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + **GETBH_KWARGS ) diff --git a/tests/test_getBH_interfaces.py b/tests/test_getBH_interfaces.py index ffa09d4dc..25d598ee5 100644 --- a/tests/test_getBH_interfaces.py +++ b/tests/test_getBH_interfaces.py @@ -1,8 +1,8 @@ import sys from unittest import mock -import pytest import numpy as np +import pytest import magpylib as magpy From 7f1a3dca595e59c6e66d319d6dd9e670eac0c1be Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 16 Jul 2022 01:00:41 +0200 Subject: [PATCH 187/207] pull from refactor_getbh --- magpylib/_src/defaults/defaults_utility.py | 15 +- .../_src/display/backend_matplotlib_old.py | 756 ---------------- magpylib/_src/display/display_utility.py | 504 +++++++++++ magpylib/_src/display/traces_generic.py | 2 +- magpylib/_src/display/traces_utility.py | 4 +- magpylib/_src/fields/__init__.py | 2 +- magpylib/_src/fields/field_BH_line.py | 57 +- magpylib/_src/fields/field_wrap_BH.py | 853 ++++++++++++++++++ magpylib/_src/fields/field_wrap_BH_level1.py | 68 -- magpylib/_src/fields/field_wrap_BH_level2.py | 447 --------- magpylib/_src/fields/field_wrap_BH_level3.py | 339 ------- magpylib/_src/fields/field_wrap_info.txt | 41 - magpylib/_src/input_checks.py | 13 +- .../_src/obj_classes/class_BaseDisplayRepr.py | 6 +- magpylib/_src/obj_classes/class_BaseGetBH.py | 2 +- magpylib/_src/obj_classes/class_Collection.py | 10 +- magpylib/_src/obj_classes/class_Sensor.py | 5 +- .../_src/obj_classes/class_current_Line.py | 18 +- .../_src/obj_classes/class_current_Loop.py | 9 +- ...s_mag_Cuboid.py => class_magnet_Cuboid.py} | 9 +- ...g_Cylinder.py => class_magnet_Cylinder.py} | 9 +- ...ent.py => class_magnet_CylinderSegment.py} | 11 +- ...s_mag_Sphere.py => class_magnet_Sphere.py} | 9 +- ...c_Custom.py => class_misc_CustomSource.py} | 3 +- .../_src/obj_classes/class_misc_Dipole.py | 9 +- magpylib/_src/style.py | 36 +- magpylib/_src/utility.py | 115 ++- magpylib/magnet/__init__.py | 8 +- magpylib/misc/__init__.py | 2 +- tests/test_display_plotly.py | 1 - tests/test_exceptions.py | 134 +-- tests/test_field_functions.py | 33 +- tests/test_obj_BaseGeo.py | 10 +- tests/test_obj_Collection.py | 2 + 34 files changed, 1611 insertions(+), 1931 deletions(-) delete mode 100644 magpylib/_src/display/backend_matplotlib_old.py create mode 100644 magpylib/_src/display/display_utility.py create mode 100644 magpylib/_src/fields/field_wrap_BH.py delete mode 100644 magpylib/_src/fields/field_wrap_BH_level1.py delete mode 100644 magpylib/_src/fields/field_wrap_BH_level2.py delete mode 100644 magpylib/_src/fields/field_wrap_BH_level3.py delete mode 100644 magpylib/_src/fields/field_wrap_info.txt rename magpylib/_src/obj_classes/{class_mag_Cuboid.py => class_magnet_Cuboid.py} (94%) rename magpylib/_src/obj_classes/{class_mag_Cylinder.py => class_magnet_Cylinder.py} (93%) rename magpylib/_src/obj_classes/{class_mag_CylinderSegment.py => class_magnet_CylinderSegment.py} (94%) rename magpylib/_src/obj_classes/{class_mag_Sphere.py => class_magnet_Sphere.py} (93%) rename magpylib/_src/obj_classes/{class_misc_Custom.py => class_misc_CustomSource.py} (97%) diff --git a/magpylib/_src/defaults/defaults_utility.py b/magpylib/_src/defaults/defaults_utility.py index ebe4d5baf..d16687bd2 100644 --- a/magpylib/_src/defaults/defaults_utility.py +++ b/magpylib/_src/defaults/defaults_utility.py @@ -5,19 +5,8 @@ from magpylib._src.defaults.defaults_values import DEFAULTS -SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly", "matplotlib_old") - -MAGPYLIB_FAMILIES = { - "Line": ("current",), - "Loop": ("current",), - "Cuboid": ("magnet",), - "Cylinder": ("magnet",), - "Sphere": ("magnet",), - "CylinderSegment": ("magnet",), - "Sensor": ("sensor",), - "Dipole": ("dipole",), - "Marker": ("markers",), -} +SUPPORTED_PLOTTING_BACKENDS = ("matplotlib", "plotly") + SYMBOLS_MATPLOTLIB_TO_PLOTLY = { ".": "circle", diff --git a/magpylib/_src/display/backend_matplotlib_old.py b/magpylib/_src/display/backend_matplotlib_old.py deleted file mode 100644 index 144681143..000000000 --- a/magpylib/_src/display/backend_matplotlib_old.py +++ /dev/null @@ -1,756 +0,0 @@ -""" matplotlib draw-functionalities""" -import warnings - -import matplotlib.pyplot as plt -import numpy as np -from mpl_toolkits.mplot3d.art3d import Poly3DCollection - -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.traces_utility import draw_arrow_from_vertices -from magpylib._src.display.traces_utility import draw_arrowed_circle -from magpylib._src.display.traces_utility import get_flatten_objects_properties -from magpylib._src.display.traces_utility import get_rot_pos_from_path -from magpylib._src.display.traces_utility import MagpyMarkers -from magpylib._src.display.traces_utility import place_and_orient_model3d -from magpylib._src.input_checks import check_excitations -from magpylib._src.style import get_style - - -def faces_cuboid(src, show_path): - """ - compute vertices and faces of Cuboid input for plotting - takes Cuboid source - returns vert, faces - returns all faces when show_path=all - """ - # pylint: disable=protected-access - a, b, c = src.dimension - vert0 = np.array( - ( - (0, 0, 0), - (a, 0, 0), - (0, b, 0), - (0, 0, c), - (a, b, 0), - (a, 0, c), - (0, b, c), - (a, b, c), - ) - ) - vert0 = vert0 - src.dimension / 2 - - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - faces = [] - for rot, pos in zip(rots, poss): - vert = rot.apply(vert0) + pos - faces += [ - [vert[0], vert[1], vert[4], vert[2]], - [vert[0], vert[1], vert[5], vert[3]], - [vert[0], vert[2], vert[6], vert[3]], - [vert[7], vert[6], vert[2], vert[4]], - [vert[7], vert[6], vert[3], vert[5]], - [vert[7], vert[5], vert[1], vert[4]], - ] - return faces - - -def faces_cylinder(src, show_path): - """ - Compute vertices and faces of Cylinder input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder faces - r, h2 = src.dimension / 2 - hs = np.array([-h2, h2]) - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - faces = [ - np.array( - [ - (r * np.cos(p1), r * np.sin(p1), h2), - (r * np.cos(p1), r * np.sin(p1), -h2), - (r * np.cos(p2), r * np.sin(p2), -h2), - (r * np.cos(p2), r * np.sin(p2), h2), - ] - ) - for p1, p2 in zip(phis, phis2) - ] - faces += [ - np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_cylinder_segment(src, show_path): - """ - Compute vertices and faces of CylinderSegment for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder segment faces - r1, r2, h, phi1, phi2 = src.dimension - res_tile = ( - int((phi2 - phi1) / 360 * 2 * res) + 2 - ) # resolution used for tile curved surface - phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi - phis2 = np.roll(phis, 1) - faces = [ - np.array( - [ # inner curved surface - (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), - (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # outer curved surface - (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), - (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # sides - (r1 * np.cos(p), r1 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), -h / 2), - (r1 * np.cos(p), r1 * np.sin(p), -h / 2), - ] - ) - for p in [phis[0], phis[-1]] - ] - faces += [ - np.array( # top surface - [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] - ) - ] - faces += [ - np.array( # bottom surface - [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] - ) - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_sphere(src, show_path): - """ - Compute vertices and faces of Sphere input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate sphere faces - r = src.diameter / 2 - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - ths = np.linspace(0, np.pi, res) - faces = [ - r - * np.array( - [ - (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), - (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), - ] - ) - for p, p2 in zip(phis, phis2) - for t1, t2 in zip(ths[1:-2], ths[2:-1]) - ] - faces += [ - r - * np.array( - [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] - ) - for th in [ths[1], ths[-2]] - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def system_size(points): - """compute system size for display""" - # determine min/max from all to generate aspect=1 plot - if points: - - # bring (n,m,3) point dimensions (e.g. from plot_surface body) - # to correct (n,3) shape - for i, p in enumerate(points): - if p.ndim == 3: - points[i] = np.reshape(p, (-1, 3)) - - pts = np.vstack(points) - xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] - ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] - zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] - - xsize = xs[1] - xs[0] - ysize = ys[1] - ys[0] - zsize = zs[1] - zs[0] - - xcenter = (xs[1] + xs[0]) / 2 - ycenter = (ys[1] + ys[0]) / 2 - zcenter = (zs[1] + zs[0]) / 2 - - size = max([xsize, ysize, zsize]) - - limx0 = xcenter + size / 2 - limx1 = xcenter - size / 2 - limy0 = ycenter + size / 2 - limy1 = ycenter - size / 2 - limz0 = zcenter + size / 2 - limz1 = zcenter - size / 2 - else: - limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 - return limx0, limx1, limy0, limy1, limz0, limz1 - - -def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): - """draw direction of magnetization of faced magnets - - Parameters - ---------- - - faced_objects(list of src objects): with magnetization vector to be drawn - - colors: colors of faced_objects - - ax(Pyplot 3D axis): to draw in - - show_path(bool or int): draw on every position where object is displayed - """ - # pylint: disable=protected-access - # pylint: disable=too-many-branches - points = [] - for col, obj in zip(colors, faced_objects): - - # add src attributes position and orientation depending on show_path - rots, poss, inds = get_rot_pos_from_path(obj, show_path) - - # vector length, color and magnetization - if obj._object_type in ("Cuboid", "Cylinder"): - length = 1.8 * np.amax(obj.dimension) - elif obj._object_type == "CylinderSegment": - length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h - else: - length = 1.8 * obj.diameter # Sphere - mag = obj.magnetization - - # collect all draw positions and directions - draw_pos, draw_direc = [], [] - for rot, pos, ind in zip(rots, poss, inds): - if obj._object_type == "CylinderSegment": - # change cylinder_tile draw_pos to barycenter - pos = obj._barycenter[ind] - draw_pos += [pos] - direc = mag / (np.linalg.norm(mag) + 1e-6) - draw_direc += [rot.apply(direc)] - draw_pos = np.array(draw_pos) - draw_direc = np.array(draw_direc) - - # use quiver() separately for each object to easier control - # color and vector length - ax.quiver( - draw_pos[:, 0], - draw_pos[:, 1], - draw_pos[:, 2], - draw_direc[:, 0], - draw_direc[:, 1], - draw_direc[:, 2], - length=length * size_direction, - color=col, - ) - arrow_tip_pos = ((draw_direc * length * size_direction) + draw_pos)[0] - points.append(arrow_tip_pos) - return points - - -def draw_markers(markers, ax, color, symbol, size): - """draws magpylib markers""" - ax.plot( - markers[:, 0], - markers[:, 1], - markers[:, 2], - color=color, - ls="", - marker=symbol, - ms=size, - ) - - -def draw_path( - obj, col, marker_symbol, marker_size, marker_color, line_style, line_width, ax -): - """draw path in given color and return list of path-points""" - # pylint: disable=protected-access - path = obj._position - if len(path) > 1: - ax.plot( - path[:, 0], - path[:, 1], - path[:, 2], - ls=line_style, - lw=line_width, - color=col, - marker=marker_symbol, - mfc=marker_color, - mec=marker_color, - ms=marker_size, - ) - ax.plot( - [path[0, 0]], [path[0, 1]], [path[0, 2]], marker="o", ms=4, mfc=col, mec="k" - ) - return list(path) - - -def draw_faces(faces, col, lw, alpha, ax): - """draw faces in respective color and return list of vertex-points""" - cuboid_faces = Poly3DCollection( - faces, - facecolors=col, - linewidths=lw, - edgecolors="k", - alpha=alpha, - ) - ax.add_collection3d(cuboid_faces) - return faces - - -def draw_pixel(sensors, ax, col, pixel_col, pixel_size, pixel_symb, show_path): - """draw pixels and return a list of pixel-points in global CS""" - # pylint: disable=protected-access - - # collect sensor and pixel positions in global CS - pos_sens, pos_pixel = [], [] - for sens in sensors: - rots, poss, _ = get_rot_pos_from_path(sens, show_path) - - pos_pixel_flat = np.reshape(sens.pixel, (-1, 3)) - - for rot, pos in zip(rots, poss): - pos_sens += [pos] - - for pix in pos_pixel_flat: - pos_pixel += [pos + rot.apply(pix)] - - pos_all = pos_sens + pos_pixel - pos_pixel = np.array(pos_pixel) - - # display pixel positions - ax.plot( - pos_pixel[:, 0], - pos_pixel[:, 1], - pos_pixel[:, 2], - marker=pixel_symb, - mfc=pixel_col, - mew=pixel_size, - mec=col, - ms=pixel_size * 4, - ls="", - ) - - # return all positions for system size evaluation - return list(pos_all) - - -def draw_sensors(sensors, ax, sys_size, show_path, size, arrows_style): - """draw sensor cross""" - # pylint: disable=protected-access - arrowlength = sys_size * size / Config.display.autosizefactor - - # collect plot data - possis, exs, eys, ezs = [], [], [], [] - for sens in sensors: - rots, poss, _ = get_rot_pos_from_path(sens, show_path) - - for rot, pos in zip(rots, poss): - possis += [pos] - exs += [rot.apply((1, 0, 0))] - eys += [rot.apply((0, 1, 0))] - ezs += [rot.apply((0, 0, 1))] - - possis = np.array(possis) - coords = np.array([exs, eys, ezs]) - - # quiver plot of basis vectors - arrow_colors = ( - arrows_style.x.color, - arrows_style.y.color, - arrows_style.z.color, - ) - arrow_show = (arrows_style.x.show, arrows_style.y.show, arrows_style.z.show) - for acol, ashow, es in zip(arrow_colors, arrow_show, coords): - if ashow: - ax.quiver( - possis[:, 0], - possis[:, 1], - possis[:, 2], - es[:, 0], - es[:, 1], - es[:, 2], - color=acol, - length=arrowlength, - ) - - -def draw_dipoles(dipoles, ax, sys_size, show_path, size, color, pivot): - """draw dipoles""" - # pylint: disable=protected-access - - # collect plot data - possis, moms = [], [] - for dip in dipoles: - rots, poss, _ = get_rot_pos_from_path(dip, show_path) - - mom = dip.moment / np.linalg.norm(dip.moment) - - for rot, pos in zip(rots, poss): - possis += [pos] - moms += [rot.apply(mom)] - - possis = np.array(possis) - moms = np.array(moms) - - # quiver plot of basis vectors - arrowlength = sys_size * size / Config.display.autosizefactor - ax.quiver( - possis[:, 0], - possis[:, 1], - possis[:, 2], - moms[:, 0], - moms[:, 1], - moms[:, 2], - color=color, - length=arrowlength, - pivot=pivot, # {'tail', 'middle', 'tip'}, - ) - - -def draw_circular(circulars, show_path, col, size, width, ax): - """draw circulars and return a list of positions""" - # pylint: disable=protected-access - - # graphical settings - discret = 72 + 1 - lw = width - - draw_pos = [] # line positions - for circ in circulars: - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(circ, show_path) - - # init orientation line positions - vertices = draw_arrowed_circle(circ.current, circ.diameter, size, discret).T - # apply pos and rot, draw, store line positions - for rot, pos in zip(rots, poss): - possis1 = rot.apply(vertices) + pos - ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) - draw_pos += list(possis1) - - return draw_pos - - -def draw_line(lines, show_path, col, size, width, ax) -> list: - """draw lines and return a list of positions""" - # pylint: disable=protected-access - - # graphical settings - lw = width - - draw_pos = [] # line positions - for line in lines: - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(line, show_path) - - # init orientation line positions - if size != 0: - vertices = draw_arrow_from_vertices(line.vertices, line.current, size) - else: - vertices = np.array(line.vertices).T - # apply pos and rot, draw, store line positions - for rot, pos in zip(rots, poss): - possis1 = rot.apply(vertices.T) + pos - ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) - draw_pos += list(possis1) - - return draw_pos - - -def draw_model3d_extra(obj, style, show_path, ax, color): - """positions, orients and draws extra 3d model including path positions - returns True if at least one the traces is now new default""" - extra_model3d_traces = style.model3d.data if style.model3d.data is not None else [] - points = [] - rots, poss, _ = get_rot_pos_from_path(obj, show_path) - for orient, pos in zip(rots, poss): - for extr in extra_model3d_traces: - if extr.show: - extr.update(extr.updatefunc()) - if extr.backend == "matplotlib": - kwargs = extr.kwargs() if callable(extr.kwargs) else extr.kwargs - args = extr.args() if callable(extr.args) else extr.args - kwargs, args, vertices = place_and_orient_model3d( - model_kwargs=kwargs, - model_args=args, - orientation=orient, - position=pos, - coordsargs=extr.coordsargs, - scale=extr.scale, - return_vertices=True, - return_model_args=True, - ) - points.append(vertices.T) - if "color" not in kwargs or kwargs["color"] is None: - kwargs.update(color=color) - getattr(ax, extr.constructor)(*args, **kwargs) - return points - - -def display_matplotlib_old( - *obj_list_semi_flat, - canvas=None, - markers=None, - zoom=0, - colorsequence=None, - animation=False, - **kwargs, -): - """Display objects and paths graphically with the matplotlib backend.""" - # pylint: disable=protected-access - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - - # apply config default values if None - # create or set plotting axis - - if animation is not False: - msg = "The matplotlib backend does not support animation at the moment.\n" - msg += "Use `backend=plotly` instead." - warnings.warn(msg) - # animation = False - - axis = canvas - if axis is None: - fig = plt.figure(dpi=80, figsize=(8, 8)) - ax = fig.add_subplot(111, projection="3d") - ax.set_box_aspect((1, 1, 1)) - generate_output = True - else: - ax = axis - generate_output = False - - # draw objects and evaluate system size -------------------------------------- - - # draw faced objects and store vertices - points = [] - dipoles = [] - sensors = [] - markers_list = [o for o in obj_list_semi_flat if isinstance(o, MagpyMarkers)] - obj_list_semi_flat = [o for o in obj_list_semi_flat if o not in markers_list] - flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, colorsequence=colorsequence - ) - for obj, props in flat_objs_props.items(): - color = props["color"] - style = get_style(obj, Config, **kwargs) - path_frames = style.path.frames - if path_frames is None: - path_frames = True - obj_color = style.color if style.color is not None else color - lw = 0.25 - faces = None - if obj.style.model3d.data: - pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color) - points += pts - if obj.style.model3d.showdefault: - if obj._object_type == "Cuboid": - lw = 0.5 - faces = faces_cuboid(obj, path_frames) - elif obj._object_type == "Cylinder": - faces = faces_cylinder(obj, path_frames) - elif obj._object_type == "CylinderSegment": - faces = faces_cylinder_segment(obj, path_frames) - elif obj._object_type == "Sphere": - faces = faces_sphere(obj, path_frames) - elif obj._object_type == "Line": - if style.arrow.show: - check_excitations([obj]) - arrow_size = style.arrow.size if style.arrow.show else 0 - arrow_width = style.arrow.width - points += draw_line( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Loop": - if style.arrow.show: - check_excitations([obj]) - arrow_width = style.arrow.width - arrow_size = style.arrow.size if style.arrow.show else 0 - points += draw_circular( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Sensor": - sensors.append((obj, obj_color)) - points += draw_pixel( - [obj], - ax, - obj_color, - style.pixel.color, - style.pixel.size, - style.pixel.symbol, - path_frames, - ) - elif obj._object_type == "Dipole": - dipoles.append((obj, obj_color)) - points += [obj.position] - elif obj._object_type == "CustomSource": - draw_markers( - np.array([obj.position]), ax, obj_color, symbol="*", size=10 - ) - label = ( - obj.style.label - if obj.style.label is not None - else str(type(obj).__name__) - ) - ax.text(*obj.position, label, horizontalalignment="center") - points += [obj.position] - if faces is not None: - alpha = style.opacity - pts = draw_faces(faces, obj_color, lw, alpha, ax) - points += [np.vstack(pts).reshape(-1, 3)] - if style.magnetization.show: - check_excitations([obj]) - pts = draw_directs_faced( - [obj], - [obj_color], - ax, - path_frames, - style.magnetization.size, - ) - points += pts - if style.path.show: - marker, line = style.path.marker, style.path.line - points += draw_path( - obj, - obj_color, - marker.symbol, - marker.size, - marker.color, - line.style, - line.width, - ax, - ) - - # markers ------------------------------------------------------- - if markers_list: - markers_instance = markers_list[0] - style = get_style(markers_instance, Config, **kwargs) - markers = np.array(markers_instance.markers) - s = style.marker - draw_markers(markers, ax, s.color, s.symbol, s.size) - points += [markers] - - # draw direction arrows (based on src size) ------------------------- - # objects with faces - - # determine system size ----------------------------------------- - limx1, limx0, limy1, limy0, limz1, limz0 = system_size(points) - - # make sure ranges are not null - limits = np.array([[limx0, limx1], [limy0, limy1], [limz0, limz1]]) - limits[np.squeeze(np.diff(limits)) == 0] += np.array([-1, 1]) - sys_size = np.max(np.diff(limits)) - c = limits.mean(axis=1) - m = sys_size.max() / 2 - ranges = np.array([c - m * (1 + zoom), c + m * (1 + zoom)]).T - - # draw all system sized based quantities ------------------------- - - # not optimal for loop if many sensors/dipoles - for sens in sensors: - sensor, color = sens - style = get_style(sensor, Config, **kwargs) - draw_sensors([sensor], ax, sys_size, path_frames, style.size, style.arrows) - for dip in dipoles: - dipole, color = dip - style = get_style(dipole, Config, **kwargs) - draw_dipoles( - [dipole], ax, sys_size, path_frames, style.size, color, style.pivot - ) - - # plot styling -------------------------------------------------- - ax.set( - **{f"{k}label": f"{k} [mm]" for k in "xyz"}, - **{f"{k}lim": r for k, r in zip("xyz", ranges)}, - ) - - # generate output ------------------------------------------------ - if generate_output: - plt.show() diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py new file mode 100644 index 000000000..8804f5763 --- /dev/null +++ b/magpylib/_src/display/display_utility.py @@ -0,0 +1,504 @@ +""" Display function codes""" +from itertools import cycle +from typing import Tuple + +import numpy as np +from scipy.spatial.transform import Rotation as RotScipy + +from magpylib._src.defaults.defaults_classes import default_settings as Config +from magpylib._src.style import Markers +from magpylib._src.utility import Registered + + +@Registered(kind="nonmodel", family="markers") +class MagpyMarkers: + """A class that stores markers 3D-coordinates""" + + def __init__(self, *markers): + self.style = Markers() + self.markers = np.array(markers) + + +# pylint: disable=too-many-branches +def place_and_orient_model3d( + model_kwargs, + model_args=None, + orientation=None, + position=None, + coordsargs=None, + scale=1, + return_vertices=False, + return_model_args=False, + **kwargs, +): + """places and orients mesh3d dict""" + if orientation is None and position is None: + return {**model_kwargs, **kwargs} + position = (0.0, 0.0, 0.0) if position is None else position + position = np.array(position, dtype=float) + new_model_dict = {} + if model_args is None: + model_args = () + new_model_args = list(model_args) + if model_args: + if coordsargs is None: # matplotlib default + coordsargs = dict(x="args[0]", y="args[1]", z="args[2]") + vertices = [] + if coordsargs is None: + coordsargs = {"x": "x", "y": "y", "z": "z"} + useargs = False + for k in "xyz": + key = coordsargs[k] + if key.startswith("args"): + useargs = True + ind = int(key[5]) + v = model_args[ind] + else: + if key in model_kwargs: + v = model_kwargs[key] + else: + raise ValueError( + "Rotating/Moving of provided model failed, trace dictionary " + f"has no argument {k!r}, use `coordsargs` to specify the names of the " + "coordinates to be used.\n" + "Matplotlib backends will set up coordsargs automatically if " + "the `args=(xs,ys,zs)` argument is provided." + ) + vertices.append(v) + + vertices = np.array(vertices) + + # sometimes traces come as (n,m,3) shape + vert_shape = vertices.shape + vertices = np.reshape(vertices, (3, -1)) + + vertices = vertices.T + + if orientation is not None: + vertices = orientation.apply(vertices) + new_vertices = (vertices * scale + position).T + new_vertices = np.reshape(new_vertices, vert_shape) + for i, k in enumerate("xyz"): + key = coordsargs[k] + if useargs: + ind = int(key[5]) + new_model_args[ind] = new_vertices[i] + else: + new_model_dict[key] = new_vertices[i] + new_model_kwargs = {**model_kwargs, **new_model_dict, **kwargs} + + out = (new_model_kwargs,) + if return_model_args: + out += (new_model_args,) + if return_vertices: + out += (new_vertices,) + return out[0] if len(out) == 1 else out + + +def draw_arrowed_line(vec, pos, sign=1, arrow_size=1) -> Tuple: + """ + Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and + centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and + moved to position `pos`. + """ + norm = np.linalg.norm(vec) + nvec = np.array(vec) / norm + yaxis = np.array([0, 1, 0]) + cross = np.cross(nvec, yaxis) + dot = np.dot(nvec, yaxis) + n = np.linalg.norm(cross) + if dot == -1: + sign *= -1 + hy = sign * 0.1 * arrow_size + hx = 0.06 * arrow_size + arrow = ( + np.array( + [ + [0, -0.5, 0], + [0, 0, 0], + [-hx, 0 - hy, 0], + [0, 0, 0], + [hx, 0 - hy, 0], + [0, 0, 0], + [0, 0.5, 0], + ] + ) + * norm + ) + if n != 0: + t = np.arccos(dot) + R = RotScipy.from_rotvec(-t * cross / n) + arrow = R.apply(arrow) + x, y, z = (arrow + pos).T + return x, y, z + + +def draw_arrow_from_vertices(vertices, current, arrow_size): + """returns scatter coordinates of arrows between input vertices""" + vectors = np.diff(vertices, axis=0) + positions = vertices[:-1] + vectors / 2 + vertices = np.concatenate( + [ + draw_arrowed_line(vec, pos, np.sign(current), arrow_size=arrow_size) + for vec, pos in zip(vectors, positions) + ], + axis=1, + ) + + return vertices + + +def draw_arrowed_circle(current, diameter, arrow_size, vert): + """draws an oriented circle with an arrow""" + t = np.linspace(0, 2 * np.pi, vert) + x = np.cos(t) + y = np.sin(t) + if arrow_size != 0: + hy = 0.2 * np.sign(current) * arrow_size + hx = 0.15 * arrow_size + x = np.hstack([x, [1 + hx, 1, 1 - hx]]) + y = np.hstack([y, [-hy, 0, -hy]]) + x = x * diameter / 2 + y = y * diameter / 2 + z = np.zeros(x.shape) + vertices = np.array([x, y, z]) + return vertices + + +def get_rot_pos_from_path(obj, show_path=None): + """ + subsets orientations and positions depending on `show_path` value. + examples: + show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6] + returns rots[[1,2,6]], poss[[1,2,6]] + """ + # pylint: disable=protected-access + # pylint: disable=invalid-unary-operand-type + if show_path is None: + show_path = True + pos = getattr(obj, "_position", None) + if pos is None: + pos = obj.position + pos = np.array(pos) + orient = getattr(obj, "_orientation", None) + if orient is None: + orient = getattr(obj, "orientation", None) + if orient is None: + orient = RotScipy.from_rotvec([[0, 0, 1]]) + pos = np.array([pos]) if pos.ndim == 1 else pos + path_len = pos.shape[0] + if show_path is True or show_path is False or show_path == 0: + inds = np.array([-1]) + elif isinstance(show_path, int): + inds = np.arange(path_len, dtype=int)[::-show_path] + elif hasattr(show_path, "__iter__") and not isinstance(show_path, str): + inds = np.array(show_path) + inds[inds >= path_len] = path_len - 1 + inds = np.unique(inds) + if inds.size == 0: + inds = np.array([path_len - 1]) + rots = orient[inds] + poss = pos[inds] + return rots, poss, inds + + +def faces_cuboid(src, show_path): + """ + compute vertices and faces of Cuboid input for plotting + takes Cuboid source + returns vert, faces + returns all faces when show_path=all + """ + # pylint: disable=protected-access + a, b, c = src.dimension + vert0 = np.array( + ( + (0, 0, 0), + (a, 0, 0), + (0, b, 0), + (0, 0, c), + (a, b, 0), + (a, 0, c), + (0, b, c), + (a, b, c), + ) + ) + vert0 = vert0 - src.dimension / 2 + + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + faces = [] + for rot, pos in zip(rots, poss): + vert = rot.apply(vert0) + pos + faces += [ + [vert[0], vert[1], vert[4], vert[2]], + [vert[0], vert[1], vert[5], vert[3]], + [vert[0], vert[2], vert[6], vert[3]], + [vert[7], vert[6], vert[2], vert[4]], + [vert[7], vert[6], vert[3], vert[5]], + [vert[7], vert[5], vert[1], vert[4]], + ] + return faces + + +def faces_cylinder(src, show_path): + """ + Compute vertices and faces of Cylinder input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder faces + r, h2 = src.dimension / 2 + hs = np.array([-h2, h2]) + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + faces = [ + np.array( + [ + (r * np.cos(p1), r * np.sin(p1), h2), + (r * np.cos(p1), r * np.sin(p1), -h2), + (r * np.cos(p2), r * np.sin(p2), -h2), + (r * np.cos(p2), r * np.sin(p2), h2), + ] + ) + for p1, p2 in zip(phis, phis2) + ] + faces += [ + np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_cylinder_segment(src, show_path): + """ + Compute vertices and faces of CylinderSegment for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate cylinder segment faces + r1, r2, h, phi1, phi2 = src.dimension + res_tile = ( + int((phi2 - phi1) / 360 * 2 * res) + 2 + ) # resolution used for tile curved surface + phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi + phis2 = np.roll(phis, 1) + faces = [ + np.array( + [ # inner curved surface + (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), + (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), + (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # outer curved surface + (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), + (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), + (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), + ] + ) + for p1, p2 in zip(phis[1:], phis2[1:]) + ] + faces += [ + np.array( + [ # sides + (r1 * np.cos(p), r1 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), h / 2), + (r2 * np.cos(p), r2 * np.sin(p), -h / 2), + (r1 * np.cos(p), r1 * np.sin(p), -h / 2), + ] + ) + for p in [phis[0], phis[-1]] + ] + faces += [ + np.array( # top surface + [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] + ) + ] + faces += [ + np.array( # bottom surface + [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] + + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] + ) + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def faces_sphere(src, show_path): + """ + Compute vertices and faces of Sphere input for plotting. + + Parameters + ---------- + - src (source object) + - show_path (bool or int) + + Returns + ------- + vert, faces (returns all faces when show_path=int) + """ + # pylint: disable=protected-access + res = 15 # surface discretization + + # generate sphere faces + r = src.diameter / 2 + phis = np.linspace(0, 2 * np.pi, res) + phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) + ths = np.linspace(0, np.pi, res) + faces = [ + r + * np.array( + [ + (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), + (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), + (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), + ] + ) + for p, p2 in zip(phis, phis2) + for t1, t2 in zip(ths[1:-2], ths[2:-1]) + ] + faces += [ + r + * np.array( + [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] + ) + for th in [ths[1], ths[-2]] + ] + + # add src attributes position and orientation depending on show_path + rots, poss, _ = get_rot_pos_from_path(src, show_path) + + # all faces (incl. along path) adding pos and rot + all_faces = [] + for rot, pos in zip(rots, poss): + for face in faces: + all_faces += [[rot.apply(f) + pos for f in face]] + + return all_faces + + +def system_size(points): + """compute system size for display""" + # determine min/max from all to generate aspect=1 plot + if points: + + # bring (n,m,3) point dimensions (e.g. from plot_surface body) + # to correct (n,3) shape + for i, p in enumerate(points): + if p.ndim == 3: + points[i] = np.reshape(p, (-1, 3)) + + pts = np.vstack(points) + xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] + ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] + zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] + + xsize = xs[1] - xs[0] + ysize = ys[1] - ys[0] + zsize = zs[1] - zs[0] + + xcenter = (xs[1] + xs[0]) / 2 + ycenter = (ys[1] + ys[0]) / 2 + zcenter = (zs[1] + zs[0]) / 2 + + size = max([xsize, ysize, zsize]) + + limx0 = xcenter + size / 2 + limx1 = xcenter - size / 2 + limy0 = ycenter + size / 2 + limy1 = ycenter - size / 2 + limz0 = zcenter + size / 2 + limz1 = zcenter - size / 2 + else: + limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 + return limx0, limx1, limy0, limy1, limz0, limz1 + + +def get_flatten_objects_properties( + *obj_list_semi_flat, + color_sequence=None, + color_cycle=None, + **parent_props, +): + """returns a flat dict -> (obj: display_props, ...) from nested collections""" + if color_sequence is None: + color_sequence = Config.display.colorsequence + if color_cycle is None: + color_cycle = cycle(color_sequence) + flat_objs = {} + for subobj in obj_list_semi_flat: + isCollection = getattr(subobj, "children", None) is not None + props = {**parent_props} + parent_color = parent_props.get("color", "!!!missing!!!") + if parent_color == "!!!missing!!!": + props["color"] = next(color_cycle) + if parent_props.get("legendgroup", None) is None: + props["legendgroup"] = f"{subobj}" + if parent_props.get("showlegend", None) is None: + props["showlegend"] = True + if parent_props.get("legendtext", None) is None: + legendtext = None + if isCollection: + legendtext = getattr(getattr(subobj, "style", None), "label", None) + legendtext = f"{subobj!r}" if legendtext is None else legendtext + props["legendtext"] = legendtext + flat_objs[subobj] = props + if isCollection: + if subobj.style.color is not None: + flat_objs[subobj]["color"] = subobj.style.color + flat_objs.update( + get_flatten_objects_properties( + *subobj.children, + color_sequence=color_sequence, + color_cycle=color_cycle, + **flat_objs[subobj], + ) + ) + return flat_objs diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 7537adfb8..477c2d32d 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -400,7 +400,7 @@ def make_Sensor( ) -def make_Marker(obj, color=None, style=None, **kwargs): +def make_MagpyMarkers(obj, color=None, style=None, **kwargs): """Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the provided arguments.""" style = obj.style if style is None else style diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index 6eca71e61..0c378f769 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -9,13 +9,13 @@ from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.defaults.defaults_utility import linearize_dict from magpylib._src.style import Markers +from magpylib._src.utility import Registered +@Registered(kind="nonmodel", family="markers") class MagpyMarkers: """A class that stores markers 3D-coordinates""" - _object_type = "Marker" - def __init__(self, *markers): self.style = Markers() self.markers = np.array(markers) diff --git a/magpylib/_src/fields/__init__.py b/magpylib/_src/fields/__init__.py index 35a7b9332..91b9819b5 100644 --- a/magpylib/_src/fields/__init__.py +++ b/magpylib/_src/fields/__init__.py @@ -3,4 +3,4 @@ __all__ = ["getB", "getH"] # create interface to outside of package -from magpylib._src.fields.field_wrap_BH_level3 import getB, getH +from magpylib._src.fields.field_wrap_BH import getB, getH diff --git a/magpylib/_src/fields/field_BH_line.py b/magpylib/_src/fields/field_BH_line.py index f1b0ab7bd..92c8fa72e 100644 --- a/magpylib/_src/fields/field_BH_line.py +++ b/magpylib/_src/fields/field_BH_line.py @@ -11,7 +11,7 @@ def current_vertices_field( field: str, observers: np.ndarray, current: np.ndarray, - vertices: list = None, + vertices: np.ndarray = None, segment_start=None, # list of mix3 ndarrays segment_end=None, ) -> np.ndarray: @@ -32,36 +32,30 @@ def current_vertices_field( if vertices is None: return current_line_field(field, observers, current, segment_start, segment_end) - nv = len(vertices) # number of input vertex_sets - npp = int(observers.shape[0] / nv) # number of position vectors - nvs = [len(vset) - 1 for vset in vertices] # length of vertex sets - nseg = sum(nvs) # number of segments - - # vertex_sets -> segments - curr_tile = np.repeat(current, nvs) - pos_start = np.concatenate([vert[:-1] for vert in vertices]) - pos_end = np.concatenate([vert[1:] for vert in vertices]) - - # create input for vectorized computation in one go - observers = np.reshape(observers, (nv, npp, 3)) - observers = np.repeat(observers, nvs, axis=0) - observers = np.reshape(observers, (-1, 3)) - - curr_tile = np.repeat(curr_tile, npp) - pos_start = np.repeat(pos_start, npp, axis=0) - pos_end = np.repeat(pos_end, npp, axis=0) - - # compute field - field = current_line_field(field, observers, curr_tile, pos_start, pos_end) - field = np.reshape(field, (nseg, npp, 3)) - - # sum for each vertex set - ns_cum = [sum(nvs[:i]) for i in range(nv + 1)] # cumulative index positions - field_sum = np.array( - [np.sum(field[ns_cum[i - 1] : ns_cum[i]], axis=0) for i in range(1, nv + 1)] - ) - - return np.reshape(field_sum, (-1, 3)) + nvs = np.array([f.shape[0] for f in vertices]) # lengths of vertices sets + if all(v == nvs[0] for v in nvs): # if all vertices sets have the same lenghts + n0, n1, *_ = vertices.shape + BH = current_line_field( + field=field, + observers=np.repeat(observers, n1 - 1, axis=0), + current=np.repeat(current, n1 - 1, axis=0), + segment_start=vertices[:, :-1].reshape(-1, 3), + segment_end=vertices[:, 1:].reshape(-1, 3), + ) + BH = BH.reshape((n0, n1 - 1, 3)) + BH = np.sum(BH, axis=1) + else: + split_indices = np.cumsum(nvs - 1)[:-1] # remove last to avoid empty split + BH = current_line_field( + field=field, + observers=np.repeat(observers, nvs - 1, axis=0), + current=np.repeat(current, nvs - 1, axis=0), + segment_start=np.concatenate([vert[:-1] for vert in vertices]), + segment_end=np.concatenate([vert[1:] for vert in vertices]), + ) + bh_split = np.split(BH, split_indices) + BH = np.array([np.sum(bh, axis=0) for bh in bh_split]) + return BH # ON INTERFACE @@ -122,7 +116,6 @@ def current_line_field( eg. http://www.phys.uri.edu/gerhard/PHY204/tsl216.pdf """ # pylint: disable=too-many-statements - bh = check_field_input(field, "current_line_field()") # allocate for special case treatment diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py new file mode 100644 index 000000000..1feab8b96 --- /dev/null +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -0,0 +1,853 @@ +"""Field computation structure: + +level0:(field_BH_XXX.py files) + - pure vectorized field computations from literature + - all computations in source CS + - distinguish B/H + +level1(getBH_level1): + - apply transformation to global CS + - select correct level0 src_type computation + - input dict, no input checks ! + +level2(getBHv_level2): <--- DIRECT ACCESS TO FIELD COMPUTATION FORMULAS, INPUT = DICT OF ARRAYS + - input dict checks (unknowns) + - secure user inputs + - check input for mandatory information + - set missing input variables to default values + - tile 1D inputs + +level2(getBH_level2): <--- COMPUTE FIELDS FROM SOURCES + - input dict checks (unknowns) + - secure user inputs + - group similar sources for combined computation + - generate vector input format for getBH_level1 + - adjust Bfield output format to (pos_obs, path, sources) input format + +level3(getB, getH, getB_dict, getH_dict): <--- USER INTERFACE + - docstrings + - separated B and H + - transform input into dict for level2 + +level4(src.getB, src.getH): <--- USER INTERFACE + - docstrings + - calling level3 getB, getH directly from sources + +level3(getBH_from_sensor): + - adjust output format to (senors, path, sources) input format + +level4(getB_from_sensor, getH_from_sensor): <--- USER INTERFACE + +level5(sens.getB, sens.getH): <--- USER INTERFACE +""" +import numbers +from itertools import product +from typing import Callable + +import numpy as np +from scipy.spatial.transform import Rotation as R + +from magpylib._src.exceptions import MagpylibBadUserInput +from magpylib._src.exceptions import MagpylibInternalError +from magpylib._src.input_checks import check_dimensions +from magpylib._src.input_checks import check_excitations +from magpylib._src.input_checks import check_format_input_observers +from magpylib._src.input_checks import check_format_pixel_agg +from magpylib._src.input_checks import check_getBH_output_type +from magpylib._src.utility import check_static_sensor_orient +from magpylib._src.utility import format_obj_input +from magpylib._src.utility import format_src_inputs +from magpylib._src.utility import Registered + + +def tile_group_property(group: list, n_pp: int, prop_name: str): + """tile up group property""" + out = [getattr(src, prop_name) for src in group] + if not np.isscalar(out[0]) and any(o.shape != out[0].shape for o in out): + out = np.asarray(out, dtype="object") + else: + out = np.array(out) + return np.repeat(out, n_pp, axis=0) + + +def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: + """create dictionaries for level1 input""" + # pylint: disable=protected-access + # pylint: disable=too-many-return-statements + + # tile up basic attributes that all sources have + # position + poss = np.array([src._position for src in group]) + posv = np.tile(poss, n_pix).reshape((-1, 3)) + + # orientation + rots = np.array([src._orientation.as_quat() for src in group]) + rotv = np.tile(rots, n_pix).reshape((-1, 4)) + rotobj = R.from_quat(rotv) + + # pos_obs + posov = np.tile(poso, (len(group), 1)) + + # determine which group we are dealing with and tile up properties + src_type = group[0]._object_type + + kwargs = { + "position": posv, + "observers": posov, + "orientation": rotobj, + } + + try: + src_props = Registered.source_kwargs_ndim[src_type] + except KeyError as err: + raise MagpylibInternalError("Bad source_type in get_src_dict") from err + + for prop in src_props: + if hasattr(group[0], prop) and prop not in ( + "position", + "orientation", + "observers", + ): + kwargs[prop] = tile_group_property(group, n_pp, prop) + + return kwargs + + +def getBH_level1( + *, + field_func: Callable, + field: str, + position: np.ndarray, + orientation: np.ndarray, + observers: np.ndarray, + **kwargs: dict, +) -> np.ndarray: + """Vectorized field computation + + - applies spatial transformations global CS <-> source CS + - selects the correct Bfield_XXX function from input + + Args + ---- + kwargs: dict of shape (N,x) input vectors that describes the computation. + + Returns + ------- + field: ndarray, shape (N,3) + + """ + + # transform obs_pos into source CS + pos_rel_rot = orientation.apply(observers - position, inverse=True) + + # compute field + BH = field_func(field=field, observers=pos_rel_rot, **kwargs) + + # transform field back into global CS + BH = orientation.apply(BH) + + return BH + + +def getBH_level2( + sources, observers, *, field, sumup, squeeze, pixel_agg, output, **kwargs +) -> np.ndarray: + """Compute field for given sources and observers. + + Parameters + ---------- + sources : src_obj or list + source object or 1D list of L sources/collections with similar + pathlength M and/or 1. + observers : sens_obj or list or pos_obs + pos_obs or sensor object or 1D list of K sensors with similar pathlength M + and/or 1 and sensor pixel of shape (N1,N2,...,3). + sumup : bool, default=False + returns [B1,B2,...] for every source, True returns sum(Bi) sfor all sources. + squeeze : bool, default=True: + If True output is squeezed (axes of length 1 are eliminated) + pixel_agg : str + A compatible numpy aggregator string (e.g. `'min', 'max', 'mean'`) + which applies on pixel output values. + field : {'B', 'H'} + 'B' computes B field, 'H' computes H-field + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + + Returns + ------- + field: ndarray, shape squeeze((L,M,K,N1,N2,...,3)), field of L sources, M path + positions, K sensors and N1xN2x.. observer positions and 3 field components. + + Info: + ----- + - generates a 1D list of sources (collections flattened) and a 1D list of sensors from input + - tile all paths of static (path_length=1) objects + - combine all sensor pixel positions for joint evaluation + - group similar source types for joint evaluation + - compute field and store in allocated array + - rearrange the array in the shape squeeze((L, M, K, N1, N2, ...,3)) + """ + # pylint: disable=protected-access + # pylint: disable=too-many-branches + # pylint: disable=too-many-statements + + # CHECK AND FORMAT INPUT --------------------------------------------------- + if isinstance(sources, str): + return getBH_dict_level2( + source_type=sources, + observers=observers, + field=field, + squeeze=squeeze, + **kwargs, + ) + + # bad user inputs mixing getBH_dict kwargs with object oriented interface + if kwargs: + raise MagpylibBadUserInput( + f"Keyword arguments {tuple(kwargs.keys())} are only allowed when the source " + "is defined by a string (e.g. sources='Cylinder')" + ) + + # format sources input: + # input: allow only one bare src object or a 1D list/tuple of src and col + # out: sources = ordered list of sources + # out: src_list = ordered list of sources with flattened collections + sources, src_list = format_src_inputs(sources) + + # test if all source dimensions and excitations are initialized + check_dimensions(sources) + check_excitations(sources, field) + + # format observers input: + # allow only bare sensor, collection, pos_vec or list thereof + # transform input into an ordered list of sensors (pos_vec->pixel) + # check if all pixel shapes are similar - or else if pixel_agg is given + pixel_agg_func = check_format_pixel_agg(pixel_agg) + sensors, pix_shapes = check_format_input_observers(observers, pixel_agg) + pix_nums = [ + int(np.product(ps[:-1])) for ps in pix_shapes + ] # number of pixel for each sensor + pix_inds = np.cumsum([0] + pix_nums) # cummulative indices of pixel for each sensor + pix_all_same = len(set(pix_shapes)) == 1 + + # check which sensors have unit roation + # so that they dont have to be rotated back later (performance issue) + # this check is made now when sensor paths are not yet tiled. + unitQ = np.array([0, 0, 0, 1.0]) + unrotated_sensors = [ + all(all(r == unitQ) for r in sens._orientation.as_quat()) for sens in sensors + ] + + # check which sensors have a static orientation + # either static sensor or translation path + # later such sensors require less back-rotation effort (performance issue) + static_sensor_rot = check_static_sensor_orient(sensors) + + # some important quantities ------------------------------------------------- + obj_list = set(src_list + sensors) # unique obj entries only !!! + num_of_sources = len(sources) + num_of_src_list = len(src_list) + num_of_sensors = len(sensors) + + # tile up paths ------------------------------------------------------------- + # all obj paths that are shorter than max-length are filled up with the last + # postion/orientation of the object (static paths) + path_lengths = [len(obj._position) for obj in obj_list] + max_path_len = max(path_lengths) + + # objects to tile up and reset below + mask_reset = [max_path_len != pl for pl in path_lengths] + reset_obj = [obj for obj, mask in zip(obj_list, mask_reset) if mask] + reset_obj_m0 = [pl for pl, mask in zip(path_lengths, mask_reset) if mask] + + if max_path_len > 1: + for obj, m0 in zip(reset_obj, reset_obj_m0): + # length to be tiled + m_tile = max_path_len - m0 + # tile up position + tile_pos = np.tile(obj._position[-1], (m_tile, 1)) + obj._position = np.concatenate((obj._position, tile_pos)) + # tile up orientation + tile_orient = np.tile(obj._orientation.as_quat()[-1], (m_tile, 1)) + # FUTURE use Rotation.concatenate() requires scipy>=1.8 and python 3.8 + tile_orient = np.concatenate((obj._orientation.as_quat(), tile_orient)) + obj._orientation = R.from_quat(tile_orient) + + # combine information form all sensors to generate pos_obs with------------- + # shape (m * concat all sens flat pixel, 3) + # allows sensors with different pixel shapes <- relevant? + poso = [ + [ + r.apply(sens.pixel.reshape(-1, 3)) + p + for r, p in zip(sens._orientation, sens._position) + ] + for sens in sensors + ] + poso = np.concatenate(poso, axis=1).reshape(-1, 3) + n_pp = len(poso) + n_pix = int(n_pp / max_path_len) + + # group similar source types---------------------------------------------- + field_func_groups = {} + for ind, src in enumerate(src_list): + group_key = src.field_func + if group_key not in field_func_groups: + field_func_groups[group_key] = { + "sources": [], + "order": [], + } + field_func_groups[group_key]["sources"].append(src) + field_func_groups[group_key]["order"].append(ind) + + # evaluate each group in one vectorized step ------------------------------- + B = np.empty((num_of_src_list, max_path_len, n_pix, 3)) # allocate B + for field_func, group in field_func_groups.items(): + lg = len(group["sources"]) + gr = group["sources"] + src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 + B_group = getBH_level1( + field_func=field_func, field=field, **src_dict + ) # compute field + B_group = B_group.reshape( + (lg, max_path_len, n_pix, 3) + ) # reshape (2% slower for large arrays) + for gr_ind in range(lg): # put into dedicated positions in B + B[group["order"][gr_ind]] = B_group[gr_ind] + + # reshape output ---------------------------------------------------------------- + # rearrange B when there is at least one Collection with more than one source + if num_of_src_list > num_of_sources: + for src_ind, src in enumerate(sources): + if src._object_type == "Collection": + col_len = len(format_obj_input(src, allow="sources")) + # set B[i] to sum of slice + B[src_ind] = np.sum(B[src_ind : src_ind + col_len], axis=0) + B = np.delete( + B, np.s_[src_ind + 1 : src_ind + col_len], 0 + ) # delete remaining part of slice + + # apply sensor rotations (after summation over collections to reduce rot.apply operations) + for sens_ind, sens in enumerate(sensors): # cycle through all sensors + if not unrotated_sensors[sens_ind]: # apply operations only to rotated sensors + # select part where rot is applied + Bpart = B[:, :, pix_inds[sens_ind] : pix_inds[sens_ind + 1]] + # change shape to (P,3) for rot package + Bpart_orig_shape = Bpart.shape + Bpart_flat = np.reshape(Bpart, (-1, 3)) + # apply sensor rotation + if static_sensor_rot[sens_ind]: # special case: same rotation along path + sens_orient = sens._orientation[0] + else: + sens_orient = R.from_quat( + np.tile( # tile for each source from list + np.repeat( # same orientation path index for all indices + sens._orientation.as_quat(), pix_nums[sens_ind], axis=0 + ), + (num_of_sources, 1), + ) + ) + Bpart_flat_rot = sens_orient.inv().apply(Bpart_flat) + # overwrite Bpart in B + B[:, :, pix_inds[sens_ind] : pix_inds[sens_ind + 1]] = np.reshape( + Bpart_flat_rot, Bpart_orig_shape + ) + + # rearrange sensor-pixel shape + if pix_all_same: + B = B.reshape((num_of_sources, max_path_len, num_of_sensors, *pix_shapes[0])) + # aggregate pixel values + if pixel_agg is not None: + B = pixel_agg_func(B, axis=tuple(range(3 - B.ndim, -1))) + else: # pixel_agg is not None when pix_all_same, checked with + Bsplit = np.split(B, pix_inds[1:-1], axis=2) + Bagg = [np.expand_dims(pixel_agg_func(b, axis=2), axis=2) for b in Bsplit] + B = np.concatenate(Bagg, axis=2) + + # reset tiled objects + for obj, m0 in zip(reset_obj, reset_obj_m0): + obj._position = obj._position[:m0] + obj._orientation = obj._orientation[:m0] + + # sumup over sources + if sumup: + B = np.sum(B, axis=0, keepdims=True) + + output = check_getBH_output_type(output) + + if output == "dataframe": + # pylint: disable=import-outside-toplevel + import pandas as pd + + if sumup and len(sources) > 1: + src_ids = [f"sumup ({len(sources)})"] + else: + src_ids = [s.style.label if s.style.label else f"{s}" for s in sources] + sens_ids = [s.style.label if s.style.label else f"{s}" for s in sensors] + num_of_pixels = np.prod(pix_shapes[0][:-1]) if pixel_agg is None else 1 + df = pd.DataFrame( + data=product(src_ids, range(max_path_len), sens_ids, range(num_of_pixels)), + columns=["source", "path", "sensor", "pixel"], + ) + df[[field + k for k in "xyz"]] = B.reshape(-1, 3) + return df + + # reduce all size-1 levels + if squeeze: + B = np.squeeze(B) + elif pixel_agg is not None: + # add missing dimension since `pixel_agg` reduces pixel + # dimensions to zero. Only needed if `squeeze is False`` + B = np.expand_dims(B, axis=-2) + + return B + + +def getBH_dict_level2( + source_type, + observers, + *, + field: str, + position=(0, 0, 0), + orientation=R.identity(), + squeeze=True, + **kwargs: dict, +) -> np.ndarray: + """Direct interface access to vectorized computation + + Parameters + ---------- + kwargs: dict that describes the computation. + + Returns + ------- + field: ndarray, shape (N,3), field at obs_pos in [mT] or [kA/m] + + Info + ---- + - check inputs + + - secures input types (list/tuple -> ndarray) + - test if mandatory inputs are there + - sets default input variables (e.g. pos, rot) if missing + - tiles 1D inputs vectors to correct dimension + """ + # pylint: disable=protected-access + + # generate dict of secured inputs for auto-tiling --------------- + # entries in this dict will be tested for input length, and then + # be automatically tiled up and stored back into kwargs for calling + # getBH_level1(). + # To allow different input dimensions, the tdim argument is also given + # which tells the program which dimension it should tile up. + + try: + field_func = Registered.sources[source_type]._field_func + except KeyError as err: + raise MagpylibBadUserInput( + f"Input parameter `sources` must be one of {list(Registered.sources)}" + " when using the direct interface." + ) from err + + kwargs["observers"] = observers + kwargs["position"] = position + + # change orientation to Rotation numpy array for tiling + kwargs["orientation"] = orientation.as_quat() + + # evaluation vector lengths + vec_lengths = {} + ragged_seq = {} + for key, val in kwargs.items(): + try: + if ( + not isinstance(val, numbers.Number) + and not isinstance(val[0], numbers.Number) + and any(len(o) != len(val[0]) for o in val) + ): + ragged_seq[key] = True + val = np.array([np.array(v, dtype=float) for v in val], dtype="object") + else: + ragged_seq[key] = False + val = np.array(val, dtype=float) + except TypeError as err: + raise MagpylibBadUserInput( + f"{key} input must be array-like.\n" f"Instead received {val}" + ) from err + expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) + if val.ndim == expected_dim or ragged_seq[key]: + vec_lengths[key] = len(val) + kwargs[key] = val + + if len(set(vec_lengths.values())) > 1: + raise MagpylibBadUserInput( + "Input array lengths must be 1 or of a similar length.\n" + f"Instead received lengths {vec_lengths}" + ) + vec_len = max(vec_lengths.values(), default=1) + + # tile 1D inputs and replace original values in kwargs + for key, val in kwargs.items(): + expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) + if val.ndim < expected_dim: + if expected_dim == 1: + kwargs[key] = np.array([val] * vec_len) + elif ragged_seq[key]: + kwargs[key] = np.array( + [np.tile(v, (vec_len, 1)) for v in val], dtype="object" + ) + else: + kwargs[key] = np.tile(val, (vec_len, 1)) + else: + kwargs[key] = val + + # change orientation back to Rotation object + kwargs["orientation"] = R.from_quat(kwargs["orientation"]) + + # compute and return B + B = getBH_level1(field=field, field_func=field_func, **kwargs) + + if squeeze: + return np.squeeze(B) + return B + + +def getB( + sources=None, + observers=None, + sumup=False, + squeeze=True, + pixel_agg=None, + output="ndarray", + **kwargs, +): + """Compute B-field in [mT] for given sources and observers. + + Field implementations can be directly accessed (avoiding the object oriented + Magpylib interface) by providing a string input `sources=source_type`, array_like + positions as `observers` input, and all other necessary input parameters (see below) + as kwargs. + + Parameters + ---------- + sources: source and collection objects or 1D list thereof + Sources that generate the magnetic field. Can be a single source (or collection) + or a 1D list of l source and/or collection objects. + + Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, + `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). + + observers: array_like or (list of) `Sensor` objects + Can be array_like positions of shape (n1, n2, ..., 3) where the field + should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list + of such sensor objects (must all have similar pixel shapes). All positions + are given in units of [mm]. + + Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding + positions to observer positions in units of [mm]. + + sumup: bool, default=`False` + If `True`, the fields of all sources are summed up. + + squeeze: bool, default=`True` + If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only + a single sensor or only a single source) are eliminated. + + pixel_agg: str, default=`None` + Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, + which is applied to observer output values, e.g. mean of all sensor pixel outputs. + With this option, observers input with different (pixel) shapes is allowed. + + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). + + Other Parameters (Direct interface) + ----------------------------------- + position: array_like, shape (3,) or (n,3), default=`(0,0,0)` + Source position(s) in the global coordinates in units of [mm]. + + orientation: scipy `Rotation` object with length 1 or n, default=`None` + Object orientation(s) in the global coordinates. `None` corresponds to + a unit-rotation. + + magnetization: array_like, shape (3,) or (n,3) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! + Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in + the local object coordinates (rotates with object). + + moment: array_like, shape (3) or (n,3), unit [mT*mm^3] + Only source_type == `'Dipole'`! + Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates + (rotates with object). For homogeneous magnets the relation moment=magnetization*volume + holds. + + current: array_like, shape (n,) + Only source_type == `'Loop'` or `'Line'`! + Electrical current in units of [A]. + + dimension: array_like, shape (x,) or (n,x) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! + Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar + as in object oriented interface. + + diameter: array_like, shape (n,) + Only source_type == `'Sphere'` or `'Loop'`! + Diameter of source in units of [mm]. + + segment_start: array_like, shape (n,3) + Only source_type == `'Line'`! + Start positions of line current segments in units of [mm]. + + segment_end: array_like, shape (n,3) + Only source_type == `'Line'`! + End positions of line current segments in units of [mm]. + + Returns + ------- + B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame + B-field at each path position (m) for each sensor (k) and each sensor pixel + position (n1, n2, ...) in units of [mT]. Sensor pixel positions are equivalent + to simple observer positions. Paths of objects that are shorter than m will be + considered as static beyond their end. + + Direct interface: ndarray, shape (n,3) + B-field for every parameter set in units of [mT]. + + Notes + ----- + This function automatically joins all sensor and position inputs together and groups + similar sources for optimal vectorization of the computation. For maximal performance + call this function as little as possible and avoid using it in loops. + + Examples + -------- + In this example we compute the B-field [mT] of a spherical magnet and a current loop + at the observer position (1,1,1) given in units of [mm]: + + >>> import magpylib as magpy + >>> src1 = magpy.current.Loop(current=100, diameter=2) + >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) + >>> B = magpy.getB([src1, src2], (1,1,1)) + >>> print(B) + [[6.23597388e+00 6.23597388e+00 2.66977810e+00] + [8.01875374e-01 8.01875374e-01 1.48029737e-16]] + + We can also use sensor objects as observers input: + + >>> sens1 = magpy.Sensor(position=(1,1,1)) + >>> sens2 = sens1.copy(position=(1,1,-1)) + >>> B = magpy.getB([src1, src2], [sens1, sens2]) + >>> print(B) + [[[ 6.23597388e+00 6.23597388e+00 2.66977810e+00] + [-6.23597388e+00 -6.23597388e+00 2.66977810e+00]] + + [[ 8.01875374e-01 8.01875374e-01 1.48029737e-16] + [-8.01875374e-01 -8.01875374e-01 1.48029737e-16]]] + + Through the direct interface we can compute the same fields for the loop as: + + >>> obs = [(1,1,1), (1,1,-1)] + >>> B = magpy.getB('Loop', obs, current=100, diameter=2) + >>> print(B) + [[ 6.23597388 6.23597388 2.6697781 ] + [-6.23597388 -6.23597388 2.6697781 ]] + + But also for a set of four completely different instances: + + >>> B = magpy.getB( + ... 'Loop', + ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), + ... current=(11, 22, 33, 44), + ... diameter=(1, 2, 3, 4), + ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), + ... ) + >>> print(B) + [[ 0.17111325 0.17111325 0.01705189] + [-0.38852048 -0.38852048 0.49400758] + [ 1.14713551 2.29427102 -0.22065346] + [-2.48213467 -2.48213467 -0.79683487]] + """ + return getBH_level2( + sources, + observers, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="B", + **kwargs, + ) + + +def getH( + sources=None, + observers=None, + sumup=False, + squeeze=True, + pixel_agg=None, + output="ndarray", + **kwargs, +): + """Compute H-field in [kA/m] for given sources and observers. + + Field implementations can be directly accessed (avoiding the object oriented + Magpylib interface) by providing a string input `sources=source_type`, array_like + positions as `observers` input, and all other necessary input parameters (see below) + as kwargs. + + Parameters + ---------- + sources: source and collection objects or 1D list thereof + Sources that generate the magnetic field. Can be a single source (or collection) + or a 1D list of l source and/or collection objects. + + Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, + `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). + + observers: array_like or (list of) `Sensor` objects + Can be array_like positions of shape (n1, n2, ..., 3) where the field + should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list + of such sensor objects (must all have similar pixel shapes). All positions + are given in units of [mm]. + + Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding + positions to observer positions in units of [mm]. + + sumup: bool, default=`False` + If `True`, the fields of all sources are summed up. + + squeeze: bool, default=`True` + If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only + a single sensor or only a single source) are eliminated. + + pixel_agg: str, default=`None` + Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, + which is applied to observer output values, e.g. mean of all sensor pixel outputs. + With this option, observer inputs with different (pixel) shapes are allowed. + + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a + `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` + object is returned (the Pandas library must be installed). + + Other Parameters (Direct interface) + ----------------------------------- + position: array_like, shape (3,) or (n,3), default=`(0,0,0)` + Source position(s) in the global coordinates in units of [mm]. + + orientation: scipy `Rotation` object with length 1 or n, default=`None` + Object orientation(s) in the global coordinates. `None` corresponds to + a unit-rotation. + + magnetization: array_like, shape (3,) or (n,3) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! + Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in + the local object coordinates (rotates with object). + + moment: array_like, shape (3) or (n,3), unit [mT*mm^3] + Only source_type == `'Dipole'`! + Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates + (rotates with object). For homogeneous magnets the relation moment=magnetization*volume + holds. + + current: array_like, shape (n,) + Only source_type == `'Loop'` or `'Line'`! + Electrical current in units of [A]. + + dimension: array_like, shape (x,) or (n,x) + Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! + Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar + as in object oriented interface. + + diameter: array_like, shape (n,) + Only source_type == `'Sphere'` or `'Loop'`! + Diameter of source in units of [mm]. + + segment_start: array_like, shape (n,3) + Only source_type == `'Line'`! + Start positions of line current segments in units of [mm]. + + segment_end: array_like, shape (n,3) + Only source_type == `'Line'`! + End positions of line current segments in units of [mm]. + + Returns + ------- + H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame + H-field at each path position (m) for each sensor (k) and each sensor pixel + position (n1, n2, ...) in units of [kA/m]. Sensor pixel positions are equivalent + to simple observer positions. Paths of objects that are shorter than m will be + considered as static beyond their end. + + Direct interface: ndarray, shape (n,3) + H-field for every parameter set in units of [kA/m]. + + Notes + ----- + This function automatically joins all sensor and position inputs together and groups + similar sources for optimal vectorization of the computation. For maximal performance + call this function as little as possible and avoid using it in loops. + + Examples + -------- + In this example we compute the H-field [kA/m] of a spherical magnet and a current loop + at the observer position (1,1,1) given in units of [mm]: + + >>> import magpylib as magpy + >>> src1 = magpy.current.Loop(current=100, diameter=2) + >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) + >>> H = magpy.getH([src1, src2], (1,1,1)) + >>> print(H) + [[4.96243034e+00 4.96243034e+00 2.12454191e+00] + [6.38112147e-01 6.38112147e-01 1.17798322e-16]] + + We can also use sensor objects as observers input: + + >>> sens1 = magpy.Sensor(position=(1,1,1)) + >>> sens2 = sens1.copy(position=(1,1,-1)) + >>> H = magpy.getH([src1, src2], [sens1, sens2]) + >>> print(H) + [[[ 4.96243034e+00 4.96243034e+00 2.12454191e+00] + [-4.96243034e+00 -4.96243034e+00 2.12454191e+00]] + + [[ 6.38112147e-01 6.38112147e-01 1.17798322e-16] + [-6.38112147e-01 -6.38112147e-01 1.17798322e-16]]] + + Through the direct interface we can compute the same fields for the loop as: + + >>> obs = [(1,1,1), (1,1,-1)] + >>> H = magpy.getH('Loop', obs, current=100, diameter=2) + >>> print(H) + [[ 4.96243034 4.96243034 2.12454191] + [-4.96243034 -4.96243034 2.12454191]] + + But also for a set of four completely different instances: + + >>> H = magpy.getH( + ... 'Loop', + ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), + ... current=(11, 22, 33, 44), + ... diameter=(1, 2, 3, 4), + ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), + ... ) + >>> print(H) + [[ 0.1361676 0.1361676 0.01356947] + [-0.30917477 -0.30917477 0.39311875] + [ 0.91286143 1.82572286 -0.17559045] + [-1.97522001 -1.97522001 -0.63410104]] + """ + return getBH_level2( + sources, + observers, + sumup=sumup, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="H", + **kwargs, + ) diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py deleted file mode 100644 index 2f0cd8b31..000000000 --- a/magpylib/_src/fields/field_wrap_BH_level1.py +++ /dev/null @@ -1,68 +0,0 @@ -import numpy as np - -from magpylib._src.exceptions import MagpylibInternalError -from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field -from magpylib._src.fields.field_BH_cylinder import magnet_cylinder_field -from magpylib._src.fields.field_BH_cylinder_segment import ( - magnet_cylinder_segment_field_internal, -) -from magpylib._src.fields.field_BH_dipole import dipole_field -from magpylib._src.fields.field_BH_line import current_vertices_field -from magpylib._src.fields.field_BH_loop import current_loop_field -from magpylib._src.fields.field_BH_sphere import magnet_sphere_field - -FIELD_FUNCTIONS = { - "Cuboid": magnet_cuboid_field, - "Cylinder": magnet_cylinder_field, - "CylinderSegment": magnet_cylinder_segment_field_internal, - "Sphere": magnet_sphere_field, - "Dipole": dipole_field, - "Loop": current_loop_field, - "Line": current_vertices_field, -} - - -def getBH_level1( - *, - source_type: str, - position: np.ndarray, - orientation: np.ndarray, - observers: np.ndarray, - **kwargs: dict, -) -> np.ndarray: - """Vectorized field computation - - - applies spatial transformations global CS <-> source CS - - selects the correct Bfield_XXX function from input - - Args - ---- - kwargs: dict of shape (N,x) input vectors that describes the computation. - - Returns - ------- - field: ndarray, shape (N,3) - - """ - # pylint: disable=too-many-statements - # pylint: disable=too-many-branches - - # transform obs_pos into source CS - pos_rel_rot = orientation.apply(observers - position, inverse=True) - - # collect dictionary inputs and compute field - field_func = FIELD_FUNCTIONS.get(source_type, None) - - if source_type == "CustomSource": - field = kwargs["field"] - if kwargs.get("field_func", None) is not None: - BH = kwargs["field_func"](field, pos_rel_rot) - elif field_func is not None: - BH = field_func(observers=pos_rel_rot, **kwargs) - else: - raise MagpylibInternalError(f'Bad src input type "{source_type}" in level1') - - # transform field back into global CS - BH = orientation.apply(BH) - - return BH diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py deleted file mode 100644 index 873feeffd..000000000 --- a/magpylib/_src/fields/field_wrap_BH_level2.py +++ /dev/null @@ -1,447 +0,0 @@ -from itertools import product - -import numpy as np -from scipy.spatial.transform import Rotation as R - -from magpylib._src.exceptions import MagpylibBadUserInput -from magpylib._src.exceptions import MagpylibInternalError -from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 -from magpylib._src.input_checks import check_dimensions -from magpylib._src.input_checks import check_excitations -from magpylib._src.input_checks import check_format_input_observers -from magpylib._src.input_checks import check_format_pixel_agg -from magpylib._src.input_checks import check_getBH_output_type -from magpylib._src.utility import check_static_sensor_orient -from magpylib._src.utility import format_obj_input -from magpylib._src.utility import format_src_inputs -from magpylib._src.utility import LIBRARY_BH_DICT_SOURCE_STRINGS - - -PARAM_TILE_DIMS = { - "observers": 2, - "position": 2, - "orientation": 2, - "magnetization": 2, - "current": 1, - "moment": 2, - "dimension": 2, - "diameter": 1, - "segment_start": 2, - "segment_end": 2, -} - -SOURCE_PROPERTIES = { - "Cuboid": ("magnetization", "dimension"), - "Cylinder": ("magnetization", "dimension"), - "CylinderSegment": ("magnetization", "dimension"), - "Sphere": ("magnetization", "diameter"), - "Dipole": ("moment",), - "Loop": ("current", "diameter"), - "Line": ("current", "vertices"), - "CustomSource": (), -} - - -def tile_group_property(group: list, n_pp: int, prop_name: str): - """tile up group property""" - out = np.array([getattr(src, prop_name) for src in group]) - return np.repeat(out, n_pp, axis=0) - - -def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: - """create dictionaries for level1 input""" - # pylint: disable=protected-access - # pylint: disable=too-many-return-statements - - # tile up basic attributes that all sources have - # position - poss = np.array([src._position for src in group]) - posv = np.tile(poss, n_pix).reshape((-1, 3)) - - # orientation - rots = np.array([src._orientation.as_quat() for src in group]) - rotv = np.tile(rots, n_pix).reshape((-1, 4)) - rotobj = R.from_quat(rotv) - - # pos_obs - posov = np.tile(poso, (len(group), 1)) - - # determine which group we are dealing with and tile up properties - src_type = group[0]._object_type - - kwargs = { - "source_type": src_type, - "position": posv, - "observers": posov, - "orientation": rotobj, - } - - try: - src_props = SOURCE_PROPERTIES[src_type] - except KeyError as err: - raise MagpylibInternalError("Bad source_type in get_src_dict") from err - - if src_type == "Line": # get_BH_line_from_vert function tiles internally ! - currv = np.array([src.current for src in group]) - vert_list = [src.vertices for src in group] - kwargs.update({"current": currv, "vertices": vert_list}) - elif src_type == "CustomSource": - kwargs.update(field_func=group[0].field_func) - else: - for prop in src_props: - kwargs[prop] = tile_group_property(group, n_pp, prop) - - return kwargs - - -def getBH_level2( - sources, observers, *, field, sumup, squeeze, pixel_agg, output, **kwargs -) -> np.ndarray: - """Compute field for given sources and observers. - - Parameters - ---------- - sources : src_obj or list - source object or 1D list of L sources/collections with similar - pathlength M and/or 1. - observers : sens_obj or list or pos_obs - pos_obs or sensor object or 1D list of K sensors with similar pathlength M - and/or 1 and sensor pixel of shape (N1,N2,...,3). - sumup : bool, default=False - returns [B1,B2,...] for every source, True returns sum(Bi) sfor all sources. - squeeze : bool, default=True: - If True output is squeezed (axes of length 1 are eliminated) - pixel_agg : str - A compatible numpy aggregator string (e.g. `'min', 'max', 'mean'`) - which applies on pixel output values. - field : {'B', 'H'} - 'B' computes B field, 'H' computes H-field - output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). - - Returns - ------- - field: ndarray, shape squeeze((L,M,K,N1,N2,...,3)), field of L sources, M path - positions, K sensors and N1xN2x.. observer positions and 3 field components. - - Info: - ----- - - generates a 1D list of sources (collections flattened) and a 1D list of sensors from input - - tile all paths of static (path_length=1) objects - - combine all sensor pixel positions for joint evaluation - - group similar source types for joint evaluation - - compute field and store in allocated array - - rearrange the array in the shape squeeze((L, M, K, N1, N2, ...,3)) - """ - # pylint: disable=protected-access - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - - # CHECK AND FORMAT INPUT --------------------------------------------------- - if isinstance(sources, str): - return getBH_dict_level2( - source_type=sources, - observers=observers, - field=field, - squeeze=squeeze, - **kwargs, - ) - - # bad user inputs mixing getBH_dict kwargs with object oriented interface - if kwargs: - raise MagpylibBadUserInput( - f"Keyword arguments {tuple(kwargs.keys())} are only allowed when the source " - "is defined by a string (e.g. sources='Cylinder')" - ) - - # format sources input: - # input: allow only one bare src object or a 1D list/tuple of src and col - # out: sources = ordered list of sources - # out: src_list = ordered list of sources with flattened collections - sources, src_list = format_src_inputs(sources) - - # test if all source dimensions and excitations are initialized - check_dimensions(sources) - check_excitations(sources, field) - - # format observers input: - # allow only bare sensor, collection, pos_vec or list thereof - # transform input into an ordered list of sensors (pos_vec->pixel) - # check if all pixel shapes are similar - or else if pixel_agg is given - pixel_agg_func = check_format_pixel_agg(pixel_agg) - sensors, pix_shapes = check_format_input_observers(observers, pixel_agg) - pix_nums = [ - int(np.product(ps[:-1])) for ps in pix_shapes - ] # number of pixel for each sensor - pix_inds = np.cumsum([0] + pix_nums) # cummulative indices of pixel for each sensor - pix_all_same = len(set(pix_shapes)) == 1 - - # check which sensors have unit roation - # so that they dont have to be rotated back later (performance issue) - # this check is made now when sensor paths are not yet tiled. - unitQ = np.array([0, 0, 0, 1.0]) - unrotated_sensors = [ - all(all(r == unitQ) for r in sens._orientation.as_quat()) for sens in sensors - ] - - # check which sensors have a static orientation - # either static sensor or translation path - # later such sensors require less back-rotation effort (performance issue) - static_sensor_rot = check_static_sensor_orient(sensors) - - # some important quantities ------------------------------------------------- - obj_list = set(src_list + sensors) # unique obj entries only !!! - num_of_sources = len(sources) - num_of_src_list = len(src_list) - num_of_sensors = len(sensors) - - # tile up paths ------------------------------------------------------------- - # all obj paths that are shorter than max-length are filled up with the last - # postion/orientation of the object (static paths) - path_lengths = [len(obj._position) for obj in obj_list] - max_path_len = max(path_lengths) - - # objects to tile up and reset below - mask_reset = [max_path_len != pl for pl in path_lengths] - reset_obj = [obj for obj, mask in zip(obj_list, mask_reset) if mask] - reset_obj_m0 = [pl for pl, mask in zip(path_lengths, mask_reset) if mask] - - if max_path_len > 1: - for obj, m0 in zip(reset_obj, reset_obj_m0): - # length to be tiled - m_tile = max_path_len - m0 - # tile up position - tile_pos = np.tile(obj._position[-1], (m_tile, 1)) - obj._position = np.concatenate((obj._position, tile_pos)) - # tile up orientation - tile_orient = np.tile(obj._orientation.as_quat()[-1], (m_tile, 1)) - # FUTURE use Rotation.concatenate() requires scipy>=1.8 and python 3.8 - tile_orient = np.concatenate((obj._orientation.as_quat(), tile_orient)) - obj._orientation = R.from_quat(tile_orient) - - # combine information form all sensors to generate pos_obs with------------- - # shape (m * concat all sens flat pixel, 3) - # allows sensors with different pixel shapes <- relevant? - poso = [ - [ - r.apply(sens.pixel.reshape(-1, 3)) + p - for r, p in zip(sens._orientation, sens._position) - ] - for sens in sensors - ] - poso = np.concatenate(poso, axis=1).reshape(-1, 3) - n_pp = len(poso) - n_pix = int(n_pp / max_path_len) - - # group similar source types---------------------------------------------- - groups = {} - for ind, src in enumerate(src_list): - if src._object_type == "CustomSource": - group_key = src.field_func - else: - group_key = src._object_type - if group_key not in groups: - groups[group_key] = { - "sources": [], - "order": [], - "source_type": src._object_type, - } - groups[group_key]["sources"].append(src) - groups[group_key]["order"].append(ind) - - # evaluate each group in one vectorized step ------------------------------- - B = np.empty((num_of_src_list, max_path_len, n_pix, 3)) # allocate B - for group in groups.values(): - lg = len(group["sources"]) - gr = group["sources"] - src_dict = get_src_dict(gr, n_pix, n_pp, poso) # compute array dict for level1 - B_group = getBH_level1(field=field, **src_dict) # compute field - B_group = B_group.reshape( - (lg, max_path_len, n_pix, 3) - ) # reshape (2% slower for large arrays) - for gr_ind in range(lg): # put into dedicated positions in B - B[group["order"][gr_ind]] = B_group[gr_ind] - - # reshape output ---------------------------------------------------------------- - # rearrange B when there is at least one Collection with more than one source - if num_of_src_list > num_of_sources: - for src_ind, src in enumerate(sources): - if src._object_type == "Collection": - col_len = len(format_obj_input(src, allow="sources")) - # set B[i] to sum of slice - B[src_ind] = np.sum(B[src_ind : src_ind + col_len], axis=0) - B = np.delete( - B, np.s_[src_ind + 1 : src_ind + col_len], 0 - ) # delete remaining part of slice - - # apply sensor rotations (after summation over collections to reduce rot.apply operations) - for sens_ind, sens in enumerate(sensors): # cycle through all sensors - if not unrotated_sensors[sens_ind]: # apply operations only to rotated sensors - # select part where rot is applied - Bpart = B[:, :, pix_inds[sens_ind] : pix_inds[sens_ind + 1]] - # change shape to (P,3) for rot package - Bpart_orig_shape = Bpart.shape - Bpart_flat = np.reshape(Bpart, (-1, 3)) - # apply sensor rotation - if static_sensor_rot[sens_ind]: # special case: same rotation along path - sens_orient = sens._orientation[0] - else: - sens_orient = R.from_quat( - np.tile( # tile for each source from list - np.repeat( # same orientation path index for all indices - sens._orientation.as_quat(), pix_nums[sens_ind], axis=0 - ), - (num_of_sources, 1), - ) - ) - Bpart_flat_rot = sens_orient.inv().apply(Bpart_flat) - # overwrite Bpart in B - B[:, :, pix_inds[sens_ind] : pix_inds[sens_ind + 1]] = np.reshape( - Bpart_flat_rot, Bpart_orig_shape - ) - - # rearrange sensor-pixel shape - if pix_all_same: - B = B.reshape((num_of_sources, max_path_len, num_of_sensors, *pix_shapes[0])) - # aggregate pixel values - if pixel_agg is not None: - B = pixel_agg_func(B, axis=tuple(range(3 - B.ndim, -1))) - else: # pixel_agg is not None when pix_all_same, checked with - Bsplit = np.split(B, pix_inds[1:-1], axis=2) - Bagg = [np.expand_dims(pixel_agg_func(b, axis=2), axis=2) for b in Bsplit] - B = np.concatenate(Bagg, axis=2) - - # reset tiled objects - for obj, m0 in zip(reset_obj, reset_obj_m0): - obj._position = obj._position[:m0] - obj._orientation = obj._orientation[:m0] - - # sumup over sources - if sumup: - B = np.sum(B, axis=0, keepdims=True) - - output = check_getBH_output_type(output) - - if output == "dataframe": - # pylint: disable=import-outside-toplevel - import pandas as pd - - if sumup and len(sources) > 1: - src_ids = [f"sumup ({len(sources)})"] - else: - src_ids = [s.style.label if s.style.label else f"{s}" for s in sources] - sens_ids = [s.style.label if s.style.label else f"{s}" for s in sensors] - num_of_pixels = np.prod(pix_shapes[0][:-1]) if pixel_agg is None else 1 - df = pd.DataFrame( - data=product(src_ids, range(max_path_len), sens_ids, range(num_of_pixels)), - columns=["source", "path", "sensor", "pixel"], - ) - df[[field + k for k in "xyz"]] = B.reshape(-1, 3) - return df - - # reduce all size-1 levels - if squeeze: - B = np.squeeze(B) - elif pixel_agg is not None: - # add missing dimension since `pixel_agg` reduces pixel - # dimensions to zero. Only needed if `squeeze is False`` - B = np.expand_dims(B, axis=-2) - - return B - - -def getBH_dict_level2( - source_type, - observers, - *, - field: str, - position=(0, 0, 0), - orientation=R.identity(), - squeeze=True, - **kwargs: dict, -) -> np.ndarray: - """Direct interface access to vectorized computation - - Parameters - ---------- - kwargs: dict that describes the computation. - - Returns - ------- - field: ndarray, shape (N,3), field at obs_pos in [mT] or [kA/m] - - Info - ---- - - check inputs - - - secures input types (list/tuple -> ndarray) - - test if mandatory inputs are there - - sets default input variables (e.g. pos, rot) if missing - - tiles 1D inputs vectors to correct dimension - """ - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - - # generate dict of secured inputs for auto-tiling --------------- - # entries in this dict will be tested for input length, and then - # be automatically tiled up and stored back into kwargs for calling - # getBH_level1(). - # To allow different input dimensions, the tdim argument is also given - # which tells the program which dimension it should tile up. - - if source_type not in LIBRARY_BH_DICT_SOURCE_STRINGS: - raise MagpylibBadUserInput( - f"Input parameter `sources` must be one of {LIBRARY_BH_DICT_SOURCE_STRINGS}" - " when using the direct interface." - ) - - kwargs["observers"] = observers - kwargs["position"] = position - - # change orientation to Rotation numpy array for tiling - kwargs["orientation"] = orientation.as_quat() - - # evaluation vector lengths - vec_lengths = [] - for key, val in kwargs.items(): - try: - val = np.array(val, dtype=float) - except TypeError as err: - raise MagpylibBadUserInput( - f"{key} input must be array-like.\n" f"Instead received {val}" - ) from err - tdim = PARAM_TILE_DIMS.get(key, 1) - if val.ndim == tdim: - vec_lengths.append(len(val)) - kwargs[key] = val - - if len(set(vec_lengths)) > 1: - raise MagpylibBadUserInput( - "Input array lengths must be 1 or of a similar length.\n" - f"Instead received {set(vec_lengths)}" - ) - vec_len = max(vec_lengths, default=1) - - # tile 1D inputs and replace original values in kwargs - for key, val in kwargs.items(): - tdim = PARAM_TILE_DIMS.get(key, 1) - if val.ndim < tdim: - if tdim == 2: - kwargs[key] = np.tile(val, (vec_len, 1)) - elif tdim == 1: - kwargs[key] = np.array([val] * vec_len) - else: - kwargs[key] = val - - # change orientation back to Rotation object - kwargs["orientation"] = R.from_quat(kwargs["orientation"]) - - # compute and return B - B = getBH_level1(source_type=source_type, field=field, **kwargs) - - if squeeze: - return np.squeeze(B) - return B diff --git a/magpylib/_src/fields/field_wrap_BH_level3.py b/magpylib/_src/fields/field_wrap_BH_level3.py deleted file mode 100644 index 3fe29b959..000000000 --- a/magpylib/_src/fields/field_wrap_BH_level3.py +++ /dev/null @@ -1,339 +0,0 @@ -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 - - -def getB( - sources=None, - observers=None, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - **kwargs -): - """Compute B-field in [mT] for given sources and observers. - - Field implementations can be directly accessed (avoiding the object oriented - Magpylib interface) by providing a string input `sources=source_type`, array_like - positions as `observers` input, and all other necessary input parameters (see below) - as kwargs. - - Parameters - ---------- - sources: source and collection objects or 1D list thereof - Sources that generate the magnetic field. Can be a single source (or collection) - or a 1D list of l source and/or collection objects. - - Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, - `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). - - observers: array_like or (list of) `Sensor` objects - Can be array_like positions of shape (n1, n2, ..., 3) where the field - should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list - of such sensor objects (must all have similar pixel shapes). All positions - are given in units of [mm]. - - Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding - positions to observer positions in units of [mm]. - - sumup: bool, default=`False` - If `True`, the fields of all sources are summed up. - - squeeze: bool, default=`True` - If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only - a single sensor or only a single source) are eliminated. - - pixel_agg: str, default=`None` - Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, - which is applied to observer output values, e.g. mean of all sensor pixel outputs. - With this option, observers input with different (pixel) shapes is allowed. - - output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a - `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` - object is returned (the Pandas library must be installed). - - Other Parameters (Direct interface) - ----------------------------------- - position: array_like, shape (3,) or (n,3), default=`(0,0,0)` - Source position(s) in the global coordinates in units of [mm]. - - orientation: scipy `Rotation` object with length 1 or n, default=`None` - Object orientation(s) in the global coordinates. `None` corresponds to - a unit-rotation. - - magnetization: array_like, shape (3,) or (n,3) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! - Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in - the local object coordinates (rotates with object). - - moment: array_like, shape (3) or (n,3), unit [mT*mm^3] - Only source_type == `'Dipole'`! - Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates - (rotates with object). For homogeneous magnets the relation moment=magnetization*volume - holds. - - current: array_like, shape (n,) - Only source_type == `'Loop'` or `'Line'`! - Electrical current in units of [A]. - - dimension: array_like, shape (x,) or (n,x) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! - Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar - as in object oriented interface. - - diameter: array_like, shape (n,) - Only source_type == `'Sphere'` or `'Loop'`! - Diameter of source in units of [mm]. - - segment_start: array_like, shape (n,3) - Only source_type == `'Line'`! - Start positions of line current segments in units of [mm]. - - segment_end: array_like, shape (n,3) - Only source_type == `'Line'`! - End positions of line current segments in units of [mm]. - - Returns - ------- - B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame - B-field at each path position (m) for each sensor (k) and each sensor pixel - position (n1, n2, ...) in units of [mT]. Sensor pixel positions are equivalent - to simple observer positions. Paths of objects that are shorter than m will be - considered as static beyond their end. - - Direct interface: ndarray, shape (n,3) - B-field for every parameter set in units of [mT]. - - Notes - ----- - This function automatically joins all sensor and position inputs together and groups - similar sources for optimal vectorization of the computation. For maximal performance - call this function as little as possible and avoid using it in loops. - - Examples - -------- - In this example we compute the B-field [mT] of a spherical magnet and a current loop - at the observer position (1,1,1) given in units of [mm]: - - >>> import magpylib as magpy - >>> src1 = magpy.current.Loop(current=100, diameter=2) - >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) - >>> B = magpy.getB([src1, src2], (1,1,1)) - >>> print(B) - [[6.23597388e+00 6.23597388e+00 2.66977810e+00] - [8.01875374e-01 8.01875374e-01 1.48029737e-16]] - - We can also use sensor objects as observers input: - - >>> sens1 = magpy.Sensor(position=(1,1,1)) - >>> sens2 = sens1.copy(position=(1,1,-1)) - >>> B = magpy.getB([src1, src2], [sens1, sens2]) - >>> print(B) - [[[ 6.23597388e+00 6.23597388e+00 2.66977810e+00] - [-6.23597388e+00 -6.23597388e+00 2.66977810e+00]] - - [[ 8.01875374e-01 8.01875374e-01 1.48029737e-16] - [-8.01875374e-01 -8.01875374e-01 1.48029737e-16]]] - - Through the direct interface we can compute the same fields for the loop as: - - >>> obs = [(1,1,1), (1,1,-1)] - >>> B = magpy.getB('Loop', obs, current=100, diameter=2) - >>> print(B) - [[ 6.23597388 6.23597388 2.6697781 ] - [-6.23597388 -6.23597388 2.6697781 ]] - - But also for a set of four completely different instances: - - >>> B = magpy.getB( - ... 'Loop', - ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), - ... current=(11, 22, 33, 44), - ... diameter=(1, 2, 3, 4), - ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), - ... ) - >>> print(B) - [[ 0.17111325 0.17111325 0.01705189] - [-0.38852048 -0.38852048 0.49400758] - [ 1.14713551 2.29427102 -0.22065346] - [-2.48213467 -2.48213467 -0.79683487]] - """ - return getBH_level2( - sources, - observers, - sumup=sumup, - squeeze=squeeze, - pixel_agg=pixel_agg, - output=output, - field="B", - **kwargs - ) - - -def getH( - sources=None, - observers=None, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - **kwargs -): - """Compute H-field in [kA/m] for given sources and observers. - - Field implementations can be directly accessed (avoiding the object oriented - Magpylib interface) by providing a string input `sources=source_type`, array_like - positions as `observers` input, and all other necessary input parameters (see below) - as kwargs. - - Parameters - ---------- - sources: source and collection objects or 1D list thereof - Sources that generate the magnetic field. Can be a single source (or collection) - or a 1D list of l source and/or collection objects. - - Direct interface: input must be one of (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, - `'Sphere'`, `'Dipole'`, `'Loop'` or `'Line'`). - - observers: array_like or (list of) `Sensor` objects - Can be array_like positions of shape (n1, n2, ..., 3) where the field - should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list - of such sensor objects (must all have similar pixel shapes). All positions - are given in units of [mm]. - - Direct interface: Input must be array_like with shape (3,) or (n,3) corresponding - positions to observer positions in units of [mm]. - - sumup: bool, default=`False` - If `True`, the fields of all sources are summed up. - - squeeze: bool, default=`True` - If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. only - a single sensor or only a single source) are eliminated. - - pixel_agg: str, default=`None` - Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, - which is applied to observer output values, e.g. mean of all sensor pixel outputs. - With this option, observer inputs with different (pixel) shapes are allowed. - - output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a - `numpy.ndarray` object is returned. If 'dataframe' is chosen, a `pandas.DataFrame` - object is returned (the Pandas library must be installed). - - Other Parameters (Direct interface) - ----------------------------------- - position: array_like, shape (3,) or (n,3), default=`(0,0,0)` - Source position(s) in the global coordinates in units of [mm]. - - orientation: scipy `Rotation` object with length 1 or n, default=`None` - Object orientation(s) in the global coordinates. `None` corresponds to - a unit-rotation. - - magnetization: array_like, shape (3,) or (n,3) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`, `'Sphere'`)! - Magnetization vector(s) (mu0*M, remanence field) in units of [kA/m] given in - the local object coordinates (rotates with object). - - moment: array_like, shape (3) or (n,3), unit [mT*mm^3] - Only source_type == `'Dipole'`! - Magnetic dipole moment(s) in units of [mT*mm^3] given in the local object coordinates - (rotates with object). For homogeneous magnets the relation moment=magnetization*volume - holds. - - current: array_like, shape (n,) - Only source_type == `'Loop'` or `'Line'`! - Electrical current in units of [A]. - - dimension: array_like, shape (x,) or (n,x) - Only source_type in (`'Cuboid'`, `'Cylinder'`, `'CylinderSegment'`)! - Magnet dimension input in units of [mm] and [deg]. Dimension format x of sources is similar - as in object oriented interface. - - diameter: array_like, shape (n,) - Only source_type == `'Sphere'` or `'Loop'`! - Diameter of source in units of [mm]. - - segment_start: array_like, shape (n,3) - Only source_type == `'Line'`! - Start positions of line current segments in units of [mm]. - - segment_end: array_like, shape (n,3) - Only source_type == `'Line'`! - End positions of line current segments in units of [mm]. - - Returns - ------- - H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame - H-field at each path position (m) for each sensor (k) and each sensor pixel - position (n1, n2, ...) in units of [kA/m]. Sensor pixel positions are equivalent - to simple observer positions. Paths of objects that are shorter than m will be - considered as static beyond their end. - - Direct interface: ndarray, shape (n,3) - H-field for every parameter set in units of [kA/m]. - - Notes - ----- - This function automatically joins all sensor and position inputs together and groups - similar sources for optimal vectorization of the computation. For maximal performance - call this function as little as possible and avoid using it in loops. - - Examples - -------- - In this example we compute the H-field [kA/m] of a spherical magnet and a current loop - at the observer position (1,1,1) given in units of [mm]: - - >>> import magpylib as magpy - >>> src1 = magpy.current.Loop(current=100, diameter=2) - >>> src2 = magpy.magnet.Sphere(magnetization=(0,0,100), diameter=1) - >>> H = magpy.getH([src1, src2], (1,1,1)) - >>> print(H) - [[4.96243034e+00 4.96243034e+00 2.12454191e+00] - [6.38112147e-01 6.38112147e-01 1.17798322e-16]] - - We can also use sensor objects as observers input: - - >>> sens1 = magpy.Sensor(position=(1,1,1)) - >>> sens2 = sens1.copy(position=(1,1,-1)) - >>> H = magpy.getH([src1, src2], [sens1, sens2]) - >>> print(H) - [[[ 4.96243034e+00 4.96243034e+00 2.12454191e+00] - [-4.96243034e+00 -4.96243034e+00 2.12454191e+00]] - - [[ 6.38112147e-01 6.38112147e-01 1.17798322e-16] - [-6.38112147e-01 -6.38112147e-01 1.17798322e-16]]] - - Through the direct interface we can compute the same fields for the loop as: - - >>> obs = [(1,1,1), (1,1,-1)] - >>> H = magpy.getH('Loop', obs, current=100, diameter=2) - >>> print(H) - [[ 4.96243034 4.96243034 2.12454191] - [-4.96243034 -4.96243034 2.12454191]] - - But also for a set of four completely different instances: - - >>> H = magpy.getH( - ... 'Loop', - ... observers=((1,1,1), (1,1,-1), (1,2,3), (2,2,2)), - ... current=(11, 22, 33, 44), - ... diameter=(1, 2, 3, 4), - ... position=((0,0,0), (0,0,1), (0,0,2), (0,0,3)), - ... ) - >>> print(H) - [[ 0.1361676 0.1361676 0.01356947] - [-0.30917477 -0.30917477 0.39311875] - [ 0.91286143 1.82572286 -0.17559045] - [-1.97522001 -1.97522001 -0.63410104]] - """ - return getBH_level2( - sources, - observers, - sumup=sumup, - squeeze=squeeze, - pixel_agg=pixel_agg, - output=output, - field="H", - **kwargs - ) diff --git a/magpylib/_src/fields/field_wrap_info.txt b/magpylib/_src/fields/field_wrap_info.txt deleted file mode 100644 index 86fe1cc42..000000000 --- a/magpylib/_src/fields/field_wrap_info.txt +++ /dev/null @@ -1,41 +0,0 @@ -Field computation structure: - -level0:(field_BH_XXX.py files) - - pure vectorized field computations from literature - - all computations in source CS - - distinguish B/H - -level1(getBH_level1): - - apply transformation to global CS - - select correct level0 src_type computation - - input dict, no input checks ! - -level2(getBHv_level2): <--- DIRECT ACCESS TO FIELD COMPUTATION FORMULAS, INPUT = DICT OF ARRAYS - - input dict checks (unknowns) - - secure user inputs - - check input for mandatory information - - set missing input variables to default values - - tile 1D inputs - -level2(getBH_level2): <--- COMPUTE FIELDS FROM SOURCES - - input dict checks (unknowns) - - secure user inputs - - group similar sources for combined computation - - generate vector input format for getBH_level1 - - adjust Bfield output format to (pos_obs, path, sources) input format - -level3(getB, getH, getB_dict, getH_dict): <--- USER INTERFACE - - docstrings - - separated B and H - - transform input into dict for level2 - -level4(src.getB, src.getH): <--- USER INTERFACE - - docstrings - - calling level3 getB, getH directly from sources - -level3(getBH_from_sensor): - - adjust output format to (senors, path, sources) input format - -level4(getB_from_sensor, getH_from_sensor): <--- USER INTERFACE - -level5(sens.getB, sens.getH): <--- USER INTERFACE \ No newline at end of file diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index c9fcea191..cca1416cd 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -11,10 +11,10 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibMissingInput from magpylib._src.utility import format_obj_input -from magpylib._src.utility import LIBRARY_SENSORS -from magpylib._src.utility import LIBRARY_SOURCES +from magpylib._src.utility import Registered from magpylib._src.utility import wrong_obj_msg + ################################################################# ################################################################# # FUNDAMENTAL CHECKS @@ -488,30 +488,27 @@ def check_format_input_obj( ) -> list: """ Returns a flat list of all wanted objects in input. - Parameters ---------- input: can be - objects - allow: str Specify which object types are wanted, separate by +, e.g. sensors+collections+sources - recursive: bool Flatten Collection objects """ # select wanted wanted_types = [] if "sources" in allow.split("+"): - wanted_types += list(LIBRARY_SOURCES) + wanted_types += list(Registered.sources) if "sensors" in allow.split("+"): - wanted_types += list(LIBRARY_SENSORS) + wanted_types += list(Registered.sensors) if "collections" in allow.split("+"): wanted_types += ["Collection"] if typechecks: - all_types = list(LIBRARY_SOURCES) + list(LIBRARY_SENSORS) + ["Collection"] + all_types = list(Registered.sources) + list(Registered.sensors) + ["Collection"] obj_list = [] for obj in inp: diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index c38e00dd9..46972acb0 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -43,7 +43,7 @@ def _get_description(self, exclude=None): params = list(self._property_names_generator()) lines = [f"{self!r}"] for k in list(dict.fromkeys(list(UNITS) + list(params))): - if k in params and k not in exclude: + if not k.startswith("_") and k in params and k not in exclude: unit = UNITS.get(k, None) unit_str = f"{unit}" if unit else "" if k == "position": @@ -70,7 +70,7 @@ def _get_description(self, exclude=None): lines.append(f" • {k}: {val} {unit_str}") return lines - def describe(self, *, exclude=("style",), return_string=False): + def describe(self, *, exclude=("style", "field_func"), return_string=False): """Returns a view of the object properties. Parameters @@ -91,7 +91,7 @@ def describe(self, *, exclude=("style",), return_string=False): return None def _repr_html_(self): - lines = self._get_description(exclude=("style",)) + lines = self._get_description(exclude=("style", "field_func")) return f"""
{'
'.join(lines)}
""" def __repr__(self) -> str: diff --git a/magpylib/_src/obj_classes/class_BaseGetBH.py b/magpylib/_src/obj_classes/class_BaseGetBH.py index 260758fe2..3be60f0b5 100644 --- a/magpylib/_src/obj_classes/class_BaseGetBH.py +++ b/magpylib/_src/obj_classes/class_BaseGetBH.py @@ -1,7 +1,7 @@ """BaseGetBHsimple class code DOCSTRINGS V4 READY """ -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.utility import format_star_input diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index fa11d4cac..18c334f08 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -4,14 +4,13 @@ from magpylib._src.defaults.defaults_utility import validate_style_keys from magpylib._src.exceptions import MagpylibBadUserInput -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_obj from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.utility import format_obj_input -from magpylib._src.utility import LIBRARY_SENSORS -from magpylib._src.utility import LIBRARY_SOURCES from magpylib._src.utility import rec_obj_remover +from magpylib._src.utility import Registered def repr_obj(obj, format="type+id+label"): @@ -76,6 +75,7 @@ def collection_tree_generator( "children", "parent", "style", + "field_func", "sources", "sensors", "collections", @@ -354,10 +354,10 @@ def _update_src_and_sens(self): # pylint: disable=protected-access """updates sources, sensors and collections attributes from children""" self._sources = [ - obj for obj in self._children if obj._object_type in LIBRARY_SOURCES + obj for obj in self._children if obj._object_type in Registered.sources ] self._sensors = [ - obj for obj in self._children if obj._object_type in LIBRARY_SENSORS + obj for obj in self._children if obj._object_type in Registered.sensors ] self._collections = [ obj for obj in self._children if obj._object_type == "Collection" diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 8a8065286..0e02defd1 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -1,13 +1,15 @@ """Sensor class code DOCSTRINGS V4 READY """ -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.utility import format_star_input +from magpylib._src.utility import Registered +@Registered(kind="sensor", family="sensor") class Sensor(BaseGeo, BaseDisplayRepr): """Magnetic field sensor. @@ -85,7 +87,6 @@ def __init__( # instance attributes self.pixel = pixel - self._object_type = "Sensor" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 00aa6c1d4..8254f47f3 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -1,13 +1,26 @@ """Line current class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.input_checks import check_format_input_vertices from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH - - +from magpylib._src.utility import Registered + + +@Registered( + kind="source", + family="current", + field_func=current_vertices_field, + source_kwargs_ndim={ + "current": 1, + "vertices": 3, + "segment_start": 2, + "segment_end": 2, + }, +) class Line(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Current flowing in straight lines from vertex to vertex. @@ -98,7 +111,6 @@ def __init__( # instance attributes self.vertices = vertices - self._object_type = "Line" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index 286c8f94b..ca04b0818 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -1,13 +1,21 @@ """Loop current class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_loop import current_loop_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered( + kind="source", + family="current", + field_func=current_loop_field, + source_kwargs_ndim={"current": 1, "diameter": 1}, +) class Loop(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): """Circular current loop. @@ -92,7 +100,6 @@ def __init__( # instance attributes self.diameter = diameter - self._object_type = "Loop" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_mag_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py similarity index 94% rename from magpylib/_src/obj_classes/class_mag_Cuboid.py rename to magpylib/_src/obj_classes/class_magnet_Cuboid.py index d07a8b20a..35d4b8937 100644 --- a/magpylib/_src/obj_classes/class_mag_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -1,13 +1,21 @@ """Magnet Cuboid class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered( + kind="source", + family="magnet", + field_func=magnet_cuboid_field, + source_kwargs_ndim={"magnetization": 2, "dimension": 2}, +) class Cuboid(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cuboid magnet with homogeneous magnetization. @@ -93,7 +101,6 @@ def __init__( # instance attributes self.dimension = dimension - self._object_type = "Cuboid" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_mag_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py similarity index 93% rename from magpylib/_src/obj_classes/class_mag_Cylinder.py rename to magpylib/_src/obj_classes/class_magnet_Cylinder.py index a2adcbb64..47c268aec 100644 --- a/magpylib/_src/obj_classes/class_mag_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -1,13 +1,21 @@ """Magnet Cylinder class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_cylinder_segment import magnet_cylinder_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered( + kind="source", + family="magnet", + field_func=magnet_cylinder_field, + source_kwargs_ndim={"magnetization": 2, "dimension": 2}, +) class Cylinder(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder magnet with homogeneous magnetization. @@ -93,7 +101,6 @@ def __init__( # instance attributes self.dimension = dimension - self._object_type = "Cylinder" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py similarity index 94% rename from magpylib/_src/obj_classes/class_mag_CylinderSegment.py rename to magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index 9dde2d6a0..d0df39f56 100644 --- a/magpylib/_src/obj_classes/class_mag_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -3,13 +3,23 @@ """ import numpy as np +from magpylib._src.fields.field_BH_cylinder_segment import ( + magnet_cylinder_segment_field_internal, +) from magpylib._src.input_checks import check_format_input_cylinder_segment from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered( + kind="source", + family="magnet", + field_func=magnet_cylinder_segment_field_internal, + source_kwargs_ndim={"magnetization": 2, "dimension": 2}, +) class CylinderSegment(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Cylinder segment (ring-section) magnet with homogeneous magnetization. @@ -100,7 +110,6 @@ def __init__( # instance attributes self.dimension = dimension - self._object_type = "CylinderSegment" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_mag_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py similarity index 93% rename from magpylib/_src/obj_classes/class_mag_Sphere.py rename to magpylib/_src/obj_classes/class_magnet_Sphere.py index 84c9a21d1..47a2a5a5f 100644 --- a/magpylib/_src/obj_classes/class_mag_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -1,13 +1,21 @@ """Magnet Sphere class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered( + kind="source", + family="magnet", + field_func=magnet_sphere_field, + source_kwargs_ndim={"magnetization": 2, "diameter": 1}, +) class Sphere(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): """Spherical magnet with homogeneous magnetization. @@ -93,7 +101,6 @@ def __init__( # instance attributes self.diameter = diameter - self._object_type = "Sphere" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_misc_Custom.py b/magpylib/_src/obj_classes/class_misc_CustomSource.py similarity index 97% rename from magpylib/_src/obj_classes/class_misc_Custom.py rename to magpylib/_src/obj_classes/class_misc_CustomSource.py index e23f9b1e3..89bd3c875 100644 --- a/magpylib/_src/obj_classes/class_misc_Custom.py +++ b/magpylib/_src/obj_classes/class_misc_CustomSource.py @@ -3,8 +3,10 @@ from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered(kind="source", family="misc", field_func=None) class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): """User-defined custom source. @@ -91,7 +93,6 @@ def __init__( ): # instance attributes self.field_func = field_func - self._object_type = "CustomSource" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 397b285f1..5101b08cf 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -1,12 +1,20 @@ """Dipole class code DOCSTRINGS V4 READY """ +from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.utility import Registered +@Registered( + kind="source", + family="dipole", + field_func=dipole_field, + source_kwargs_ndim={"moment": 2}, +) class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH): """Magnetic dipole moment. @@ -87,7 +95,6 @@ def __init__( ): # instance attributes self.moment = moment - self._object_type = "Dipole" # init inheritance BaseGeo.__init__(self, position, orientation, style=style, **kwargs) diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 6a46198a2..2d0b0cb6f 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -1,27 +1,27 @@ """Collection of classes for display styling.""" # pylint: disable=C0302 # pylint: disable=too-many-instance-attributes +from collections import defaultdict + import numpy as np from magpylib._src.defaults.defaults_utility import color_validator from magpylib._src.defaults.defaults_utility import get_defaults_dict from magpylib._src.defaults.defaults_utility import LINESTYLES_MATPLOTLIB_TO_PLOTLY from magpylib._src.defaults.defaults_utility import MagicProperties -from magpylib._src.defaults.defaults_utility import MAGPYLIB_FAMILIES from magpylib._src.defaults.defaults_utility import SUPPORTED_PLOTTING_BACKENDS from magpylib._src.defaults.defaults_utility import SYMBOLS_MATPLOTLIB_TO_PLOTLY from magpylib._src.defaults.defaults_utility import validate_property_class from magpylib._src.defaults.defaults_utility import validate_style_keys +from magpylib._src.utility import Registered def get_style_class(obj): """Returns style instance based on object type. If object has no attribute `_object_type` or is - not found in `MAGPYLIB_FAMILIES` returns `BaseStyle` instance. + not found in `Registered.famillies` returns `BaseStyle` instance. """ obj_type = getattr(obj, "_object_type", None) - style_fam = MAGPYLIB_FAMILIES.get(obj_type, None) - if isinstance(style_fam, (list, tuple)): - style_fam = style_fam[0] + style_fam = Registered.families.get(obj_type, None) return STYLE_CLASSES.get(style_fam, BaseStyle) @@ -44,17 +44,13 @@ def get_style(obj, default_settings, **kwargs): # construct object specific dictionary base on style family and default style obj_type = getattr(obj, "_object_type", None) - obj_families = MAGPYLIB_FAMILIES.get(obj_type, []) - + obj_family = Registered.families.get(obj_type, None) obj_style_default_dict = { **styles_by_family["base"], - **{ - k: v - for fam in obj_families - for k, v in styles_by_family.get(fam, {}).items() - }, + **dict(styles_by_family.get(obj_family, {}).items()), } style_kwargs = validate_style_keys(style_kwargs) + # create style class instance and update based on precedence obj_style = getattr(obj, "style", None) style = obj_style.copy() if obj_style is not None else BaseStyle() @@ -1610,9 +1606,13 @@ def markers(self, val): self._markers = validate_property_class(val, "markers", Markers, self) -STYLE_CLASSES = { - "magnet": MagnetStyle, - "current": CurrentStyle, - "dipole": DipoleStyle, - "sensor": SensorStyle, -} +STYLE_CLASSES = defaultdict(lambda: BaseStyle) +STYLE_CLASSES.update( + { + "magnet": MagnetStyle, + "current": CurrentStyle, + "dipole": DipoleStyle, + "sensor": SensorStyle, + "markers": Markers, + } +) diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index c7031f3ff..eba12d755 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -7,34 +7,73 @@ from magpylib._src.exceptions import MagpylibBadUserInput -LIBRARY_SOURCES = ( - "Cuboid", - "Cylinder", - "CylinderSegment", - "Sphere", - "Dipole", - "Loop", - "Line", - "CustomSource", -) - -LIBRARY_BH_DICT_SOURCE_STRINGS = ( - "Cuboid", - "Cylinder", - "CylinderSegment", - "Sphere", - "Dipole", - "Loop", - "Line", -) - -LIBRARY_SENSORS = ("Sensor",) - -ALLOWED_SOURCE_MSG = f"""Sources must be either -- one of type {LIBRARY_SOURCES} + +class Registered: + """Class decorator to register sources or sensors + - Sources get their field function assigned""" + + sensors = {} + sources = {} + families = {} + source_kwargs_ndim = {} + + def __init__(self, *, kind, family, field_func=None, source_kwargs_ndim=None): + self.kind = kind + self.family = family + self.field_func = field_func + self.source_kwargs_ndim_new = ( + {} if source_kwargs_ndim is None else source_kwargs_ndim + ) + + def __call__(self, klass): + name = klass.__name__ + setattr(klass, "_object_type", name) + setattr(klass, "_family", self.family) + setattr( + klass, + "family", + property( + lambda self: getattr(self, "_family"), + doc="""The object family (e.g. 'magnet', 'current', 'misc')""", + ), + ) + self.families[name] = self.family + + if self.kind == "sensor": + self.sensors[name] = klass + + elif self.kind == "source": + if self.field_func is None: + setattr(klass, "_field_func", None) + else: + setattr(klass, "_field_func", staticmethod(self.field_func)) + setattr( + klass, + "field_func", + property( + lambda self: getattr(self, "_field_func"), + doc="""The core function for B- and H-field computation""", + ), + ) + self.sources[name] = klass + if name not in self.source_kwargs_ndim: + self.source_kwargs_ndim[name] = { + "position": 2, + "orientation": 2, + "observers": 2, + } + self.source_kwargs_ndim[name].update(self.source_kwargs_ndim_new) + return klass + + +def get_allowed_sources_msg(): + "Return allowed source message" + return f"""Sources must be either +- one of type {list(Registered.sources)} - Collection with at least one of the above - 1D list of the above -- string {LIBRARY_BH_DICT_SOURCE_STRINGS}""" +- string {list(Registered.sources)}""" + ALLOWED_OBSERVER_MSG = """Observers must be either - array_like positions of shape (N1, N2, ..., 3) @@ -55,7 +94,7 @@ def wrong_obj_msg(*objs, allow="sources"): prefix = "No" if len(allowed) == 1 else "Bad" msg = f"{prefix} {'/'.join(allowed)} provided" if "sources" in allowed: - msg += "\n" + ALLOWED_SOURCE_MSG + msg += "\n" + get_allowed_sources_msg() if "observers" in allowed: msg += "\n" + ALLOWED_OBSERVER_MSG if "sensors" in allowed: @@ -78,13 +117,10 @@ def format_star_input(inp): def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> list: """tests and flattens potential input sources (sources, Collections, sequences) - ### Args: - sources (sequence): input sources - ### Returns: - list: flattened, ordered list of sources - ### Info: - exits if invalid sources are given """ @@ -94,8 +130,8 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> flatten_collection = not "collections" in allow.split("+") for obj in objects: try: - if getattr(obj, "_object_type", None) in list(LIBRARY_SOURCES) + list( - LIBRARY_SENSORS + if getattr(obj, "_object_type", None) in list(Registered.sources) + list( + Registered.sensors ): obj_list += [obj] else: @@ -117,14 +153,11 @@ def format_src_inputs(sources) -> list: """ - input: allow only bare src objects or 1D lists/tuple of src and col - out: sources, src_list - ### Args: - sources - ### Returns: - sources: ordered list of sources - src_list: ordered list of sources with flattened collections - ### Info: - raises an error if sources format is bad """ @@ -147,7 +180,7 @@ def format_src_inputs(sources) -> list: if not child_sources: raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources")) src_list += child_sources - elif obj_type in LIBRARY_SOURCES: + elif obj_type in list(Registered.sources): src_list += [src] else: raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources")) @@ -172,10 +205,8 @@ def check_static_sensor_orient(sensors): def check_duplicates(obj_list: Sequence) -> list: """checks for and eliminates source duplicates in a list of sources - ### Args: - obj_list (list): list with source objects - ### Returns: - list: obj_list with duplicates removed """ @@ -193,11 +224,9 @@ def check_duplicates(obj_list: Sequence) -> list: def test_path_format(inp): """check if each object path has same length of obj.pos and obj.rot - Parameters ---------- inp: single BaseGeo or list of BaseGeo objects - Returns ------- no return @@ -220,9 +249,9 @@ def filter_objects(obj_list, allow="sources+sensors", warn=True): allowed_list = [] for allowed in allow.split("+"): if allowed == "sources": - allowed_list.extend(LIBRARY_SOURCES) + allowed_list.extend(list(Registered.sources)) elif allowed == "sensors": - allowed_list.extend(LIBRARY_SENSORS) + allowed_list.extend(list(Registered.sensors)) elif allowed == "collections": allowed_list.extend(["Collection"]) new_list = [] @@ -260,7 +289,6 @@ def unit_prefix(number, unit="", precision=3, char_between="") -> str: """ displays a number with given unit and precision and uses unit prefixes for the exponents from yotta (y) to Yocto (Y). If the exponent is smaller or bigger, falls back to scientific notation. - Parameters ---------- number : int, float @@ -272,7 +300,6 @@ def unit_prefix(number, unit="", precision=3, char_between="") -> str: char_between : str, optional character to insert between number of prefix. Can be " " or any string, if a space is wanted before the unit symbol , by default "" - Returns ------- str diff --git a/magpylib/magnet/__init__.py b/magpylib/magnet/__init__.py index 32a595551..bfa9dc7a7 100644 --- a/magpylib/magnet/__init__.py +++ b/magpylib/magnet/__init__.py @@ -5,7 +5,7 @@ __all__ = ["Cuboid", "Cylinder", "Sphere", "CylinderSegment"] -from magpylib._src.obj_classes.class_mag_Cuboid import Cuboid -from magpylib._src.obj_classes.class_mag_Cylinder import Cylinder -from magpylib._src.obj_classes.class_mag_Sphere import Sphere -from magpylib._src.obj_classes.class_mag_CylinderSegment import CylinderSegment +from magpylib._src.obj_classes.class_magnet_Cuboid import Cuboid +from magpylib._src.obj_classes.class_magnet_Cylinder import Cylinder +from magpylib._src.obj_classes.class_magnet_Sphere import Sphere +from magpylib._src.obj_classes.class_magnet_CylinderSegment import CylinderSegment diff --git a/magpylib/misc/__init__.py b/magpylib/misc/__init__.py index 4e336393e..2ab07f329 100644 --- a/magpylib/misc/__init__.py +++ b/magpylib/misc/__init__.py @@ -5,4 +5,4 @@ __all__ = ["Dipole", "CustomSource"] from magpylib._src.obj_classes.class_misc_Dipole import Dipole -from magpylib._src.obj_classes.class_misc_Custom import CustomSource +from magpylib._src.obj_classes.class_misc_CustomSource import CustomSource diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index e17460ad8..30a341d5b 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -3,7 +3,6 @@ import pytest import magpylib as magpy -from magpylib._src.display.traces_generic import get_generic_traces from magpylib._src.exceptions import MagpylibBadUserInput from magpylib.magnet import Cuboid from magpylib.magnet import Cylinder diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 0b7f3e428..3ba3889d6 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,18 +1,18 @@ import unittest import numpy as np -from scipy.spatial.transform import Rotation as R import magpylib as magpy from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibInternalError -from magpylib._src.fields.field_wrap_BH_level1 import getBH_level1 -from magpylib._src.fields.field_wrap_BH_level2 import getBH_level2 +from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_observers from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs from magpylib._src.utility import test_path_format as tpf +GETBH_KWARGS = {"sumup": False, "squeeze": True, "pixel_agg": None, "output": "ndarray"} + def getBHv_unknown_source_type(): """unknown source type""" @@ -22,26 +22,8 @@ def getBHv_unknown_source_type(): magnetization=(1, 0, 0), dimension=(0, 2, 1, 0, 360), position=(0, 0, -0.5), - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", field="B", - ) - - -def getBH_level1_internal_error(): - """bad source_type input should not happen""" - x = np.array([(1, 2, 3)]) - rot = R.from_quat((0, 0, 0, 1)) - getBH_level1( - field="B", - source_type="woot", - magnetization=x, - dimension=x, - observers=x, - position=x, - orientation=rot, + **GETBH_KWARGS ) @@ -75,7 +57,7 @@ def getBH_level2_internal_error1(): # pylint: disable=protected-access sens = magpy.Sensor() x = np.zeros((10, 3)) - magpy._src.fields.field_wrap_BH_level2.get_src_dict([sens], 10, 10, x) + magpy._src.fields.field_wrap_BH.get_src_dict([sens], 10, 10, x) # getBHv missing inputs ------------------------------------------------------ @@ -83,74 +65,35 @@ def getBHv_missing_input1(): """missing field""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cuboid", - observers=x, - magnetization=x, - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cuboid", observers=x, magnetization=x, dimension=x, **GETBH_KWARGS ) def getBHv_missing_input2(): """missing source_type""" x = np.array([(1, 2, 3)]) - getBH_level2( - observers=x, - field="B", - magnetization=x, - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - ) + getBH_level2(observers=x, field="B", magnetization=x, dimension=x, **GETBH_KWARGS) def getBHv_missing_input3(): """missing observer""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cuboid", - field="B", - magnetization=x, - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cuboid", field="B", magnetization=x, dimension=x, **GETBH_KWARGS ) def getBHv_missing_input4_cuboid(): """missing Cuboid mag""" x = np.array([(1, 2, 3)]) - getBH_level2( - sources="Cuboid", - observers=x, - field="B", - dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - ) + getBH_level2(sources="Cuboid", observers=x, field="B", dimension=x, **GETBH_KWARGS) def getBHv_missing_input5_cuboid(): """missing Cuboid dim""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cuboid", - observers=x, - field="B", - magnetization=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cuboid", observers=x, field="B", magnetization=x, **GETBH_KWARGS ) @@ -159,14 +102,7 @@ def getBHv_missing_input4_cyl(): x = np.array([(1, 2, 3)]) y = np.array([(1, 2)]) getBH_level2( - sources="Cylinder", - observers=x, - field="B", - dimension=y, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cylinder", observers=x, field="B", dimension=y, **GETBH_KWARGS ) @@ -174,44 +110,21 @@ def getBHv_missing_input5_cyl(): """missing Cylinder dim""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Cylinder", - observers=x, - field="B", - magnetization=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Cylinder", observers=x, field="B", magnetization=x, **GETBH_KWARGS ) def getBHv_missing_input4_sphere(): """missing Sphere mag""" x = np.array([(1, 2, 3)]) - getBH_level2( - sources="Sphere", - observers=x, - field="B", - dimension=1, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", - ) + getBH_level2(sources="Sphere", observers=x, field="B", dimension=1, **GETBH_KWARGS) def getBHv_missing_input5_sphere(): """missing Sphere dim""" x = np.array([(1, 2, 3)]) getBH_level2( - sources="Sphere", - observers=x, - field="B", - magnetization=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + sources="Sphere", observers=x, field="B", magnetization=x, **GETBH_KWARGS ) @@ -226,10 +139,7 @@ def getBHv_bad_input1(): field="B", magnetization=x2, dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + **GETBH_KWARGS ) @@ -242,10 +152,7 @@ def getBHv_bad_input2(): field="B", magnetization=x, dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + **GETBH_KWARGS ) @@ -259,10 +166,7 @@ def getBHv_bad_input3(): field="B", magnetization=x, dimension=x, - sumup=False, - squeeze=True, - pixel_agg=None, - output="ndarray", + **GETBH_KWARGS ) @@ -379,10 +283,6 @@ def test_except_getBHv(self): self.assertRaises(MagpylibBadUserInput, getBHv_bad_input3) self.assertRaises(MagpylibBadUserInput, getBHv_unknown_source_type) - def test_except_getBH_lev1(self): - """getBH_level1 exception testing""" - self.assertRaises(MagpylibInternalError, getBH_level1_internal_error) - def test_except_getBH_lev2(self): """getBH_level2 exception testing""" self.assertRaises(MagpylibBadUserInput, getBH_level2_bad_input1) diff --git a/tests/test_field_functions.py b/tests/test_field_functions.py index fd0d0fc9c..21af4dcdf 100644 --- a/tests/test_field_functions.py +++ b/tests/test_field_functions.py @@ -298,26 +298,29 @@ def test_field_line(): def test_field_line_from_vert(): """test the Line field from vertex input""" - p = np.array([(1, 2, 2), (1, 2, 3), (-1, 0, -3)]) - curr = np.array([1, 5, -3]) + observers = np.array([(1, 2, 2), (1, 2, 3), (-1, 0, -3)]) + current = np.array([1, 5, -3]) - vert1 = np.array( - [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 3, 3), (1, 2, 3), (-3, 4, -5)] + vertices = np.array( + [ + np.array( + [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 3, 3), (1, 2, 3), (-3, 4, -5)] + ), + np.array([(0, 0, 0), (3, 3, 3), (-3, 4, -5)]), + np.array([(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)]), + ], + dtype="object", ) - vert2 = np.array([(0, 0, 0), (3, 3, 3), (-3, 4, -5)]) - vert3 = np.array([(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)]) - pos_tiled = np.tile(p, (3, 1)) - B_vert = current_vertices_field("B", pos_tiled, curr, [vert1, vert2, vert3]) + B_vert = current_vertices_field("B", observers, current, vertices) B = [] - for i, vert in enumerate([vert1, vert2, vert3]): - for pos in p: - p1 = vert[:-1] - p2 = vert[1:] - po = np.array([pos] * (len(vert) - 1)) - cu = np.array([curr[i]] * (len(vert) - 1)) - B += [np.sum(current_line_field("B", po, cu, p1, p2), axis=0)] + for obs, vert, curr in zip(observers, vertices, current): + p1 = vert[:-1] + p2 = vert[1:] + po = np.array([obs] * (len(vert) - 1)) + cu = np.array([curr] * (len(vert) - 1)) + B += [np.sum(current_line_field("B", po, cu, p1, p2), axis=0)] B = np.array(B) assert_allclose(B_vert, B) diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index a0d57b69d..40b75206a 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -417,8 +417,8 @@ def test_describe(): test = ( "
Cuboid(id=REGEX, label='x1')
• parent: None
• " - + "position: [0. 0. 0.] mm
• orientation: [0. 0. 0.] degrees
• " - + "dimension: None mm
• magnetization: None mT
" + "position: [0. 0. 0.] mm
• orientation: [0. 0. 0.] degrees
• " + "dimension: None mm
• magnetization: None mT
• family: magnet " ) rep = x1._repr_html_() rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep) @@ -432,6 +432,7 @@ def test_describe(): " • orientation: [0. 0. 0.] degrees", " • dimension: None mm", " • magnetization: None mT", + " • family: magnet ", # INVISIBLE SPACE ] desc = x1.describe(return_string=True) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) @@ -444,6 +445,7 @@ def test_describe(): " • orientation: [0. 0. 0.] degrees", " • dimension: [1. 3.] mm", " • magnetization: [2. 3. 4.] mT", + " • family: magnet ", # INVISIBLE SPACE ] desc = x2.describe(return_string=True) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) @@ -455,6 +457,7 @@ def test_describe(): " • path length: 3", " • position (last): [1. 2. 3.] mm", " • orientation (last): [0. 0. 0.] degrees", + " • family: sensor ", # INVISIBLE SPACE " • pixel: 15 ", # INVISIBLE SPACE ] desc = s1.describe(return_string=True) @@ -469,6 +472,7 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" + + " • family: sensor \n" + " • pixel: 1 \n" + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," @@ -489,6 +493,7 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" + + " • family: sensor \n" + " • pixel: 1 \n" + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," @@ -509,6 +514,7 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" + + " • family: sensor \n" + " • pixel: 75 (3x5x5) " ) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) diff --git a/tests/test_obj_Collection.py b/tests/test_obj_Collection.py index 94fd5442d..293755100 100644 --- a/tests/test_obj_Collection.py +++ b/tests/test_obj_Collection.py @@ -417,11 +417,13 @@ def test_collection_describe(): "│ • orientation: [0. 0. 0.] degrees", "│ • dimension: None mm", "│ • magnetization: None mT", + "│ • family: magnet", "└── y", " • position: [0. 0. 0.] mm", " • orientation: [0. 0. 0.] degrees", " • dimension: None mm", " • magnetization: None mT", + " • family: magnet", ] assert "".join(test) == re.sub("id=*[0-9]*[0-9]", "id=REGEX", "".join(desc)) From 680906912c85546e7394d3fa6b3ab6147acb16c8 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 16 Jul 2022 01:04:46 +0200 Subject: [PATCH 188/207] pylint --- magpylib/_src/fields/field_wrap_BH.py | 1 + 1 file changed, 1 insertion(+) diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index 1feab8b96..8027a7366 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -436,6 +436,7 @@ def getBH_dict_level2( - tiles 1D inputs vectors to correct dimension """ # pylint: disable=protected-access + # pylint: disable=too-many-branches # generate dict of secured inputs for auto-tiling --------------- # entries in this dict will be tested for input length, and then From af6a813c1f2374347252c0c50af604c89a515671 Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 19 Jul 2022 14:57:08 +0200 Subject: [PATCH 189/207] fix getB dict Line with "scalar" vertices --- magpylib/_src/fields/field_wrap_BH.py | 3 ++- tests/test_getBH_dict.py | 11 +++++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index 8027a7366..3eb4eae07 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -500,11 +500,12 @@ def getBH_dict_level2( kwargs[key] = np.array( [np.tile(v, (vec_len, 1)) for v in val], dtype="object" ) + elif expected_dim == 3: + kwargs[key] = np.tile(val, (vec_len, 1, 1)) else: kwargs[key] = np.tile(val, (vec_len, 1)) else: kwargs[key] = val - # change orientation back to Rotation object kwargs["orientation"] = R.from_quat(kwargs["orientation"]) diff --git a/tests/test_getBH_dict.py b/tests/test_getBH_dict.py index 6ed4da6e8..8bdc5b0d5 100644 --- a/tests/test_getBH_dict.py +++ b/tests/test_getBH_dict.py @@ -240,6 +240,17 @@ def test_getBHv_line2(): ) assert np.allclose(B5, np.array([0, -x, 0])) + # "scalar" vertices tiling + B = getB( + "Line", + observers=[(0, 0, 0)] * 5, + current=1, + vertices=np.linspace((0, 5, 5), (5, 5, 5), 6), + ) + np.testing.assert_allclose( + B, np.array([[0.0, 0.0057735, -0.0057735]] * 5), rtol=1e-6 + ) + def test_BHv_Cylinder_FEM(): """test against FEM""" From fdc45854aaaf0890a5aaad34e8d21925a6cf0f79 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Jul 2022 16:38:30 +0200 Subject: [PATCH 190/207] fix mag show bug --- magpylib/_src/display/traces_generic.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index 477c2d32d..f540d6bcd 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -710,7 +710,8 @@ def get_generic_traces( traces.append(scatter_path) if mag_arrows and getattr(input_obj, "magnetization", None) is not None: - traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) + if style.magnetization.show: + traces.append(make_mag_arrows(input_obj, style, legendgroup, kwargs)) out = (traces,) if extra_backend is not False: out += (path_traces_extra_specific_backend,) From d8a0a9542663180c511cf6c4e824df15bb87ce5a Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Jul 2022 16:50:00 +0200 Subject: [PATCH 191/207] update compound example with generic extra trace --- docs/examples/examples_21_compound.md | 29 ++++++++++----------------- 1 file changed, 11 insertions(+), 18 deletions(-) diff --git a/docs/examples/examples_21_compound.md b/docs/examples/examples_21_compound.md index 011884c34..4f1053f72 100644 --- a/docs/examples/examples_21_compound.md +++ b/docs/examples/examples_21_compound.md @@ -6,7 +6,7 @@ jupytext: format_version: 0.13 jupytext_version: 1.13.7 kernelspec: - display_name: Python 3 (ipykernel) + display_name: Python 3 language: python name: python3 --- @@ -52,7 +52,7 @@ class MagnetRing(magpy.Collection): """updates the MagnetRing instance""" self._cubes = cubes ring_radius = cubes/3 - + # construct in temporary Collection for path transfer temp_coll = magpy.Collection() for i in range(cubes): @@ -71,20 +71,18 @@ class MagnetRing(magpy.Collection): # add parameter-dependent 3d trace self.style.model3d.data = [] - self.style.model3d.add_trace(self._custom_trace3d('plotly')) - self.style.model3d.add_trace(self._custom_trace3d('matplotlib')) + self.style.model3d.add_trace(self._custom_trace3d()) return self - def _custom_trace3d(self, backend): + def _custom_trace3d(self): """ creates a parameter-dependent 3d model""" r1 = self.cubes/3 - .6 r2 = self.cubes/3 + 0.6 trace = magpy.graphics.model3d.make_CylinderSegment( - backend=backend, dimension=(r1, r2, 1.1, 0, 360), vert=150, - **{('opacity' if backend=='plotly' else 'alpha') :0.5} + opacity=0.5, ) return trace ``` @@ -125,7 +123,6 @@ Custom traces can be computationally costly to construct. In the above example, To make our compounds ready for heavy computation, it is possible to provide a callable as a trace, which will only be constructed when `show` is called. The following modification of the above example demonstrates this: ```{code-cell} ipython3 -from functools import partial import magpylib as magpy import numpy as np @@ -143,8 +140,7 @@ class MagnetRingAdv(magpy.Collection): self._update(cubes) # hand trace over as callable - self.style.model3d.add_trace(partial(self._custom_trace3d, 'plotly')) - self.style.model3d.add_trace(partial(self._custom_trace3d, 'matplotlib')) + self.style.model3d.add_trace(self._custom_trace3d) @property def cubes(self): @@ -160,7 +156,7 @@ class MagnetRingAdv(magpy.Collection): """updates the MagnetRing instance""" self._cubes = cubes ring_radius = cubes/3 - + # construct in temporary Collection for path transfer temp_coll = magpy.Collection() for i in range(cubes): @@ -179,15 +175,14 @@ class MagnetRingAdv(magpy.Collection): return self - def _custom_trace3d(self, backend): + def _custom_trace3d(self): """ creates a parameter-dependent 3d model""" r1 = self.cubes/3 - .6 r2 = self.cubes/3 + 0.6 trace = magpy.graphics.model3d.make_CylinderSegment( - backend=backend, dimension=(r1, r2, 1.1, 0, 360), vert=150, - **{('opacity' if backend=='plotly' else 'alpha') :0.5} + opacity=0.5, ) return trace ``` @@ -195,10 +190,10 @@ class MagnetRingAdv(magpy.Collection): All we have done is, to remove the trace construction from the `_update` method, and instead provide `_custom_trace3d` as callable in `__init__` with the help of `partial`. ```{code-cell} ipython3 -ring0 = MagnetRing() +ring0 = MagnetRing() %time for _ in range(100): ring0.cubes=10 -ring1 = MagnetRingAdv() +ring1 = MagnetRingAdv() %time for _ in range(100): ring1.cubes=10 ``` @@ -214,5 +209,3 @@ for i,cub in zip([2,7,12,17,22], [20,16,12,8,4]): magpy.show(rings, animation=2, backend='plotly', style_path_show=False) ``` - - From 2953e0e0e64e5ed1707b19be8f83816cd9629d41 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Jul 2022 18:00:52 +0200 Subject: [PATCH 192/207] remove unused files --- magpylib/_src/display/display_matplotlib.py | 497 ------------------- magpylib/_src/display/display_utility.py | 504 -------------------- 2 files changed, 1001 deletions(-) delete mode 100644 magpylib/_src/display/display_matplotlib.py delete mode 100644 magpylib/_src/display/display_utility.py diff --git a/magpylib/_src/display/display_matplotlib.py b/magpylib/_src/display/display_matplotlib.py deleted file mode 100644 index 049b4aaa6..000000000 --- a/magpylib/_src/display/display_matplotlib.py +++ /dev/null @@ -1,497 +0,0 @@ -""" matplotlib draw-functionalities""" -import matplotlib.pyplot as plt -import numpy as np -from mpl_toolkits.mplot3d.art3d import Poly3DCollection - -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.display.display_utility import draw_arrow_from_vertices -from magpylib._src.display.display_utility import draw_arrowed_circle -from magpylib._src.display.display_utility import faces_cuboid -from magpylib._src.display.display_utility import faces_cylinder -from magpylib._src.display.display_utility import faces_cylinder_segment -from magpylib._src.display.display_utility import faces_sphere -from magpylib._src.display.display_utility import get_flatten_objects_properties -from magpylib._src.display.display_utility import get_rot_pos_from_path -from magpylib._src.display.display_utility import MagpyMarkers -from magpylib._src.display.display_utility import place_and_orient_model3d -from magpylib._src.display.display_utility import system_size -from magpylib._src.input_checks import check_excitations -from magpylib._src.style import get_style - - -def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): - """draw direction of magnetization of faced magnets - - Parameters - ---------- - - faced_objects(list of src objects): with magnetization vector to be drawn - - colors: colors of faced_objects - - ax(Pyplot 3D axis): to draw in - - show_path(bool or int): draw on every position where object is displayed - """ - # pylint: disable=protected-access - # pylint: disable=too-many-branches - points = [] - for col, obj in zip(colors, faced_objects): - - # add src attributes position and orientation depending on show_path - rots, poss, inds = get_rot_pos_from_path(obj, show_path) - - # vector length, color and magnetization - if obj._object_type in ("Cuboid", "Cylinder"): - length = 1.8 * np.amax(obj.dimension) - elif obj._object_type == "CylinderSegment": - length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h - else: - length = 1.8 * obj.diameter # Sphere - mag = obj.magnetization - - # collect all draw positions and directions - draw_pos, draw_direc = [], [] - for rot, pos, ind in zip(rots, poss, inds): - if obj._object_type == "CylinderSegment": - # change cylinder_tile draw_pos to barycenter - pos = obj._barycenter[ind] - draw_pos += [pos] - direc = mag / (np.linalg.norm(mag) + 1e-6) - draw_direc += [rot.apply(direc)] - draw_pos = np.array(draw_pos) - draw_direc = np.array(draw_direc) - - # use quiver() separately for each object to easier control - # color and vector length - ax.quiver( - draw_pos[:, 0], - draw_pos[:, 1], - draw_pos[:, 2], - draw_direc[:, 0], - draw_direc[:, 1], - draw_direc[:, 2], - length=length * size_direction, - color=col, - ) - arrow_tip_pos = ((draw_direc * length * size_direction) + draw_pos)[0] - points.append(arrow_tip_pos) - return points - - -def draw_markers(markers, ax, color, symbol, size): - """draws magpylib markers""" - ax.plot( - markers[:, 0], - markers[:, 1], - markers[:, 2], - color=color, - ls="", - marker=symbol, - ms=size, - ) - - -def draw_path( - obj, col, marker_symbol, marker_size, marker_color, line_style, line_width, ax -): - """draw path in given color and return list of path-points""" - # pylint: disable=protected-access - path = obj._position - if len(path) > 1: - ax.plot( - path[:, 0], - path[:, 1], - path[:, 2], - ls=line_style, - lw=line_width, - color=col, - marker=marker_symbol, - mfc=marker_color, - mec=marker_color, - ms=marker_size, - ) - ax.plot( - [path[0, 0]], [path[0, 1]], [path[0, 2]], marker="o", ms=4, mfc=col, mec="k" - ) - return list(path) - - -def draw_faces(faces, col, lw, alpha, ax): - """draw faces in respective color and return list of vertex-points""" - cuboid_faces = Poly3DCollection( - faces, - facecolors=col, - linewidths=lw, - edgecolors="k", - alpha=alpha, - ) - ax.add_collection3d(cuboid_faces) - return faces - - -def draw_pixel(sensors, ax, col, pixel_col, pixel_size, pixel_symb, show_path): - """draw pixels and return a list of pixel-points in global CS""" - # pylint: disable=protected-access - - # collect sensor and pixel positions in global CS - pos_sens, pos_pixel = [], [] - for sens in sensors: - rots, poss, _ = get_rot_pos_from_path(sens, show_path) - - pos_pixel_flat = np.reshape(sens.pixel, (-1, 3)) - - for rot, pos in zip(rots, poss): - pos_sens += [pos] - - for pix in pos_pixel_flat: - pos_pixel += [pos + rot.apply(pix)] - - pos_all = pos_sens + pos_pixel - pos_pixel = np.array(pos_pixel) - - # display pixel positions - ax.plot( - pos_pixel[:, 0], - pos_pixel[:, 1], - pos_pixel[:, 2], - marker=pixel_symb, - mfc=pixel_col, - mew=pixel_size, - mec=col, - ms=pixel_size * 4, - ls="", - ) - - # return all positions for system size evaluation - return list(pos_all) - - -def draw_sensors(sensors, ax, sys_size, show_path, size, arrows_style): - """draw sensor cross""" - # pylint: disable=protected-access - arrowlength = sys_size * size / Config.display.autosizefactor - - # collect plot data - possis, exs, eys, ezs = [], [], [], [] - for sens in sensors: - rots, poss, _ = get_rot_pos_from_path(sens, show_path) - - for rot, pos in zip(rots, poss): - possis += [pos] - exs += [rot.apply((1, 0, 0))] - eys += [rot.apply((0, 1, 0))] - ezs += [rot.apply((0, 0, 1))] - - possis = np.array(possis) - coords = np.array([exs, eys, ezs]) - - # quiver plot of basis vectors - arrow_colors = ( - arrows_style.x.color, - arrows_style.y.color, - arrows_style.z.color, - ) - arrow_show = (arrows_style.x.show, arrows_style.y.show, arrows_style.z.show) - for acol, ashow, es in zip(arrow_colors, arrow_show, coords): - if ashow: - ax.quiver( - possis[:, 0], - possis[:, 1], - possis[:, 2], - es[:, 0], - es[:, 1], - es[:, 2], - color=acol, - length=arrowlength, - ) - - -def draw_dipoles(dipoles, ax, sys_size, show_path, size, color, pivot): - """draw dipoles""" - # pylint: disable=protected-access - - # collect plot data - possis, moms = [], [] - for dip in dipoles: - rots, poss, _ = get_rot_pos_from_path(dip, show_path) - - mom = dip.moment / np.linalg.norm(dip.moment) - - for rot, pos in zip(rots, poss): - possis += [pos] - moms += [rot.apply(mom)] - - possis = np.array(possis) - moms = np.array(moms) - - # quiver plot of basis vectors - arrowlength = sys_size * size / Config.display.autosizefactor - ax.quiver( - possis[:, 0], - possis[:, 1], - possis[:, 2], - moms[:, 0], - moms[:, 1], - moms[:, 2], - color=color, - length=arrowlength, - pivot=pivot, # {'tail', 'middle', 'tip'}, - ) - - -def draw_circular(circulars, show_path, col, size, width, ax): - """draw circulars and return a list of positions""" - # pylint: disable=protected-access - - # graphical settings - discret = 72 + 1 - lw = width - - draw_pos = [] # line positions - for circ in circulars: - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(circ, show_path) - - # init orientation line positions - vertices = draw_arrowed_circle(circ.current, circ.diameter, size, discret).T - # apply pos and rot, draw, store line positions - for rot, pos in zip(rots, poss): - possis1 = rot.apply(vertices) + pos - ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) - draw_pos += list(possis1) - - return draw_pos - - -def draw_line(lines, show_path, col, size, width, ax) -> list: - """draw lines and return a list of positions""" - # pylint: disable=protected-access - - # graphical settings - lw = width - - draw_pos = [] # line positions - for line in lines: - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(line, show_path) - - # init orientation line positions - if size != 0: - vertices = draw_arrow_from_vertices(line.vertices, line.current, size) - else: - vertices = np.array(line.vertices).T - # apply pos and rot, draw, store line positions - for rot, pos in zip(rots, poss): - possis1 = rot.apply(vertices.T) + pos - ax.plot(possis1[:, 0], possis1[:, 1], possis1[:, 2], color=col, lw=lw) - draw_pos += list(possis1) - - return draw_pos - - -def draw_model3d_extra(obj, style, show_path, ax, color): - """positions, orients and draws extra 3d model including path positions - returns True if at least one the traces is now new default""" - extra_model3d_traces = style.model3d.data if style.model3d.data is not None else [] - points = [] - rots, poss, _ = get_rot_pos_from_path(obj, show_path) - for orient, pos in zip(rots, poss): - for extr in extra_model3d_traces: - if extr.show: - extr.update(extr.updatefunc()) - if extr.backend == "matplotlib": - kwargs = extr.kwargs() if callable(extr.kwargs) else extr.kwargs - args = extr.args() if callable(extr.args) else extr.args - kwargs, args, vertices = place_and_orient_model3d( - model_kwargs=kwargs, - model_args=args, - orientation=orient, - position=pos, - coordsargs=extr.coordsargs, - scale=extr.scale, - return_vertices=True, - return_model_args=True, - ) - points.append(vertices.T) - if "color" not in kwargs or kwargs["color"] is None: - kwargs.update(color=color) - getattr(ax, extr.constructor)(*args, **kwargs) - return points - - -def display_matplotlib( - *obj_list_semi_flat, - axis=None, - markers=None, - zoom=0, - color_sequence=None, - **kwargs, -): - """ - Display objects and paths graphically with the matplotlib backend. - - - axis: matplotlib axis3d object - - markers: list of marker positions - - path: bool / int / list of ints - - zoom: zoom level, 0=tight boundaries - - color_sequence: list of colors for object coloring - """ - # pylint: disable=protected-access - # pylint: disable=too-many-branches - # pylint: disable=too-many-statements - - # apply config default values if None - # create or set plotting axis - if axis is None: - fig = plt.figure(dpi=80, figsize=(8, 8)) - ax = fig.add_subplot(111, projection="3d") - ax.set_box_aspect((1, 1, 1)) - generate_output = True - else: - ax = axis - generate_output = False - - # draw objects and evaluate system size -------------------------------------- - - # draw faced objects and store vertices - points = [] - dipoles = [] - sensors = [] - flat_objs_props = get_flatten_objects_properties( - *obj_list_semi_flat, color_sequence=color_sequence - ) - for obj, props in flat_objs_props.items(): - color = props["color"] - style = get_style(obj, Config, **kwargs) - path_frames = style.path.frames - if path_frames is None: - path_frames = True - obj_color = style.color if style.color is not None else color - lw = 0.25 - faces = None - if obj.style.model3d.data: - pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color) - points += pts - if obj.style.model3d.showdefault: - if obj._object_type == "Cuboid": - lw = 0.5 - faces = faces_cuboid(obj, path_frames) - elif obj._object_type == "Cylinder": - faces = faces_cylinder(obj, path_frames) - elif obj._object_type == "CylinderSegment": - faces = faces_cylinder_segment(obj, path_frames) - elif obj._object_type == "Sphere": - faces = faces_sphere(obj, path_frames) - elif obj._object_type == "Line": - if style.arrow.show: - check_excitations([obj]) - arrow_size = style.arrow.size if style.arrow.show else 0 - arrow_width = style.arrow.width - points += draw_line( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Loop": - if style.arrow.show: - check_excitations([obj]) - arrow_width = style.arrow.width - arrow_size = style.arrow.size if style.arrow.show else 0 - points += draw_circular( - [obj], path_frames, obj_color, arrow_size, arrow_width, ax - ) - elif obj._object_type == "Sensor": - sensors.append((obj, obj_color)) - points += draw_pixel( - [obj], - ax, - obj_color, - style.pixel.color, - style.pixel.size, - style.pixel.symbol, - path_frames, - ) - elif obj._object_type == "Dipole": - dipoles.append((obj, obj_color)) - points += [obj.position] - elif obj._object_type == "CustomSource": - draw_markers( - np.array([obj.position]), ax, obj_color, symbol="*", size=10 - ) - label = ( - obj.style.label - if obj.style.label is not None - else str(type(obj).__name__) - ) - ax.text(*obj.position, label, horizontalalignment="center") - points += [obj.position] - if faces is not None: - alpha = style.opacity - pts = draw_faces(faces, obj_color, lw, alpha, ax) - points += [np.vstack(pts).reshape(-1, 3)] - if style.magnetization.show: - check_excitations([obj]) - pts = draw_directs_faced( - [obj], - [obj_color], - ax, - path_frames, - style.magnetization.size, - ) - points += pts - if style.path.show: - marker, line = style.path.marker, style.path.line - points += draw_path( - obj, - obj_color, - marker.symbol, - marker.size, - marker.color, - line.style, - line.width, - ax, - ) - - # markers ------------------------------------------------------- - if markers is not None and markers: - m = MagpyMarkers() - style = get_style(m, Config, **kwargs) - markers = np.array(markers) - s = style.marker - draw_markers(markers, ax, s.color, s.symbol, s.size) - points += [markers] - - # draw direction arrows (based on src size) ------------------------- - # objects with faces - - # determine system size ----------------------------------------- - limx1, limx0, limy1, limy0, limz1, limz0 = system_size(points) - - # make sure ranges are not null - limits = np.array([[limx0, limx1], [limy0, limy1], [limz0, limz1]]) - limits[np.squeeze(np.diff(limits)) == 0] += np.array([-1, 1]) - sys_size = np.max(np.diff(limits)) - c = limits.mean(axis=1) - m = sys_size.max() / 2 - ranges = np.array([c - m * (1 + zoom), c + m * (1 + zoom)]).T - - # draw all system sized based quantities ------------------------- - - # not optimal for loop if many sensors/dipoles - for sens in sensors: - sensor, color = sens - style = get_style(sensor, Config, **kwargs) - draw_sensors([sensor], ax, sys_size, path_frames, style.size, style.arrows) - for dip in dipoles: - dipole, color = dip - style = get_style(dipole, Config, **kwargs) - draw_dipoles( - [dipole], ax, sys_size, path_frames, style.size, color, style.pivot - ) - - # plot styling -------------------------------------------------- - ax.set( - **{f"{k}label": f"{k} [mm]" for k in "xyz"}, - **{f"{k}lim": r for k, r in zip("xyz", ranges)}, - ) - - # generate output ------------------------------------------------ - if generate_output: - plt.show() diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py deleted file mode 100644 index 8804f5763..000000000 --- a/magpylib/_src/display/display_utility.py +++ /dev/null @@ -1,504 +0,0 @@ -""" Display function codes""" -from itertools import cycle -from typing import Tuple - -import numpy as np -from scipy.spatial.transform import Rotation as RotScipy - -from magpylib._src.defaults.defaults_classes import default_settings as Config -from magpylib._src.style import Markers -from magpylib._src.utility import Registered - - -@Registered(kind="nonmodel", family="markers") -class MagpyMarkers: - """A class that stores markers 3D-coordinates""" - - def __init__(self, *markers): - self.style = Markers() - self.markers = np.array(markers) - - -# pylint: disable=too-many-branches -def place_and_orient_model3d( - model_kwargs, - model_args=None, - orientation=None, - position=None, - coordsargs=None, - scale=1, - return_vertices=False, - return_model_args=False, - **kwargs, -): - """places and orients mesh3d dict""" - if orientation is None and position is None: - return {**model_kwargs, **kwargs} - position = (0.0, 0.0, 0.0) if position is None else position - position = np.array(position, dtype=float) - new_model_dict = {} - if model_args is None: - model_args = () - new_model_args = list(model_args) - if model_args: - if coordsargs is None: # matplotlib default - coordsargs = dict(x="args[0]", y="args[1]", z="args[2]") - vertices = [] - if coordsargs is None: - coordsargs = {"x": "x", "y": "y", "z": "z"} - useargs = False - for k in "xyz": - key = coordsargs[k] - if key.startswith("args"): - useargs = True - ind = int(key[5]) - v = model_args[ind] - else: - if key in model_kwargs: - v = model_kwargs[key] - else: - raise ValueError( - "Rotating/Moving of provided model failed, trace dictionary " - f"has no argument {k!r}, use `coordsargs` to specify the names of the " - "coordinates to be used.\n" - "Matplotlib backends will set up coordsargs automatically if " - "the `args=(xs,ys,zs)` argument is provided." - ) - vertices.append(v) - - vertices = np.array(vertices) - - # sometimes traces come as (n,m,3) shape - vert_shape = vertices.shape - vertices = np.reshape(vertices, (3, -1)) - - vertices = vertices.T - - if orientation is not None: - vertices = orientation.apply(vertices) - new_vertices = (vertices * scale + position).T - new_vertices = np.reshape(new_vertices, vert_shape) - for i, k in enumerate("xyz"): - key = coordsargs[k] - if useargs: - ind = int(key[5]) - new_model_args[ind] = new_vertices[i] - else: - new_model_dict[key] = new_vertices[i] - new_model_kwargs = {**model_kwargs, **new_model_dict, **kwargs} - - out = (new_model_kwargs,) - if return_model_args: - out += (new_model_args,) - if return_vertices: - out += (new_vertices,) - return out[0] if len(out) == 1 else out - - -def draw_arrowed_line(vec, pos, sign=1, arrow_size=1) -> Tuple: - """ - Provides x,y,z coordinates of an arrow drawn in the x-y-plane (z=0), showing up the y-axis and - centered in x,y,z=(0,0,0). The arrow vertices are then turned in the direction of `vec` and - moved to position `pos`. - """ - norm = np.linalg.norm(vec) - nvec = np.array(vec) / norm - yaxis = np.array([0, 1, 0]) - cross = np.cross(nvec, yaxis) - dot = np.dot(nvec, yaxis) - n = np.linalg.norm(cross) - if dot == -1: - sign *= -1 - hy = sign * 0.1 * arrow_size - hx = 0.06 * arrow_size - arrow = ( - np.array( - [ - [0, -0.5, 0], - [0, 0, 0], - [-hx, 0 - hy, 0], - [0, 0, 0], - [hx, 0 - hy, 0], - [0, 0, 0], - [0, 0.5, 0], - ] - ) - * norm - ) - if n != 0: - t = np.arccos(dot) - R = RotScipy.from_rotvec(-t * cross / n) - arrow = R.apply(arrow) - x, y, z = (arrow + pos).T - return x, y, z - - -def draw_arrow_from_vertices(vertices, current, arrow_size): - """returns scatter coordinates of arrows between input vertices""" - vectors = np.diff(vertices, axis=0) - positions = vertices[:-1] + vectors / 2 - vertices = np.concatenate( - [ - draw_arrowed_line(vec, pos, np.sign(current), arrow_size=arrow_size) - for vec, pos in zip(vectors, positions) - ], - axis=1, - ) - - return vertices - - -def draw_arrowed_circle(current, diameter, arrow_size, vert): - """draws an oriented circle with an arrow""" - t = np.linspace(0, 2 * np.pi, vert) - x = np.cos(t) - y = np.sin(t) - if arrow_size != 0: - hy = 0.2 * np.sign(current) * arrow_size - hx = 0.15 * arrow_size - x = np.hstack([x, [1 + hx, 1, 1 - hx]]) - y = np.hstack([y, [-hy, 0, -hy]]) - x = x * diameter / 2 - y = y * diameter / 2 - z = np.zeros(x.shape) - vertices = np.array([x, y, z]) - return vertices - - -def get_rot_pos_from_path(obj, show_path=None): - """ - subsets orientations and positions depending on `show_path` value. - examples: - show_path = [1,2,8], path_len = 6 -> path_indices = [1,2,6] - returns rots[[1,2,6]], poss[[1,2,6]] - """ - # pylint: disable=protected-access - # pylint: disable=invalid-unary-operand-type - if show_path is None: - show_path = True - pos = getattr(obj, "_position", None) - if pos is None: - pos = obj.position - pos = np.array(pos) - orient = getattr(obj, "_orientation", None) - if orient is None: - orient = getattr(obj, "orientation", None) - if orient is None: - orient = RotScipy.from_rotvec([[0, 0, 1]]) - pos = np.array([pos]) if pos.ndim == 1 else pos - path_len = pos.shape[0] - if show_path is True or show_path is False or show_path == 0: - inds = np.array([-1]) - elif isinstance(show_path, int): - inds = np.arange(path_len, dtype=int)[::-show_path] - elif hasattr(show_path, "__iter__") and not isinstance(show_path, str): - inds = np.array(show_path) - inds[inds >= path_len] = path_len - 1 - inds = np.unique(inds) - if inds.size == 0: - inds = np.array([path_len - 1]) - rots = orient[inds] - poss = pos[inds] - return rots, poss, inds - - -def faces_cuboid(src, show_path): - """ - compute vertices and faces of Cuboid input for plotting - takes Cuboid source - returns vert, faces - returns all faces when show_path=all - """ - # pylint: disable=protected-access - a, b, c = src.dimension - vert0 = np.array( - ( - (0, 0, 0), - (a, 0, 0), - (0, b, 0), - (0, 0, c), - (a, b, 0), - (a, 0, c), - (0, b, c), - (a, b, c), - ) - ) - vert0 = vert0 - src.dimension / 2 - - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - faces = [] - for rot, pos in zip(rots, poss): - vert = rot.apply(vert0) + pos - faces += [ - [vert[0], vert[1], vert[4], vert[2]], - [vert[0], vert[1], vert[5], vert[3]], - [vert[0], vert[2], vert[6], vert[3]], - [vert[7], vert[6], vert[2], vert[4]], - [vert[7], vert[6], vert[3], vert[5]], - [vert[7], vert[5], vert[1], vert[4]], - ] - return faces - - -def faces_cylinder(src, show_path): - """ - Compute vertices and faces of Cylinder input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder faces - r, h2 = src.dimension / 2 - hs = np.array([-h2, h2]) - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - faces = [ - np.array( - [ - (r * np.cos(p1), r * np.sin(p1), h2), - (r * np.cos(p1), r * np.sin(p1), -h2), - (r * np.cos(p2), r * np.sin(p2), -h2), - (r * np.cos(p2), r * np.sin(p2), h2), - ] - ) - for p1, p2 in zip(phis, phis2) - ] - faces += [ - np.array([(r * np.cos(phi), r * np.sin(phi), h) for phi in phis]) for h in hs - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_cylinder_segment(src, show_path): - """ - Compute vertices and faces of CylinderSegment for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate cylinder segment faces - r1, r2, h, phi1, phi2 = src.dimension - res_tile = ( - int((phi2 - phi1) / 360 * 2 * res) + 2 - ) # resolution used for tile curved surface - phis = np.linspace(phi1, phi2, res_tile) / 180 * np.pi - phis2 = np.roll(phis, 1) - faces = [ - np.array( - [ # inner curved surface - (r1 * np.cos(p1), r1 * np.sin(p1), h / 2), - (r1 * np.cos(p1), r1 * np.sin(p1), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), -h / 2), - (r1 * np.cos(p2), r1 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # outer curved surface - (r2 * np.cos(p1), r2 * np.sin(p1), h / 2), - (r2 * np.cos(p1), r2 * np.sin(p1), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), -h / 2), - (r2 * np.cos(p2), r2 * np.sin(p2), h / 2), - ] - ) - for p1, p2 in zip(phis[1:], phis2[1:]) - ] - faces += [ - np.array( - [ # sides - (r1 * np.cos(p), r1 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), h / 2), - (r2 * np.cos(p), r2 * np.sin(p), -h / 2), - (r1 * np.cos(p), r1 * np.sin(p), -h / 2), - ] - ) - for p in [phis[0], phis[-1]] - ] - faces += [ - np.array( # top surface - [(r1 * np.cos(p), r1 * np.sin(p), h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), h / 2) for p in phis[::-1]] - ) - ] - faces += [ - np.array( # bottom surface - [(r1 * np.cos(p), r1 * np.sin(p), -h / 2) for p in phis] - + [(r2 * np.cos(p), r2 * np.sin(p), -h / 2) for p in phis[::-1]] - ) - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def faces_sphere(src, show_path): - """ - Compute vertices and faces of Sphere input for plotting. - - Parameters - ---------- - - src (source object) - - show_path (bool or int) - - Returns - ------- - vert, faces (returns all faces when show_path=int) - """ - # pylint: disable=protected-access - res = 15 # surface discretization - - # generate sphere faces - r = src.diameter / 2 - phis = np.linspace(0, 2 * np.pi, res) - phis2 = np.roll(np.linspace(0, 2 * np.pi, res), 1) - ths = np.linspace(0, np.pi, res) - faces = [ - r - * np.array( - [ - (np.cos(p) * np.sin(t1), np.sin(p) * np.sin(t1), np.cos(t1)), - (np.cos(p) * np.sin(t2), np.sin(p) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t2), np.sin(p2) * np.sin(t2), np.cos(t2)), - (np.cos(p2) * np.sin(t1), np.sin(p2) * np.sin(t1), np.cos(t1)), - ] - ) - for p, p2 in zip(phis, phis2) - for t1, t2 in zip(ths[1:-2], ths[2:-1]) - ] - faces += [ - r - * np.array( - [(np.cos(p) * np.sin(th), np.sin(p) * np.sin(th), np.cos(th)) for p in phis] - ) - for th in [ths[1], ths[-2]] - ] - - # add src attributes position and orientation depending on show_path - rots, poss, _ = get_rot_pos_from_path(src, show_path) - - # all faces (incl. along path) adding pos and rot - all_faces = [] - for rot, pos in zip(rots, poss): - for face in faces: - all_faces += [[rot.apply(f) + pos for f in face]] - - return all_faces - - -def system_size(points): - """compute system size for display""" - # determine min/max from all to generate aspect=1 plot - if points: - - # bring (n,m,3) point dimensions (e.g. from plot_surface body) - # to correct (n,3) shape - for i, p in enumerate(points): - if p.ndim == 3: - points[i] = np.reshape(p, (-1, 3)) - - pts = np.vstack(points) - xs = [np.amin(pts[:, 0]), np.amax(pts[:, 0])] - ys = [np.amin(pts[:, 1]), np.amax(pts[:, 1])] - zs = [np.amin(pts[:, 2]), np.amax(pts[:, 2])] - - xsize = xs[1] - xs[0] - ysize = ys[1] - ys[0] - zsize = zs[1] - zs[0] - - xcenter = (xs[1] + xs[0]) / 2 - ycenter = (ys[1] + ys[0]) / 2 - zcenter = (zs[1] + zs[0]) / 2 - - size = max([xsize, ysize, zsize]) - - limx0 = xcenter + size / 2 - limx1 = xcenter - size / 2 - limy0 = ycenter + size / 2 - limy1 = ycenter - size / 2 - limz0 = zcenter + size / 2 - limz1 = zcenter - size / 2 - else: - limx0, limx1, limy0, limy1, limz0, limz1 = -1, 1, -1, 1, -1, 1 - return limx0, limx1, limy0, limy1, limz0, limz1 - - -def get_flatten_objects_properties( - *obj_list_semi_flat, - color_sequence=None, - color_cycle=None, - **parent_props, -): - """returns a flat dict -> (obj: display_props, ...) from nested collections""" - if color_sequence is None: - color_sequence = Config.display.colorsequence - if color_cycle is None: - color_cycle = cycle(color_sequence) - flat_objs = {} - for subobj in obj_list_semi_flat: - isCollection = getattr(subobj, "children", None) is not None - props = {**parent_props} - parent_color = parent_props.get("color", "!!!missing!!!") - if parent_color == "!!!missing!!!": - props["color"] = next(color_cycle) - if parent_props.get("legendgroup", None) is None: - props["legendgroup"] = f"{subobj}" - if parent_props.get("showlegend", None) is None: - props["showlegend"] = True - if parent_props.get("legendtext", None) is None: - legendtext = None - if isCollection: - legendtext = getattr(getattr(subobj, "style", None), "label", None) - legendtext = f"{subobj!r}" if legendtext is None else legendtext - props["legendtext"] = legendtext - flat_objs[subobj] = props - if isCollection: - if subobj.style.color is not None: - flat_objs[subobj]["color"] = subobj.style.color - flat_objs.update( - get_flatten_objects_properties( - *subobj.children, - color_sequence=color_sequence, - color_cycle=color_cycle, - **flat_objs[subobj], - ) - ) - return flat_objs From 089654544129f1af36594d2e835417ec9b79994d Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Jul 2022 22:02:31 +0200 Subject: [PATCH 193/207] coverage --- magpylib/_src/display/traces_utility.py | 33 ++++++------------------- 1 file changed, 8 insertions(+), 25 deletions(-) diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index 0c378f769..5b1b6e974 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -29,7 +29,6 @@ def place_and_orient_model3d( position=None, coordsargs=None, scale=1, - return_vertices=False, return_model_args=False, **kwargs, ): @@ -92,8 +91,6 @@ def place_and_orient_model3d( out = (new_model_kwargs,) if return_model_args: out += (new_model_args,) - if return_vertices: - out += (new_vertices,) return out[0] if len(out) == 1 else out @@ -189,16 +186,8 @@ def get_rot_pos_from_path(obj, show_path=None): # pylint: disable=invalid-unary-operand-type if show_path is None: show_path = True - pos = getattr(obj, "_position", None) - if pos is None: - pos = obj.position - pos = np.array(pos) - orient = getattr(obj, "_orientation", None) - if orient is None: - orient = getattr(obj, "orientation", None) - if orient is None: - orient = RotScipy.from_rotvec([[0, 0, 1]]) - pos = np.array([pos]) if pos.ndim == 1 else pos + pos = obj._position + orient = obj._orientation path_len = pos.shape[0] if show_path is True or show_path is False or show_path == 0: inds = np.array([-1]) @@ -222,8 +211,9 @@ def get_flatten_objects_properties( **parent_props, ): """returns a flat dict -> (obj: display_props, ...) from nested collections""" - if colorsequence is None: - colorsequence = Config.display.colorsequence + colorsequence = ( + Config.display.colorsequence if colorsequence is None else colorsequence + ) if color_cycle is None: color_cycle = cycle(colorsequence) flat_objs = {} @@ -440,10 +430,7 @@ def group_traces(*traces): ) gr = [tr["type"]] for k in common_keys + spec_keys[tr["type"]]: - try: - v = tr.get(k, "") - except AttributeError: - v = getattr(tr, k, "") + v = tr.get(k, "") gr.append(str(v)) gr = "".join(gr) if gr not in mesh_groups: @@ -451,12 +438,8 @@ def group_traces(*traces): mesh_groups[gr].append(tr) traces = [] - for key, gr in mesh_groups.items(): - if key.startswith("mesh3d") or key.startswith("scatter3d"): - tr = [merge_traces(*gr)] - else: - tr = gr - traces.extend(tr) + for group in mesh_groups.values(): + traces.extend([merge_traces(*group)]) return traces From 35bcad5f1b48ba4ac57c9f23905dc858b0b1385a Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Thu, 21 Jul 2022 23:21:12 +0200 Subject: [PATCH 194/207] fix ragged seq tiling for gebhdict line --- magpylib/_src/fields/field_wrap_BH.py | 23 ++++++++-------------- tests/test_exceptions.py | 2 +- tests/test_getBH_dict.py | 28 +++++++++++++++++++++++++++ 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index 3eb4eae07..4c547acb3 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -480,7 +480,11 @@ def getBH_dict_level2( ) from err expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) if val.ndim == expected_dim or ragged_seq[key]: - vec_lengths[key] = len(val) + if len(val) == 1: + val = np.squeeze(val) + else: + vec_lengths[key] = len(val) + kwargs[key] = val if len(set(vec_lengths.values())) > 1: @@ -489,23 +493,12 @@ def getBH_dict_level2( f"Instead received lengths {vec_lengths}" ) vec_len = max(vec_lengths.values(), default=1) - # tile 1D inputs and replace original values in kwargs for key, val in kwargs.items(): expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) - if val.ndim < expected_dim: - if expected_dim == 1: - kwargs[key] = np.array([val] * vec_len) - elif ragged_seq[key]: - kwargs[key] = np.array( - [np.tile(v, (vec_len, 1)) for v in val], dtype="object" - ) - elif expected_dim == 3: - kwargs[key] = np.tile(val, (vec_len, 1, 1)) - else: - kwargs[key] = np.tile(val, (vec_len, 1)) - else: - kwargs[key] = val + if val.ndim < expected_dim and not ragged_seq[key]: + kwargs[key] = np.tile(val, (vec_len, *[1] * (expected_dim - 1))) + # change orientation back to Rotation object kwargs["orientation"] = R.from_quat(kwargs["orientation"]) diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index 6ea7c0deb..cbc843eb8 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -132,7 +132,7 @@ def getBHv_missing_input5_sphere(): # bad inputs ------------------------------------------------------------------- def getBHv_bad_input1(): """different input lengths""" - x = np.array([(1, 2, 3)]) + x = np.array([(1, 2, 3)] * 3) x2 = np.array([(1, 2, 3)] * 2) getBH_level2( sources="Cuboid", diff --git a/tests/test_getBH_dict.py b/tests/test_getBH_dict.py index 8bdc5b0d5..6909b415f 100644 --- a/tests/test_getBH_dict.py +++ b/tests/test_getBH_dict.py @@ -251,6 +251,34 @@ def test_getBHv_line2(): B, np.array([[0.0, 0.0057735, -0.0057735]] * 5), rtol=1e-6 ) + # ragged sequence of vertices + observers = (1, 1, 1) + current = 1 + vertices = [ + [(0, 0, 0), (1, 1, 1), (2, 2, 2), (3, 3, 3), (1, 2, 3), (-3, 4, -5)], + [(0, 0, 0), (3, 3, 3), (-3, 4, -5)], + [(1, 2, 3), (-2, -3, 3), (3, 2, 1), (3, 3, 3)], + ] + B1 = getB( + "Line", + observers=observers, + current=current, + vertices=vertices, + ) + B2 = np.array( + [ + getB( + "Line", + observers=observers, + current=current, + vertices=v, + ) + for v in vertices + ] + ) + + np.testing.assert_allclose(B1, B2) + def test_BHv_Cylinder_FEM(): """test against FEM""" From 69df834cc8a6846ee62e8ef5808fb0c5e9fda92f Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 22 Jul 2022 00:32:15 +0200 Subject: [PATCH 195/207] coverage --- magpylib/_src/display/backend_matplotlib.py | 5 ++- tests/test_display_matplotlib.py | 41 ++++++++++++++------- 2 files changed, 31 insertions(+), 15 deletions(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index ee672156d..f794ab5b1 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -83,7 +83,7 @@ def generic_trace_to_matplotlib(trace): }, } ) - else: + else: # pragma: no cover raise ValueError( f"Trace type {trace['type']!r} cannot be transformed into matplotlib trace" ) @@ -169,7 +169,7 @@ def draw_frame(ind): **{f"{k}lim": r for k, r in zip("xyz", ranges)}, ) - def animate(ind): + def animate(ind): # pragma: no cover plt.cla() draw_frame(ind) return [ax] @@ -187,6 +187,7 @@ def animate(ind): ) out = () if return_fig: + show_canvas = False out += (fig,) if return_animation and len(frames) != 1: out += (anim,) diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py index ca8e258a8..92d975145 100644 --- a/tests/test_display_matplotlib.py +++ b/tests/test_display_matplotlib.py @@ -1,3 +1,4 @@ +import matplotlib import matplotlib.pyplot as plt import matplotlib.tri as mtri import numpy as np @@ -22,20 +23,15 @@ def test_Cylinder_display(): x = src.show(canvas=ax, style_path_frames=15, backend="matplotlib") assert x is None, "path should revert to True" src.move(np.linspace((0.4, 0.4, 0.4), (12, 12, 12), 30), start=-1) - x = src.show( - canvas=ax, style_path_show=False, show_direction=True, backend="matplotlib" - ) + x = src.show(canvas=ax, style_path_show=False, backend="matplotlib") assert x is None, "display test fail" - x = src.show( - canvas=ax, style_path_frames=[], show_direction=True, backend="matplotlib" - ) + x = src.show(canvas=ax, style_path_frames=[], backend="matplotlib") assert x is None, "ind>path_len, should display last position" x = src.show( canvas=ax, style_path_frames=[1, 5, 6], - show_direction=True, backend="matplotlib", ) assert x is None, "should display 1,5,6 position" @@ -49,7 +45,7 @@ def test_CylinderSegment_display(): assert x is None, "path should revert to True" src.move(np.linspace((0.4, 0.4, 0.4), (13.2, 13.2, 13.2), 33), start=-1) - x = src.show(canvas=ax, style_path_show=False, show_direction=True) + x = src.show(canvas=ax, style_path_show=False) assert x is None, "display test fail" @@ -61,7 +57,7 @@ def test_Sphere_display(): assert x is None, "path should revert to True" src.move(np.linspace((0.4, 0.4, 0.4), (8, 8, 8), 20), start=-1) - x = src.show(canvas=ax, style_path_show=False, show_direction=True) + x = src.show(canvas=ax, style_path_show=False) assert x is None, "display test fail" @@ -70,12 +66,12 @@ def test_Cuboid_display(): src = Cuboid((1, 2, 3), (1, 2, 3)) src.move(np.linspace((0.1, 0.1, 0.1), (2, 2, 2), 20), start=-1) plt.ion() - x = src.show(style_path_frames=5, show_direction=True) + x = src.show(style_path_frames=5) plt.close() assert x is None, "display test fail" ax = plt.subplot(projection="3d") - x = src.show(canvas=ax, style_path_show=False, show_direction=True) + x = src.show(canvas=ax, style_path_show=False) assert x is None, "display test fail" @@ -183,7 +179,7 @@ def test_matplotlib_model3d_extra(): constructor="plot_surface", args=(xs, ys, zs), kwargs=dict( - cmap=plt.cm.YlGnBu_r, + cmap=plt.cm.YlGnBu_r, # pylint: disable=no-member ), ) obj2 = magpy.Collection() @@ -202,7 +198,7 @@ def test_matplotlib_model3d_extra(): args=lambda: (xs, ys, zs), # test callable args kwargs=dict( triangles=tri.triangles, - cmap=plt.cm.Spectral, + cmap=plt.cm.Spectral, # pylint: disable=no-member ), ) obj3 = magpy.misc.CustomSource(style_model3d_showdefault=False, position=(3, 0, 0)) @@ -270,3 +266,22 @@ def test_graphics_model_mpl(): c.rotate_from_angax(33, "x", anchor=0) c.style.model3d.add_trace(**make_Cuboid("matplotlib", position=(2, 0, 0))) c.show(canvas=ax, style_path_frames=1, backend="matplotlib") + + +def test_graphics_model_generic_to_mpl(): + """test generic base extra graphics with mpl""" + c = magpy.magnet.Cuboid((0, 1, 0), (1, 1, 1)) + c.move([[i, 0, 0] for i in range(2)]) + model3d = make_Cuboid(position=(2, 0, 0)) + model3d["kwargs"]["facecolor"] = np.array(["blue"] * 12) + c.style.model3d.add_trace(**model3d) + fig = c.show(style_path_frames=1, backend="matplotlib", return_fig=True) + assert isinstance(fig, matplotlib.figure.Figure) + + +def test_mpl_animation(): + """test animation with matplotib""" + c = magpy.magnet.Cuboid((0, 1, 0), (1, 1, 1)) + c.move([[i, 0, 0] for i in range(2)]) + anim = c.show(backend="matplotlib", animation=True, return_animation=True) + assert isinstance(anim, matplotlib.animation.FuncAnimation) From 3f07ccf08f7d6a3beded7739b29a5eb5c1d0ab45 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 22 Jul 2022 11:25:39 +0200 Subject: [PATCH 196/207] coverage --- magpylib/_src/display/traces_generic.py | 9 ++- tests/test_Coumpound_setters.py | 89 ++++++++++++------------- tests/test_display_plotly.py | 7 +- 3 files changed, 51 insertions(+), 54 deletions(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index f540d6bcd..fcffd46f9 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -409,10 +409,9 @@ def make_MagpyMarkers(obj, color=None, style=None, **kwargs): f"marker_{k}": v for k, v in style.marker.as_dict(flatten=True, separator="_").items() } - if marker_kwargs["marker_color"] is None: - marker_kwargs["marker_color"] = ( - style.color if style.color is not None else color - ) + marker_kwargs["marker_color"] = ( + style.marker.color if style.marker.color is not None else color + ) trace = dict( type="scatter3d", x=x, @@ -644,7 +643,7 @@ def get_generic_traces( if "facecolor" in obj_extr_trace: ttype = "mesh3d_facecolor" trace3d["color"] = trace3d.get("color", kwargs["color"]) - else: + else: # pragma: no cover raise ValueError( f"{ttype} is not supported, only 'scatter3d' and 'mesh3d' are" ) diff --git a/tests/test_Coumpound_setters.py b/tests/test_Coumpound_setters.py index 3bacec196..e25088a25 100644 --- a/tests/test_Coumpound_setters.py +++ b/tests/test_Coumpound_setters.py @@ -12,6 +12,45 @@ magpy.defaults.display.backend = "plotly" +# def create_compound_test_data(path=None): +# """creates tests data for compound setters testing""" +# setters = [ +# ("orientation=None", dict(orientation="None")), +# ("shorter position path", dict(position="np.array([[50, 0, 100]] * 2)")), +# ( +# "shorter orientation path", +# dict(orientation="R.from_rotvec([[90,0,0],[0,90,0]], degrees=True)"), +# ), +# ( +# "longer position path", +# dict(position="np.array(np.linspace((280.,0.,0), (280.,0.,300), 8))"), +# ), +# ( +# "longer orientation path", +# dict( +# orientation="R.from_rotvec([[0,90*i,0] for i in range(6)], degrees=True)" +# ), +# ), +# ] +# data = {"test_names": [], "setters_inputs": [], "pos_orient_as_matrix_expected": []} +# for setter in setters: +# tname, kwargs = setter +# coll = create_compound_set(**kwargs) +# pos_orient = get_pos_orient_from_collection(coll) +# data["test_names"].append(tname) +# data["setters_inputs"].append(kwargs) +# data["pos_orient_as_matrix_expected"].append(pos_orient) +# if path is None: +# return data +# np.save(path, data) + + +# def display_compound_test_data(path): +# """display compound test data from file""" +# data = np.load(path, allow_pickle=True).item() +# for kwargs in data["setters_inputs"]: +# create_compound_set(show=True, **kwargs) + def make_wheel(Ncubes=6, height=10, diameter=36, path_len=5, label=None): """creates a basic Collection Compound object with a rotary arrangement of cuboid magnets""" @@ -63,11 +102,11 @@ def create_compound_set(show=False, **kwargs): c2.set_children_styles(path_show=False, opacity=0.1) for k, v in kwargs.items(): setattr(c1, k, eval(v)) - if show: - fig = go.Figure() - magpy.show(c2, c1, style_path_frames=1, canvas=fig) - fig.layout.title = ", ".join(f"c1.{k} = {v}" for k, v in kwargs.items()) - fig.show() + # if show: + # fig = go.Figure() + # magpy.show(c2, c1, style_path_frames=1, canvas=fig) + # fig.layout.title = ", ".join(f"c1.{k} = {v}" for k, v in kwargs.items()) + # fig.show() return c1 @@ -80,46 +119,6 @@ def get_pos_orient_from_collection(coll): return pos_orient -def create_compound_test_data(path=None): - """creates tests data for compound setters testing""" - setters = [ - ("orientation=None", dict(orientation="None")), - ("shorter position path", dict(position="np.array([[50, 0, 100]] * 2)")), - ( - "shorter orientation path", - dict(orientation="R.from_rotvec([[90,0,0],[0,90,0]], degrees=True)"), - ), - ( - "longer position path", - dict(position="np.array(np.linspace((280.,0.,0), (280.,0.,300), 8))"), - ), - ( - "longer orientation path", - dict( - orientation="R.from_rotvec([[0,90*i,0] for i in range(6)], degrees=True)" - ), - ), - ] - data = {"test_names": [], "setters_inputs": [], "pos_orient_as_matrix_expected": []} - for setter in setters: - tname, kwargs = setter - coll = create_compound_set(**kwargs) - pos_orient = get_pos_orient_from_collection(coll) - data["test_names"].append(tname) - data["setters_inputs"].append(kwargs) - data["pos_orient_as_matrix_expected"].append(pos_orient) - if path is None: - return data - np.save(path, data) - - -def display_compound_test_data(path): - """display compound test data from file""" - data = np.load(path, allow_pickle=True).item() - for kwargs in data["setters_inputs"]: - create_compound_set(show=True, **kwargs) - - folder = "tests/testdata" file = os.path.join(folder, "testdata_compound_setter_cases.npy") # create_compound_test_data(file) diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py index 30a341d5b..6e9ffa154 100644 --- a/tests/test_display_plotly.py +++ b/tests/test_display_plotly.py @@ -171,7 +171,7 @@ def test_extra_model3d(): cuboid.style.model3d.showdefault = False cuboid.style.model3d.data = [ { - "backend": "plotly", + "backend": "generic", "constructor": "Scatter3d", "kwargs": { "x": [-1, -1, 1, 1, -1, -1, -1, -1, -1, 1, 1, 1, 1, 1, 1, -1], @@ -244,9 +244,8 @@ def test_CustomSource_display(): def test_empty_display(): """should not fail if nothing to display""" - fig = go.Figure() - x = magpy.show(canvas=fig, backend="plotly") - assert x is None, "empty display plotly test fail" + fig = magpy.show(backend="plotly", return_fig=True) + assert isinstance(fig, go.Figure), "empty display plotly test fail" def test_display_warnings(): From 30402bcc930848d60ff4fbb3a42bc50fadeb2aef Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 22 Jul 2022 11:55:30 +0200 Subject: [PATCH 197/207] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1d5199ca8..3b8e93c32 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ All notable changes to magpylib are documented here. # Unreleased * Field computation `getB`/`getH` now supports 2D [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe) in addition to the `numpy.ndarray` as output type. ([#523](https://github.com/magpylib/magpylib/pull/523)) +* Internal `getB`/`getH` refactoring. Like for the object oriented interface, the direct interface for `'Line'` current now also accepts `'vertices'` as argument. ([#540](https://github.com/magpylib/magpylib/pull/540)) ## [4.0.4] - 2022-06-09 From 7cb090c8a94e341fd231966de3f9dfcf97a6f561 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 22 Jul 2022 11:58:33 +0200 Subject: [PATCH 198/207] update changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b8e93c32..c77476243 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ All notable changes to magpylib are documented here. # Unreleased * Field computation `getB`/`getH` now supports 2D [pandas](https://pandas.pydata.org/).[dataframe](https://pandas.pydata.org/docs/user_guide/dsintro.html#dataframe) in addition to the `numpy.ndarray` as output type. ([#523](https://github.com/magpylib/magpylib/pull/523)) * Internal `getB`/`getH` refactoring. Like for the object oriented interface, the direct interface for `'Line'` current now also accepts `'vertices'` as argument. ([#540](https://github.com/magpylib/magpylib/pull/540)) +* Complete display backend rework to prepare for easy implementation of new plotting backends, with minimal maintenance. ([#539](https://github.com/magpylib/magpylib/pull/539)) ## [4.0.4] - 2022-06-09 From f314fff0fc116bd6561b887553605e10ab997a63 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Fri, 22 Jul 2022 17:13:40 +0200 Subject: [PATCH 199/207] frame duration for mpl --- magpylib/_src/display/backend_matplotlib.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/magpylib/_src/display/backend_matplotlib.py b/magpylib/_src/display/backend_matplotlib.py index f794ab5b1..ba438e624 100644 --- a/magpylib/_src/display/backend_matplotlib.py +++ b/magpylib/_src/display/backend_matplotlib.py @@ -181,7 +181,7 @@ def animate(ind): # pragma: no cover fig, animate, frames=range(len(frames)), - interval=100, + interval=data["frame_duration"], blit=False, repeat=repeat, ) From 062b19ec2f1db74c489550a39097fae15c07dfb8 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sat, 23 Jul 2022 21:36:29 +0200 Subject: [PATCH 200/207] refactor inheritance --- .../_src/obj_classes/class_BaseExcitations.py | 164 +++++++++++++++++- magpylib/_src/obj_classes/class_BaseGetBH.py | 148 ---------------- .../_src/obj_classes/class_current_Line.py | 9 +- .../_src/obj_classes/class_current_Loop.py | 9 +- .../_src/obj_classes/class_magnet_Cuboid.py | 9 +- .../_src/obj_classes/class_magnet_Cylinder.py | 9 +- .../class_magnet_CylinderSegment.py | 9 +- .../_src/obj_classes/class_magnet_Sphere.py | 9 +- .../obj_classes/class_misc_CustomSource.py | 9 +- .../_src/obj_classes/class_misc_Dipole.py | 9 +- 10 files changed, 177 insertions(+), 207 deletions(-) delete mode 100644 magpylib/_src/obj_classes/class_BaseGetBH.py diff --git a/magpylib/_src/obj_classes/class_BaseExcitations.py b/magpylib/_src/obj_classes/class_BaseExcitations.py index 612664756..c70dd424d 100644 --- a/magpylib/_src/obj_classes/class_BaseExcitations.py +++ b/magpylib/_src/obj_classes/class_BaseExcitations.py @@ -1,14 +1,167 @@ """BaseHomMag class code DOCSTRINGS V4 READY """ -from magpylib._src.input_checks import check_format_input_scalar +from magpylib._src.fields.field_wrap_BH import getBH_level2 +from magpylib._src.utility import format_star_input from magpylib._src.input_checks import check_format_input_vector +from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr +from magpylib._src.obj_classes.class_BaseGeo import BaseGeo +from magpylib._src.input_checks import check_format_input_scalar + +class BaseSource(BaseGeo, BaseDisplayRepr): + """Base class for all types of sources. Provides getB and getH methods for source objects + and corresponding field function""" + + def __init__(self, position, orientation, style, **kwargs): + BaseGeo.__init__(self, position, orientation, style, **kwargs) + BaseDisplayRepr.__init__(self) + + + def getB(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): + """Compute the B-field in units of [mT] generated by the source. + + Parameters + ---------- + observers: array_like or (list of) `Sensor` objects + Can be array_like positions of shape (n1, n2, ..., 3) where the field + should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list + of such sensor objects (must all have similar pixel shapes). All positions + are given in units of [mm]. + + squeeze: bool, default=`True` + If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. + only a single source) are eliminated. + + pixel_agg: str, default=`None` + Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, + which is applied to observer output values, e.g. mean of all sensor pixel outputs. + With this option, observers input with different (pixel) shapes is allowed. + + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + + Returns + ------- + B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame + B-field at each path position (m) for each sensor (k) and each sensor pixel + position (n1,n2,...) in units of [mT]. Sensor pixel positions are equivalent + to simple observer positions. Paths of objects that are shorter than m will be + considered as static beyond their end. + + Examples + -------- + Compute the B-field of a spherical magnet at three positions: + + >>> import magpylib as magpy + >>> src = magpy.magnet.Sphere((0,0,1000), 1) + >>> B = src.getB(((0,0,0), (1,0,0), (2,0,0))) + >>> print(B) + [[ 0. 0. 666.66666667] + [ 0. 0. -41.66666667] + [ 0. 0. -5.20833333]] + + Compute the B-field at two sensors, each one with two pixels + + >>> sens1 = magpy.Sensor(position=(1,0,0), pixel=((0,0,.1), (0,0,-.1))) + >>> sens2 = sens1.copy(position=(2,0,0)) + >>> B = src.getB(sens1, sens2) + >>> print(B) + [[[ 12.19288783 0. -39.83010025] + [-12.19288783 0. -39.83010025]] + + [[ 0.77638847 0. -5.15004352] + [ -0.77638847 0. -5.15004352]]] + """ + observers = format_star_input(observers) + return getBH_level2( + self, + observers, + sumup=False, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="B", + ) + + def getH(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): + """Compute the H-field in units of [kA/m] generated by the source. + + Parameters + ---------- + observers: array_like or (list of) `Sensor` objects + Can be array_like positions of shape (n1, n2, ..., 3) where the field + should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list + of such sensor objects (must all have similar pixel shapes). All positions + are given in units of [mm]. + + squeeze: bool, default=`True` + If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. + only a single source) are eliminated. + + pixel_agg: str, default=`None` + Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, + which is applied to observer output values, e.g. mean of all sensor pixel outputs. + With this option, observers input with different (pixel) shapes is allowed. + + output: str, default='ndarray' + Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- + dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function + returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be + installed). + + Returns + ------- + H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame + H-field at each path position (m) for each sensor (k) and each sensor pixel + position (n1,n2,...) in units of [kA/m]. Sensor pixel positions are equivalent + to simple observer positions. Paths of objects that are shorter than m will be + considered as static beyond their end. + + Examples + -------- + Compute the H-field of a spherical magnet at three positions: + + >>> import magpylib as magpy + + >>> src = magpy.magnet.Sphere((0,0,1000), 1) + >>> H = src.getH(((0,0,0), (1,0,0), (2,0,0))) + >>> print(H) + [[ 0. 0. -265.25823849] + [ 0. 0. -33.15727981] + [ 0. 0. -4.14465998]] + + Compute the H-field at two sensors, each one with two pixels + + >>> sens1 = magpy.Sensor(position=(1,0,0), pixel=((0,0,.1), (0,0,-.1))) + >>> sens2 = sens1.copy(position=(2,0,0)) + >>> H = src.getH(sens1, sens2) + >>> print(H) + [[[ 9.70279185 0. -31.69578669] + [ -9.70279185 0. -31.69578669]] + + [[ 0.61783031 0. -4.09827441] + [ -0.61783031 0. -4.09827441]]] + """ + observers = format_star_input(observers) + return getBH_level2( + self, + observers, + sumup=False, + squeeze=squeeze, + pixel_agg=pixel_agg, + output=output, + field="H", + ) -class BaseHomMag: +class BaseHomMag(BaseSource): """provides the magnetization attribute for homogeneously magnetized magnets""" - def __init__(self, magnetization): + def __init__(self, position, orientation, magnetization, style, **kwargs): + super().__init__(position, orientation, style, **kwargs) self.magnetization = magnetization @property @@ -29,10 +182,11 @@ def magnetization(self, mag): ) -class BaseCurrent: +class BaseCurrent(BaseSource): """provides scalar current attribute""" - def __init__(self, current): + def __init__(self, position, orientation, current, style, **kwargs): + super().__init__(position, orientation, style, **kwargs) self.current = current @property diff --git a/magpylib/_src/obj_classes/class_BaseGetBH.py b/magpylib/_src/obj_classes/class_BaseGetBH.py deleted file mode 100644 index 3be60f0b5..000000000 --- a/magpylib/_src/obj_classes/class_BaseGetBH.py +++ /dev/null @@ -1,148 +0,0 @@ -"""BaseGetBHsimple class code -DOCSTRINGS V4 READY -""" -from magpylib._src.fields.field_wrap_BH import getBH_level2 -from magpylib._src.utility import format_star_input - - -class BaseGetBH: - """provides getB and getH methods for source objects""" - - def getB(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): - """Compute the B-field in units of [mT] generated by the source. - - Parameters - ---------- - observers: array_like or (list of) `Sensor` objects - Can be array_like positions of shape (n1, n2, ..., 3) where the field - should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list - of such sensor objects (must all have similar pixel shapes). All positions - are given in units of [mm]. - - squeeze: bool, default=`True` - If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. - only a single source) are eliminated. - - pixel_agg: str, default=`None` - Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, - which is applied to observer output values, e.g. mean of all sensor pixel outputs. - With this option, observers input with different (pixel) shapes is allowed. - - output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). - - Returns - ------- - B-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame - B-field at each path position (m) for each sensor (k) and each sensor pixel - position (n1,n2,...) in units of [mT]. Sensor pixel positions are equivalent - to simple observer positions. Paths of objects that are shorter than m will be - considered as static beyond their end. - - Examples - -------- - Compute the B-field of a spherical magnet at three positions: - - >>> import magpylib as magpy - >>> src = magpy.magnet.Sphere((0,0,1000), 1) - >>> B = src.getB(((0,0,0), (1,0,0), (2,0,0))) - >>> print(B) - [[ 0. 0. 666.66666667] - [ 0. 0. -41.66666667] - [ 0. 0. -5.20833333]] - - Compute the B-field at two sensors, each one with two pixels - - >>> sens1 = magpy.Sensor(position=(1,0,0), pixel=((0,0,.1), (0,0,-.1))) - >>> sens2 = sens1.copy(position=(2,0,0)) - >>> B = src.getB(sens1, sens2) - >>> print(B) - [[[ 12.19288783 0. -39.83010025] - [-12.19288783 0. -39.83010025]] - - [[ 0.77638847 0. -5.15004352] - [ -0.77638847 0. -5.15004352]]] - """ - observers = format_star_input(observers) - return getBH_level2( - self, - observers, - sumup=False, - squeeze=squeeze, - pixel_agg=pixel_agg, - output=output, - field="B", - ) - - def getH(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): - """Compute the H-field in units of [kA/m] generated by the source. - - Parameters - ---------- - observers: array_like or (list of) `Sensor` objects - Can be array_like positions of shape (n1, n2, ..., 3) where the field - should be evaluated, a `Sensor` object with pixel shape (n1, n2, ..., 3) or a list - of such sensor objects (must all have similar pixel shapes). All positions - are given in units of [mm]. - - squeeze: bool, default=`True` - If `True`, the output is squeezed, i.e. all axes of length 1 in the output (e.g. - only a single source) are eliminated. - - pixel_agg: str, default=`None` - Reference to a compatible numpy aggregator function like `'min'` or `'mean'`, - which is applied to observer output values, e.g. mean of all sensor pixel outputs. - With this option, observers input with different (pixel) shapes is allowed. - - output: str, default='ndarray' - Output type, which must be one of `('ndarray', 'dataframe')`. By default a multi- - dimensional array ('ndarray') is returned. If 'dataframe' is chosen, the function - returns a 2D-table as a `pandas.DataFrame` object (the Pandas library must be - installed). - - Returns - ------- - H-field: ndarray, shape squeeze(m, k, n1, n2, ..., 3) or DataFrame - H-field at each path position (m) for each sensor (k) and each sensor pixel - position (n1,n2,...) in units of [kA/m]. Sensor pixel positions are equivalent - to simple observer positions. Paths of objects that are shorter than m will be - considered as static beyond their end. - - Examples - -------- - Compute the H-field of a spherical magnet at three positions: - - >>> import magpylib as magpy - - >>> src = magpy.magnet.Sphere((0,0,1000), 1) - >>> H = src.getH(((0,0,0), (1,0,0), (2,0,0))) - >>> print(H) - [[ 0. 0. -265.25823849] - [ 0. 0. -33.15727981] - [ 0. 0. -4.14465998]] - - Compute the H-field at two sensors, each one with two pixels - - >>> sens1 = magpy.Sensor(position=(1,0,0), pixel=((0,0,.1), (0,0,-.1))) - >>> sens2 = sens1.copy(position=(2,0,0)) - >>> H = src.getH(sens1, sens2) - >>> print(H) - [[[ 9.70279185 0. -31.69578669] - [ -9.70279185 0. -31.69578669]] - - [[ 0.61783031 0. -4.09827441] - [ -0.61783031 0. -4.09827441]]] - """ - observers = format_star_input(observers) - return getBH_level2( - self, - observers, - sumup=False, - squeeze=squeeze, - pixel_agg=pixel_agg, - output=output, - field="H", - ) diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 8254f47f3..9ee556b4e 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -3,10 +3,7 @@ """ from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.input_checks import check_format_input_vertices -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH from magpylib._src.utility import Registered @@ -21,7 +18,7 @@ "segment_end": 2, }, ) -class Line(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): +class Line(BaseCurrent): """Current flowing in straight lines from vertex to vertex. Can be used as `sources` input for magnetic field computation. @@ -113,9 +110,7 @@ def __init__( self.vertices = vertices # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) - BaseCurrent.__init__(self, current) + super().__init__(position, orientation, current, style, **kwargs) # property getters and setters @property diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index ca04b0818..cef8e9836 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -3,10 +3,7 @@ """ from magpylib._src.fields.field_BH_loop import current_loop_field from magpylib._src.input_checks import check_format_input_scalar -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH from magpylib._src.utility import Registered @@ -16,7 +13,7 @@ field_func=current_loop_field, source_kwargs_ndim={"current": 1, "diameter": 1}, ) -class Loop(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseCurrent): +class Loop(BaseCurrent): """Circular current loop. Can be used as `sources` input for magnetic field computation. @@ -102,9 +99,7 @@ def __init__( self.diameter = diameter # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) - BaseCurrent.__init__(self, current) + super().__init__(position, orientation, current, style, **kwargs) # property getters and setters @property diff --git a/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py index 35d4b8937..3ede9671e 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -3,10 +3,7 @@ """ from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field from magpylib._src.input_checks import check_format_input_vector -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH from magpylib._src.utility import Registered @@ -16,7 +13,7 @@ field_func=magnet_cuboid_field, source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) -class Cuboid(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): +class Cuboid(BaseHomMag): """Cuboid magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -103,9 +100,7 @@ def __init__( self.dimension = dimension # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) - BaseHomMag.__init__(self, magnetization) + super().__init__(position, orientation, magnetization, style, **kwargs) # property getters and setters @property diff --git a/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py index 47c268aec..7f2b7d0f6 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -3,10 +3,7 @@ """ from magpylib._src.fields.field_BH_cylinder_segment import magnet_cylinder_field from magpylib._src.input_checks import check_format_input_vector -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH from magpylib._src.utility import Registered @@ -16,7 +13,7 @@ field_func=magnet_cylinder_field, source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) -class Cylinder(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): +class Cylinder(BaseHomMag): """Cylinder magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -103,9 +100,7 @@ def __init__( self.dimension = dimension # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) - BaseHomMag.__init__(self, magnetization) + super().__init__(position, orientation, magnetization, style, **kwargs) # property getters and setters @property diff --git a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index d0df39f56..3223d3867 100644 --- a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -7,10 +7,7 @@ magnet_cylinder_segment_field_internal, ) from magpylib._src.input_checks import check_format_input_cylinder_segment -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH from magpylib._src.utility import Registered @@ -20,7 +17,7 @@ field_func=magnet_cylinder_segment_field_internal, source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) -class CylinderSegment(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): +class CylinderSegment(BaseHomMag): """Cylinder segment (ring-section) magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -112,9 +109,7 @@ def __init__( self.dimension = dimension # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) - BaseHomMag.__init__(self, magnetization) + super().__init__(position, orientation, magnetization, style, **kwargs) # property getters and setters @property diff --git a/magpylib/_src/obj_classes/class_magnet_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py index 47a2a5a5f..e2bec66cc 100644 --- a/magpylib/_src/obj_classes/class_magnet_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -3,10 +3,7 @@ """ from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.input_checks import check_format_input_scalar -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH from magpylib._src.utility import Registered @@ -16,7 +13,7 @@ field_func=magnet_sphere_field, source_kwargs_ndim={"magnetization": 2, "diameter": 1}, ) -class Sphere(BaseGeo, BaseDisplayRepr, BaseGetBH, BaseHomMag): +class Sphere(BaseHomMag): """Spherical magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -103,9 +100,7 @@ def __init__( self.diameter = diameter # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) - BaseHomMag.__init__(self, magnetization) + super().__init__(position, orientation, magnetization, style, **kwargs) # property getters and setters @property diff --git a/magpylib/_src/obj_classes/class_misc_CustomSource.py b/magpylib/_src/obj_classes/class_misc_CustomSource.py index 89bd3c875..7e78e877a 100644 --- a/magpylib/_src/obj_classes/class_misc_CustomSource.py +++ b/magpylib/_src/obj_classes/class_misc_CustomSource.py @@ -1,13 +1,11 @@ """Custom class code """ from magpylib._src.input_checks import validate_field_func -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.obj_classes.class_BaseExcitations import BaseSource from magpylib._src.utility import Registered @Registered(kind="source", family="misc", field_func=None) -class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH): +class CustomSource(BaseSource): """User-defined custom source. Can be used as `sources` input for magnetic field computation. @@ -95,8 +93,7 @@ def __init__( self.field_func = field_func # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) + super().__init__(position, orientation, style, **kwargs) @property def field_func(self): diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 5101b08cf..57bd139bf 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -3,9 +3,7 @@ """ from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.input_checks import check_format_input_vector -from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr -from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.obj_classes.class_BaseGetBH import BaseGetBH +from magpylib._src.obj_classes.class_BaseExcitations import BaseSource from magpylib._src.utility import Registered @@ -15,7 +13,7 @@ field_func=dipole_field, source_kwargs_ndim={"moment": 2}, ) -class Dipole(BaseGeo, BaseDisplayRepr, BaseGetBH): +class Dipole(BaseSource): """Magnetic dipole moment. Can be used as `sources` input for magnetic field computation. @@ -97,8 +95,7 @@ def __init__( self.moment = moment # init inheritance - BaseGeo.__init__(self, position, orientation, style=style, **kwargs) - BaseDisplayRepr.__init__(self) + super().__init__(position, orientation, style, **kwargs) # property getters and setters @property From 9235004f9450ad4ace96c022a83c70557014875e Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 24 Jul 2022 00:26:39 +0200 Subject: [PATCH 201/207] move field func logic to BaseSource class --- .../_src/obj_classes/class_BaseExcitations.py | 39 ++++++++++++++++--- .../_src/obj_classes/class_current_Line.py | 2 +- .../_src/obj_classes/class_current_Loop.py | 2 + .../_src/obj_classes/class_magnet_Cuboid.py | 3 +- .../_src/obj_classes/class_magnet_Cylinder.py | 3 +- .../class_magnet_CylinderSegment.py | 3 +- .../_src/obj_classes/class_magnet_Sphere.py | 3 +- .../obj_classes/class_misc_CustomSource.py | 26 ++----------- .../_src/obj_classes/class_misc_Dipole.py | 3 +- magpylib/_src/utility.py | 12 ------ 10 files changed, 50 insertions(+), 46 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseExcitations.py b/magpylib/_src/obj_classes/class_BaseExcitations.py index c70dd424d..1ca8337dd 100644 --- a/magpylib/_src/obj_classes/class_BaseExcitations.py +++ b/magpylib/_src/obj_classes/class_BaseExcitations.py @@ -2,20 +2,47 @@ DOCSTRINGS V4 READY """ from magpylib._src.fields.field_wrap_BH import getBH_level2 -from magpylib._src.utility import format_star_input +from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.input_checks import check_format_input_vector +from magpylib._src.input_checks import validate_field_func from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo -from magpylib._src.input_checks import check_format_input_scalar +from magpylib._src.utility import format_star_input + class BaseSource(BaseGeo, BaseDisplayRepr): """Base class for all types of sources. Provides getB and getH methods for source objects and corresponding field function""" - def __init__(self, position, orientation, style, **kwargs): - BaseGeo.__init__(self, position, orientation, style, **kwargs) + _field_func = None + _editable_field_func = False + + def __init__(self, position, orientation, field_func=None, style=None, **kwargs): + if field_func is not None: + self.field_func = field_func + BaseGeo.__init__(self, position, orientation, style=style, **kwargs) BaseDisplayRepr.__init__(self) + @property + def field_func(self): + """ + The function for B- and H-field computation must have the two positional arguments + `field` and `observers`. With `field='B'` or `field='H'` the B- or H-field in units + of [mT] or [kA/m] must be returned respectively. The `observers` argument must + accept numpy ndarray inputs of shape (n,3), in which case the returned fields must + be numpy ndarrays of shape (n,3) themselves. + """ + return self._field_func + + @field_func.setter + def field_func(self, val): + if self._editable_field_func: + validate_field_func(val) + else: + raise AttributeError( + "The `field_func` attribute should not be edited for original Magpylib sources." + ) + self._field_func = val def getB(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): """Compute the B-field in units of [mT] generated by the source. @@ -161,7 +188,7 @@ class BaseHomMag(BaseSource): """provides the magnetization attribute for homogeneously magnetized magnets""" def __init__(self, position, orientation, magnetization, style, **kwargs): - super().__init__(position, orientation, style, **kwargs) + super().__init__(position, orientation, style=style, **kwargs) self.magnetization = magnetization @property @@ -186,7 +213,7 @@ class BaseCurrent(BaseSource): """provides scalar current attribute""" def __init__(self, position, orientation, current, style, **kwargs): - super().__init__(position, orientation, style, **kwargs) + super().__init__(position, orientation, style=style, **kwargs) self.current = current @property diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 9ee556b4e..50e771987 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -10,7 +10,6 @@ @Registered( kind="source", family="current", - field_func=current_vertices_field, source_kwargs_ndim={ "current": 1, "vertices": 3, @@ -95,6 +94,7 @@ class Line(BaseCurrent): """ # pylint: disable=dangerous-default-value + _field_func = staticmethod(current_vertices_field) def __init__( self, diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index cef8e9836..1cdb0bdee 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -85,6 +85,8 @@ class Loop(BaseCurrent): [-3.55802727e-17 1.65201495e-01 -1.65201495e-01]] """ + _field_func = staticmethod(current_loop_field) + def __init__( self, current=None, diff --git a/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py index 3ede9671e..debdf7cae 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -10,7 +10,6 @@ @Registered( kind="source", family="magnet", - field_func=magnet_cuboid_field, source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) class Cuboid(BaseHomMag): @@ -86,6 +85,8 @@ class Cuboid(BaseHomMag): [0.1604214 0.25726266 0.01664045]] """ + _field_func = staticmethod(magnet_cuboid_field) + def __init__( self, magnetization=None, diff --git a/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py index 7f2b7d0f6..1e243693d 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -10,7 +10,6 @@ @Registered( kind="source", family="magnet", - field_func=magnet_cylinder_field, source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) class Cylinder(BaseHomMag): @@ -86,6 +85,8 @@ class Cylinder(BaseHomMag): [0.12571523 0.20144503 0.01312389]] """ + _field_func = staticmethod(magnet_cylinder_field) + def __init__( self, magnetization=None, diff --git a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index 3223d3867..4d1929bb8 100644 --- a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -14,7 +14,6 @@ @Registered( kind="source", family="magnet", - field_func=magnet_cylinder_segment_field_internal, source_kwargs_ndim={"magnetization": 2, "dimension": 2}, ) class CylinderSegment(BaseHomMag): @@ -95,6 +94,8 @@ class CylinderSegment(BaseHomMag): [ 0.25439493 0.74331628 0.11682542]] """ + _field_func = staticmethod(magnet_cylinder_segment_field_internal) + def __init__( self, magnetization=None, diff --git a/magpylib/_src/obj_classes/class_magnet_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py index e2bec66cc..8ae81e592 100644 --- a/magpylib/_src/obj_classes/class_magnet_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -10,7 +10,6 @@ @Registered( kind="source", family="magnet", - field_func=magnet_sphere_field, source_kwargs_ndim={"magnetization": 2, "diameter": 1}, ) class Sphere(BaseHomMag): @@ -86,6 +85,8 @@ class Sphere(BaseHomMag): [0.08400171 0.13470122 0.00869866]] """ + _field_func = staticmethod(magnet_sphere_field) + def __init__( self, magnetization=None, diff --git a/magpylib/_src/obj_classes/class_misc_CustomSource.py b/magpylib/_src/obj_classes/class_misc_CustomSource.py index 7e78e877a..2ca67c722 100644 --- a/magpylib/_src/obj_classes/class_misc_CustomSource.py +++ b/magpylib/_src/obj_classes/class_misc_CustomSource.py @@ -1,10 +1,9 @@ """Custom class code """ -from magpylib._src.input_checks import validate_field_func from magpylib._src.obj_classes.class_BaseExcitations import BaseSource from magpylib._src.utility import Registered -@Registered(kind="source", family="misc", field_func=None) +@Registered(kind="source", family="misc") class CustomSource(BaseSource): """User-defined custom source. @@ -81,6 +80,8 @@ class CustomSource(BaseSource): [70.71067812 70.71067812 0. ]] """ + _editable_field_func = True + def __init__( self, field_func=None, @@ -89,24 +90,5 @@ def __init__( style=None, **kwargs, ): - # instance attributes - self.field_func = field_func - # init inheritance - super().__init__(position, orientation, style, **kwargs) - - @property - def field_func(self): - """ - The function for B- and H-field computation must have the two positional arguments - `field` and `observers`. With `field='B'` or `field='H'` the B- or H-field in units - of [mT] or [kA/m] must be returned respectively. The `observers` argument must - accept numpy ndarray inputs of shape (n,3), in which case the returned fields must - be numpy ndarrays of shape (n,3) themselves. - """ - return self._field_func - - @field_func.setter - def field_func(self, val): - validate_field_func(val) - self._field_func = val + super().__init__(position, orientation, field_func, style, **kwargs) diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 57bd139bf..c5f37c7ae 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -10,7 +10,6 @@ @Registered( kind="source", family="dipole", - field_func=dipole_field, source_kwargs_ndim={"moment": 2}, ) class Dipole(BaseSource): @@ -83,6 +82,8 @@ class Dipole(BaseSource): [0.08021572 0.1369368 0.05672108]] """ + _field_func = staticmethod(dipole_field) + def __init__( self, moment=None, diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index a77ec4cc6..ad59a0852 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -43,18 +43,6 @@ def __call__(self, klass): self.sensors[name] = klass elif self.kind == "source": - if self.field_func is None: - setattr(klass, "_field_func", None) - else: - setattr(klass, "_field_func", staticmethod(self.field_func)) - setattr( - klass, - "field_func", - property( - lambda self: getattr(self, "_field_func"), - doc="""The core function for B- and H-field computation""", - ), - ) self.sources[name] = klass if name not in self.source_kwargs_ndim: self.source_kwargs_ndim[name] = { From 444e4cb19db22b927291117685634debd8a27ab0 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 24 Jul 2022 15:20:23 +0200 Subject: [PATCH 202/207] refactor Registering classes with a metaclass --- magpylib/_src/display/display_matplotlib.py | 39 ++++-- magpylib/_src/display/display_utility.py | 2 - .../_src/display/plotly/plotly_display.py | 2 +- magpylib/_src/fields/field_wrap_BH.py | 29 +++-- magpylib/_src/input_checks.py | 114 +++++++++-------- .../_src/obj_classes/class_BaseDisplayRepr.py | 1 - .../_src/obj_classes/class_BaseExcitations.py | 6 +- magpylib/_src/obj_classes/class_BaseGeo.py | 25 ++-- magpylib/_src/obj_classes/class_Collection.py | 29 ++--- magpylib/_src/obj_classes/class_Sensor.py | 5 +- .../_src/obj_classes/class_current_Line.py | 19 ++- .../_src/obj_classes/class_current_Loop.py | 10 +- .../_src/obj_classes/class_magnet_Cuboid.py | 13 +- .../_src/obj_classes/class_magnet_Cylinder.py | 13 +- .../class_magnet_CylinderSegment.py | 13 +- .../_src/obj_classes/class_magnet_Sphere.py | 13 +- .../obj_classes/class_misc_CustomSource.py | 2 - .../_src/obj_classes/class_misc_Dipole.py | 9 +- magpylib/_src/style.py | 43 ++++--- magpylib/_src/utility.py | 116 ++++++++---------- tests/test_exceptions.py | 12 +- tests/test_input_checks.py | 4 +- tests/test_obj_BaseGeo.py | 22 ++-- tests/test_obj_Collection.py | 2 - 24 files changed, 258 insertions(+), 285 deletions(-) diff --git a/magpylib/_src/display/display_matplotlib.py b/magpylib/_src/display/display_matplotlib.py index 049b4aaa6..b87e5ce56 100644 --- a/magpylib/_src/display/display_matplotlib.py +++ b/magpylib/_src/display/display_matplotlib.py @@ -1,4 +1,5 @@ """ matplotlib draw-functionalities""" +# pylint: disable=import-outside-toplevel import matplotlib.pyplot as plt import numpy as np from mpl_toolkits.mplot3d.art3d import Poly3DCollection @@ -31,6 +32,10 @@ def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): """ # pylint: disable=protected-access # pylint: disable=too-many-branches + from magpylib._src.obj_classes.class_magnet_Cuboid import Cuboid + from magpylib._src.obj_classes.class_magnet_Cylinder import Cylinder + from magpylib._src.obj_classes.class_magnet_CylinderSegment import CylinderSegment + points = [] for col, obj in zip(colors, faced_objects): @@ -38,9 +43,9 @@ def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): rots, poss, inds = get_rot_pos_from_path(obj, show_path) # vector length, color and magnetization - if obj._object_type in ("Cuboid", "Cylinder"): + if isinstance(obj, (Cuboid, Cylinder)): length = 1.8 * np.amax(obj.dimension) - elif obj._object_type == "CylinderSegment": + elif isinstance(obj, CylinderSegment): length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h else: length = 1.8 * obj.diameter # Sphere @@ -49,7 +54,7 @@ def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction): # collect all draw positions and directions draw_pos, draw_direc = [], [] for rot, pos, ind in zip(rots, poss, inds): - if obj._object_type == "CylinderSegment": + if isinstance(obj, CylinderSegment): # change cylinder_tile draw_pos to barycenter pos = obj._barycenter[ind] draw_pos += [pos] @@ -339,6 +344,16 @@ def display_matplotlib( # pylint: disable=too-many-branches # pylint: disable=too-many-statements + from magpylib._src.obj_classes.class_magnet_Cuboid import Cuboid + from magpylib._src.obj_classes.class_magnet_Cylinder import Cylinder + from magpylib._src.obj_classes.class_magnet_CylinderSegment import CylinderSegment + from magpylib._src.obj_classes.class_magnet_Sphere import Sphere + from magpylib._src.obj_classes.class_current_Line import Line + from magpylib._src.obj_classes.class_current_Loop import Loop + from magpylib._src.obj_classes.class_misc_Dipole import Dipole + from magpylib._src.obj_classes.class_misc_CustomSource import CustomSource + from magpylib._src.obj_classes.class_Sensor import Sensor + # apply config default values if None # create or set plotting axis if axis is None: @@ -372,16 +387,16 @@ def display_matplotlib( pts = draw_model3d_extra(obj, style, path_frames, ax, obj_color) points += pts if obj.style.model3d.showdefault: - if obj._object_type == "Cuboid": + if isinstance(obj, Cuboid): lw = 0.5 faces = faces_cuboid(obj, path_frames) - elif obj._object_type == "Cylinder": + elif isinstance(obj, Cylinder): faces = faces_cylinder(obj, path_frames) - elif obj._object_type == "CylinderSegment": + elif isinstance(obj, CylinderSegment): faces = faces_cylinder_segment(obj, path_frames) - elif obj._object_type == "Sphere": + elif isinstance(obj, Sphere): faces = faces_sphere(obj, path_frames) - elif obj._object_type == "Line": + elif isinstance(obj, Line): if style.arrow.show: check_excitations([obj]) arrow_size = style.arrow.size if style.arrow.show else 0 @@ -389,7 +404,7 @@ def display_matplotlib( points += draw_line( [obj], path_frames, obj_color, arrow_size, arrow_width, ax ) - elif obj._object_type == "Loop": + elif isinstance(obj, Loop): if style.arrow.show: check_excitations([obj]) arrow_width = style.arrow.width @@ -397,7 +412,7 @@ def display_matplotlib( points += draw_circular( [obj], path_frames, obj_color, arrow_size, arrow_width, ax ) - elif obj._object_type == "Sensor": + elif isinstance(obj, Sensor): sensors.append((obj, obj_color)) points += draw_pixel( [obj], @@ -408,10 +423,10 @@ def display_matplotlib( style.pixel.symbol, path_frames, ) - elif obj._object_type == "Dipole": + elif isinstance(obj, Dipole): dipoles.append((obj, obj_color)) points += [obj.position] - elif obj._object_type == "CustomSource": + elif isinstance(obj, CustomSource): draw_markers( np.array([obj.position]), ax, obj_color, symbol="*", size=10 ) diff --git a/magpylib/_src/display/display_utility.py b/magpylib/_src/display/display_utility.py index 8804f5763..0c9875834 100644 --- a/magpylib/_src/display/display_utility.py +++ b/magpylib/_src/display/display_utility.py @@ -7,10 +7,8 @@ from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.style import Markers -from magpylib._src.utility import Registered -@Registered(kind="nonmodel", family="markers") class MagpyMarkers: """A class that stores markers 3D-coordinates""" diff --git a/magpylib/_src/display/plotly/plotly_display.py b/magpylib/_src/display/plotly/plotly_display.py index f8e962212..5a3eb5295 100644 --- a/magpylib/_src/display/plotly/plotly_display.py +++ b/magpylib/_src/display/plotly/plotly_display.py @@ -922,7 +922,7 @@ def animate_path( path_lengths = [] for obj in objs: subobjs = [obj] - if getattr(obj, "_object_type", None) == "Collection": + if hasattr(obj, "children"): subobjs.extend(obj.children) for subobj in subobjs: path_len = getattr(subobj, "_position", np.array((0.0, 0.0, 0.0))).shape[0] diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index 4c547acb3..b434a35b3 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -48,7 +48,6 @@ from scipy.spatial.transform import Rotation as R from magpylib._src.exceptions import MagpylibBadUserInput -from magpylib._src.exceptions import MagpylibInternalError from magpylib._src.input_checks import check_dimensions from magpylib._src.input_checks import check_excitations from magpylib._src.input_checks import check_format_input_observers @@ -57,7 +56,6 @@ from magpylib._src.utility import check_static_sensor_orient from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs -from magpylib._src.utility import Registered def tile_group_property(group: list, n_pp: int, prop_name: str): @@ -89,7 +87,6 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: posov = np.tile(poso, (len(group), 1)) # determine which group we are dealing with and tile up properties - src_type = group[0]._object_type kwargs = { "position": posv, @@ -97,10 +94,7 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict: "orientation": rotobj, } - try: - src_props = Registered.source_kwargs_ndim[src_type] - except KeyError as err: - raise MagpylibInternalError("Bad source_type in get_src_dict") from err + src_props = group[0]._field_func_kwargs_ndim for prop in src_props: if hasattr(group[0], prop) and prop not in ( @@ -194,6 +188,9 @@ def getBH_level2( # pylint: disable=protected-access # pylint: disable=too-many-branches # pylint: disable=too-many-statements + # pylint: disable=import-outside-toplevel + + from magpylib._src.obj_classes.class_Collection import Collection # CHECK AND FORMAT INPUT --------------------------------------------------- if isinstance(sources, str): @@ -322,7 +319,7 @@ def getBH_level2( # rearrange B when there is at least one Collection with more than one source if num_of_src_list > num_of_sources: for src_ind, src in enumerate(sources): - if src._object_type == "Collection": + if isinstance(src, Collection): col_len = len(format_obj_input(src, allow="sources")) # set B[i] to sum of slice B[src_ind] = np.sum(B[src_ind : src_ind + col_len], axis=0) @@ -445,11 +442,19 @@ def getBH_dict_level2( # To allow different input dimensions, the tdim argument is also given # which tells the program which dimension it should tile up. + # pylint: disable=import-outside-toplevel + from magpylib._src.obj_classes.class_BaseExcitations import BaseSource + try: - field_func = Registered.sources[source_type]._field_func + source_classes = {c.__name__: c for c in BaseSource.registry} + field_func = source_classes[source_type]._field_func + field_func_kwargs_ndim = {"position": 2, "orientation": 2, "observers": 2} + field_func_kwargs_ndim.update( + source_classes[source_type]._field_func_kwargs_ndim + ) except KeyError as err: raise MagpylibBadUserInput( - f"Input parameter `sources` must be one of {list(Registered.sources)}" + f"Input parameter `sources` must be one of {list(source_classes)}" " when using the direct interface." ) from err @@ -478,7 +483,7 @@ def getBH_dict_level2( raise MagpylibBadUserInput( f"{key} input must be array-like.\n" f"Instead received {val}" ) from err - expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) + expected_dim = field_func_kwargs_ndim.get(key, 1) if val.ndim == expected_dim or ragged_seq[key]: if len(val) == 1: val = np.squeeze(val) @@ -495,7 +500,7 @@ def getBH_dict_level2( vec_len = max(vec_lengths.values(), default=1) # tile 1D inputs and replace original values in kwargs for key, val in kwargs.items(): - expected_dim = Registered.source_kwargs_ndim[source_type].get(key, 1) + expected_dim = field_func_kwargs_ndim.get(key, 1) if val.ndim < expected_dim and not ragged_seq[key]: kwargs[key] = np.tile(val, (vec_len, *[1] * (expected_dim - 1))) diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index 243a969c5..be538f59e 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -1,4 +1,5 @@ """ input checks code""" +# pylint: disable=import-outside-toplevel import inspect import numbers @@ -10,7 +11,6 @@ from magpylib._src.exceptions import MagpylibBadUserInput from magpylib._src.exceptions import MagpylibMissingInput from magpylib._src.utility import format_obj_input -from magpylib._src.utility import Registered from magpylib._src.utility import wrong_obj_msg @@ -426,10 +426,11 @@ def check_format_input_observers(inp, pixel_agg=None): checks observers input and returns a list of sensor objects """ # pylint: disable=raise-missing-from - # pylint: disable=protected-access + from magpylib._src.obj_classes.class_Collection import Collection + from magpylib._src.obj_classes.class_Sensor import Sensor # make bare Sensor, bare Collection into a list - if getattr(inp, "_object_type", "") in ("Collection", "Sensor"): + if isinstance(inp, (Collection, Sensor)): inp = (inp,) # note: bare pixel is automatically made into a list by Sensor @@ -451,9 +452,9 @@ def check_format_input_observers(inp, pixel_agg=None): except (TypeError, ValueError): # if not, it must be [pos_vec, sens, coll] sensors = [] for obj in inp: - if getattr(obj, "_object_type", "") == "Sensor": + if isinstance(obj, Sensor): sensors.append(obj) - elif getattr(obj, "_object_type", "") == "Collection": + elif isinstance(obj, Collection): child_sensors = format_obj_input(obj, allow="sensors") if not child_sensors: raise MagpylibBadUserInput(wrong_obj_msg(obj, allow="observers")) @@ -499,28 +500,31 @@ def check_format_input_obj( recursive: bool Flatten Collection objects """ + from magpylib._src.obj_classes.class_BaseExcitations import BaseSource + from magpylib._src.obj_classes.class_Sensor import Sensor + from magpylib._src.obj_classes.class_Collection import Collection + # select wanted - wanted_types = [] + wanted_types = () if "sources" in allow.split("+"): - wanted_types += list(Registered.sources) + wanted_types += (BaseSource,) if "sensors" in allow.split("+"): - wanted_types += list(Registered.sensors) + wanted_types += (Sensor,) if "collections" in allow.split("+"): - wanted_types += ["Collection"] + wanted_types += (Collection,) if typechecks: - all_types = list(Registered.sources) + list(Registered.sensors) + ["Collection"] + all_types = (BaseSource, Sensor, Collection) obj_list = [] for obj in inp: - obj_type = getattr(obj, "_object_type", None) # add to list if wanted type - if obj_type in wanted_types: + if isinstance(obj, wanted_types): obj_list.append(obj) # recursion - if (obj_type == "Collection") and recursive: + if isinstance(obj, Collection) and recursive: obj_list += check_format_input_obj( obj, allow=allow, @@ -529,12 +533,11 @@ def check_format_input_obj( ) # typechecks - if typechecks: - if not obj_type in all_types: - raise MagpylibBadUserInput( - f"Input objects must be {allow} or a flat list thereof.\n" - f"Instead received {type(obj)}." - ) + if typechecks and not isinstance(obj, all_types): + raise MagpylibBadUserInput( + f"Input objects must be {allow} or a flat list thereof.\n" + f"Instead received {type(obj)}." + ) return obj_list @@ -546,47 +549,60 @@ def check_format_input_obj( def check_dimensions(sources): """check if all sources have dimension (or similar) initialized""" - # pylint: disable=protected-access - for s in sources: - obj_type = getattr(s, "_object_type", None) - if obj_type in ("Cuboid", "Cylinder", "CylinderSegment"): - if s.dimension is None: - raise MagpylibMissingInput(f"Parameter `dimension` of {s} must be set.") - elif obj_type in ("Sphere", "Loop"): - if s.diameter is None: - raise MagpylibMissingInput(f"Parameter `diameter` of {s} must be set.") - elif obj_type == "Line": - if s.vertices is None: - raise MagpylibMissingInput(f"Parameter `vertices` of {s} must be set.") + from magpylib._src.obj_classes.class_magnet_Cuboid import Cuboid + from magpylib._src.obj_classes.class_magnet_Cylinder import Cylinder + from magpylib._src.obj_classes.class_magnet_CylinderSegment import CylinderSegment + from magpylib._src.obj_classes.class_magnet_Sphere import Sphere + from magpylib._src.obj_classes.class_current_Line import Line + from magpylib._src.obj_classes.class_current_Loop import Loop + + for src in sources: + if isinstance(src, (Cuboid, Cylinder, CylinderSegment)): + if src.dimension is None: + raise MagpylibMissingInput( + f"Parameter `dimension` of {src} must be set." + ) + elif isinstance(src, (Sphere, Loop)): + if src.diameter is None: + raise MagpylibMissingInput( + f"Parameter `diameter` of {src} must be set." + ) + elif isinstance(src, Line): + if src.vertices is None: + raise MagpylibMissingInput( + f"Parameter `vertices` of {src} must be set." + ) def check_excitations(sources, custom_field=None): """check if all sources have exitation initialized""" - # pylint: disable=protected-access - for s in sources: - obj_type = getattr(s, "_object_type", None) - if obj_type in ("Cuboid", "Cylinder", "Sphere", "CylinderSegment"): - if s.magnetization is None: + from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet, BaseCurrent + from magpylib._src.obj_classes.class_misc_CustomSource import CustomSource + from magpylib._src.obj_classes.class_misc_Dipole import Dipole + + for src in sources: + if isinstance(src, CustomSource) and (custom_field is not None): + if src.field_func is None: raise MagpylibMissingInput( - f"Parameter `magnetization` of {s} must be set." + f"Cannot compute {custom_field}-field because input parameter" + f"`field_func` of {src} has undefined {custom_field}-field computation." ) - elif obj_type in ("Loop", "Line"): - if s.current is None: - raise MagpylibMissingInput(f"Parameter `current` of {s} must be set.") - elif obj_type == "Dipole": - if s.moment is None: - raise MagpylibMissingInput(f"Parameter `moment` of {s} must be set.") - elif (obj_type == "CustomSource") and (custom_field is not None): - if s.field_func is None: + if src.field_func(custom_field, np.zeros((1, 3))) is None: raise MagpylibMissingInput( f"Cannot compute {custom_field}-field because input parameter" - f"`field_func` of {s} has undefined {custom_field}-field computation." + f"`field_func` of {src} has undefined {custom_field}-field computation." ) - if s.field_func(custom_field, np.zeros((1, 3))) is None: + elif isinstance(src, BaseMagnet): + if src.magnetization is None: raise MagpylibMissingInput( - f"Cannot compute {custom_field}-field because input parameter" - f"`field_func` of {s} has undefined {custom_field}-field computation." + f"Parameter `magnetization` of {src} must be set." ) + elif isinstance(src, BaseCurrent): + if src.current is None: + raise MagpylibMissingInput(f"Parameter `current` of {src} must be set.") + elif isinstance(src, Dipole): + if src.moment is None: + raise MagpylibMissingInput(f"Parameter `moment` of {src} must be set.") def check_format_pixel_agg(pixel_agg): diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index 46972acb0..1de7c8f36 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -20,7 +20,6 @@ class BaseDisplayRepr: """Provides the show and repr methods for all objects""" show = show - _object_type = None def _property_names_generator(self): """returns a generator with class properties only""" diff --git a/magpylib/_src/obj_classes/class_BaseExcitations.py b/magpylib/_src/obj_classes/class_BaseExcitations.py index 1ca8337dd..06d1c8a0c 100644 --- a/magpylib/_src/obj_classes/class_BaseExcitations.py +++ b/magpylib/_src/obj_classes/class_BaseExcitations.py @@ -8,13 +8,15 @@ from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.utility import format_star_input +from magpylib._src.utility import Registered -class BaseSource(BaseGeo, BaseDisplayRepr): +class BaseSource(BaseGeo, BaseDisplayRepr, metaclass=Registered): """Base class for all types of sources. Provides getB and getH methods for source objects and corresponding field function""" _field_func = None + _field_func_kwargs_ndim = {} _editable_field_func = False def __init__(self, position, orientation, field_func=None, style=None, **kwargs): @@ -184,7 +186,7 @@ def getH(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): ) -class BaseHomMag(BaseSource): +class BaseMagnet(BaseSource): """provides the magnetization attribute for homogeneously magnetized magnets""" def __init__(self, position, orientation, magnetization, style, **kwargs): diff --git a/magpylib/_src/obj_classes/class_BaseGeo.py b/magpylib/_src/obj_classes/class_BaseGeo.py index 4ae518309..7a7b2e619 100644 --- a/magpylib/_src/obj_classes/class_BaseGeo.py +++ b/magpylib/_src/obj_classes/class_BaseGeo.py @@ -11,6 +11,7 @@ from magpylib._src.input_checks import check_format_input_orientation from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseTransform import BaseTransform +from magpylib._src.style import BaseStyle from magpylib._src.utility import add_iteration_suffix @@ -60,6 +61,8 @@ class BaseGeo(BaseTransform): """ + _style_class = BaseStyle + def __init__( self, position=( @@ -76,8 +79,6 @@ def __init__( # set _position and _orientation attributes self._init_position_orientation(position, orientation) - # style - self.style_class = self._get_style_class() if style is not None or kwargs: # avoid style creation cost if not needed self.style = self._process_style_kwargs(style=style, **kwargs) @@ -129,15 +130,6 @@ def _init_position_orientation(self, position, orientation): self._position = pos self._orientation = R.from_quat(oriQ) - def _get_style_class(self): - """returns style class based on object type. If class has no attribute `_object_type` or is - not found in `MAGPYLIB_FAMILIES` returns `BaseStyle` class. - """ - # pylint: disable=import-outside-toplevel - from magpylib._src.style import get_style_class - - return get_style_class(self) - # properties ---------------------------------------------------- @property def parent(self): @@ -146,7 +138,10 @@ def parent(self): @parent.setter def parent(self, inp): - if getattr(inp, "_object_type", "") == "Collection": + # pylint: disable=import-outside-toplevel + from magpylib._src.obj_classes.class_Collection import Collection + + if isinstance(inp, Collection): inp.add(self, override_parent=True) elif inp is None: if self._parent is not None: @@ -261,10 +256,10 @@ def _validate_style(self, val=None): if val is None: val = {} if isinstance(val, dict): - val = self.style_class(**val) - if not isinstance(val, self.style_class): + val = self._style_class(**val) + if not isinstance(val, self._style_class): raise ValueError( - f"Input parameter `style` must be of type {self.style_class}.\n" + f"Input parameter `style` must be of type {self._style_class}.\n" f"Instead received type {type(val)}" ) return val diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 18c334f08..3a45b6793 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -1,5 +1,6 @@ """Collection class code""" # pylint: disable=redefined-builtin +# pylint: disable=import-outside-toplevel from collections import Counter from magpylib._src.defaults.defaults_utility import validate_style_keys @@ -10,7 +11,6 @@ from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.utility import format_obj_input from magpylib._src.utility import rec_obj_remover -from magpylib._src.utility import Registered def repr_obj(obj, format="type+id+label"): @@ -24,14 +24,14 @@ def repr_obj(obj, format="type+id+label"): tag = "" if show_type: - tag += f"{obj._object_type}" + tag += f"{type(obj).__name__}" if show_label: if show_type: tag += " " label = getattr(getattr(obj, "style", None), "label", None) if label is None: - label = "nolabel" if show_type else f"{obj._object_type}" + label = "nolabel" if show_type else f"{type(obj).__name__}" tag += label if show_id: @@ -62,7 +62,7 @@ def collection_tree_generator( children = getattr(obj, "children", []) if len(children) > max_elems: # replace with counter if too many - counts = Counter([c._object_type for c in children]) + counts = Counter([type(c).__name__ for c in children]) children = [f"{v}x {k}s" for k, v in counts.items()] props = [] @@ -122,8 +122,6 @@ class BaseCollection(BaseDisplayRepr): def __init__(self, *children, override_parent=False): - self._object_type = "Collection" - BaseDisplayRepr.__init__(self) self._children = [] @@ -327,7 +325,7 @@ def add(self, *children, override_parent=False): # assign parent for obj in obj_list: - if obj._object_type == "Collection": + if isinstance(obj, Collection): # no need to check recursively with `collections_all` if obj is already self if obj is self or self in obj.collections_all: raise MagpylibBadUserInput( @@ -351,16 +349,15 @@ def add(self, *children, override_parent=False): return self def _update_src_and_sens(self): - # pylint: disable=protected-access """updates sources, sensors and collections attributes from children""" - self._sources = [ - obj for obj in self._children if obj._object_type in Registered.sources - ] - self._sensors = [ - obj for obj in self._children if obj._object_type in Registered.sensors - ] + # pylint: disable=protected-access + from magpylib._src.obj_classes.class_BaseExcitations import BaseSource + from magpylib._src.obj_classes.class_Sensor import Sensor + + self._sources = [obj for obj in self._children if isinstance(obj, BaseSource)] + self._sensors = [obj for obj in self._children if isinstance(obj, Sensor)] self._collections = [ - obj for obj in self._children if obj._object_type == "Collection" + obj for obj in self._children if isinstance(obj, Collection) ] def remove(self, *children, recursive=True, errors="raise"): @@ -488,7 +485,7 @@ def set_children_styles(self, arg=None, recursive=True, _validate=True, **kwargs for child in self._children: # match properties false will try to apply properties from kwargs only if it finds it # without throwing an error - if child._object_type == "Collection" and recursive: + if isinstance(child, Collection) and recursive: self.__class__.set_children_styles(child, style_kwargs, _validate=False) style_kwargs_specific = { k: v diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index 0e02defd1..dc3899fb8 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -5,11 +5,10 @@ from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo +from magpylib._src.style import SensorStyle from magpylib._src.utility import format_star_input -from magpylib._src.utility import Registered -@Registered(kind="sensor", family="sensor") class Sensor(BaseGeo, BaseDisplayRepr): """Magnetic field sensor. @@ -76,6 +75,8 @@ class Sensor(BaseGeo, BaseDisplayRepr): [0. 1.01415383 1.01415383]] """ + _style_class = SensorStyle + def __init__( self, position=(0, 0, 0), diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 50e771987..00aae5273 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -4,19 +4,9 @@ from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.input_checks import check_format_input_vertices from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent -from magpylib._src.utility import Registered +from magpylib._src.style import CurrentStyle -@Registered( - kind="source", - family="current", - source_kwargs_ndim={ - "current": 1, - "vertices": 3, - "segment_start": 2, - "segment_end": 2, - }, -) class Line(BaseCurrent): """Current flowing in straight lines from vertex to vertex. @@ -95,6 +85,13 @@ class Line(BaseCurrent): # pylint: disable=dangerous-default-value _field_func = staticmethod(current_vertices_field) + _field_func_kwargs_ndim = { + "current": 1, + "vertices": 3, + "segment_start": 2, + "segment_end": 2, + } + _style_class = CurrentStyle def __init__( self, diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index 1cdb0bdee..747ed28ea 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -4,15 +4,9 @@ from magpylib._src.fields.field_BH_loop import current_loop_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent -from magpylib._src.utility import Registered +from magpylib._src.style import CurrentStyle -@Registered( - kind="source", - family="current", - field_func=current_loop_field, - source_kwargs_ndim={"current": 1, "diameter": 1}, -) class Loop(BaseCurrent): """Circular current loop. @@ -86,6 +80,8 @@ class Loop(BaseCurrent): """ _field_func = staticmethod(current_loop_field) + _field_func_kwargs_ndim = {"current": 1, "diameter": 1} + _style_class = CurrentStyle def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py index debdf7cae..0283a83a6 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -3,16 +3,11 @@ """ from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field from magpylib._src.input_checks import check_format_input_vector -from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.utility import Registered +from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet +from magpylib._src.style import MagnetStyle -@Registered( - kind="source", - family="magnet", - source_kwargs_ndim={"magnetization": 2, "dimension": 2}, -) -class Cuboid(BaseHomMag): +class Cuboid(BaseMagnet): """Cuboid magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -86,6 +81,8 @@ class Cuboid(BaseHomMag): """ _field_func = staticmethod(magnet_cuboid_field) + _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} + _style_class = MagnetStyle def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py index 1e243693d..98a85d0d7 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -3,16 +3,11 @@ """ from magpylib._src.fields.field_BH_cylinder_segment import magnet_cylinder_field from magpylib._src.input_checks import check_format_input_vector -from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.utility import Registered +from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet +from magpylib._src.style import MagnetStyle -@Registered( - kind="source", - family="magnet", - source_kwargs_ndim={"magnetization": 2, "dimension": 2}, -) -class Cylinder(BaseHomMag): +class Cylinder(BaseMagnet): """Cylinder magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -86,6 +81,8 @@ class Cylinder(BaseHomMag): """ _field_func = staticmethod(magnet_cylinder_field) + _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} + _style_class = MagnetStyle def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index 4d1929bb8..7e3275044 100644 --- a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -7,16 +7,11 @@ magnet_cylinder_segment_field_internal, ) from magpylib._src.input_checks import check_format_input_cylinder_segment -from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.utility import Registered +from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet +from magpylib._src.style import MagnetStyle -@Registered( - kind="source", - family="magnet", - source_kwargs_ndim={"magnetization": 2, "dimension": 2}, -) -class CylinderSegment(BaseHomMag): +class CylinderSegment(BaseMagnet): """Cylinder segment (ring-section) magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -95,6 +90,8 @@ class CylinderSegment(BaseHomMag): """ _field_func = staticmethod(magnet_cylinder_segment_field_internal) + _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} + _style_class = MagnetStyle def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py index 8ae81e592..146f2fa40 100644 --- a/magpylib/_src/obj_classes/class_magnet_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -3,16 +3,11 @@ """ from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.input_checks import check_format_input_scalar -from magpylib._src.obj_classes.class_BaseExcitations import BaseHomMag -from magpylib._src.utility import Registered +from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet +from magpylib._src.style import MagnetStyle -@Registered( - kind="source", - family="magnet", - source_kwargs_ndim={"magnetization": 2, "diameter": 1}, -) -class Sphere(BaseHomMag): +class Sphere(BaseMagnet): """Spherical magnet with homogeneous magnetization. Can be used as `sources` input for magnetic field computation. @@ -86,6 +81,8 @@ class Sphere(BaseHomMag): """ _field_func = staticmethod(magnet_sphere_field) + _field_func_kwargs_ndim = {"magnetization": 2, "diameter": 1} + _style_class = MagnetStyle def __init__( self, diff --git a/magpylib/_src/obj_classes/class_misc_CustomSource.py b/magpylib/_src/obj_classes/class_misc_CustomSource.py index 2ca67c722..ea7b2f134 100644 --- a/magpylib/_src/obj_classes/class_misc_CustomSource.py +++ b/magpylib/_src/obj_classes/class_misc_CustomSource.py @@ -1,9 +1,7 @@ """Custom class code """ from magpylib._src.obj_classes.class_BaseExcitations import BaseSource -from magpylib._src.utility import Registered -@Registered(kind="source", family="misc") class CustomSource(BaseSource): """User-defined custom source. diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index c5f37c7ae..59047e530 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -4,14 +4,9 @@ from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseExcitations import BaseSource -from magpylib._src.utility import Registered +from magpylib._src.style import DipoleStyle -@Registered( - kind="source", - family="dipole", - source_kwargs_ndim={"moment": 2}, -) class Dipole(BaseSource): """Magnetic dipole moment. @@ -83,6 +78,8 @@ class Dipole(BaseSource): """ _field_func = staticmethod(dipole_field) + _field_func_kwargs_ndim = {"moment": 2} + _style_class = DipoleStyle def __init__( self, diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 412d08f32..f753cef59 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -11,16 +11,6 @@ from magpylib._src.defaults.defaults_utility import SYMBOLS_MATPLOTLIB_TO_PLOTLY from magpylib._src.defaults.defaults_utility import validate_property_class from magpylib._src.defaults.defaults_utility import validate_style_keys -from magpylib._src.utility import Registered - - -def get_style_class(obj): - """Returns style instance based on object type. If object has no attribute `_object_type` or is - not found in `Registered.famillies` returns `BaseStyle` instance. - """ - obj_type = getattr(obj, "_object_type", None) - style_fam = Registered.families.get(obj_type, None) - return STYLE_CLASSES.get(style_fam, BaseStyle) def get_style(obj, default_settings, **kwargs): @@ -29,6 +19,29 @@ def get_style(obj, default_settings, **kwargs): - style from object - style from kwargs arguments """ + # pylint: disable=import-outside-toplevel + from magpylib._src.obj_classes.class_BaseExcitations import ( + BaseMagnet as MagpyMagnet, + ) + from magpylib._src.obj_classes.class_BaseExcitations import ( + BaseCurrent as MagpyCurrent, + ) + from magpylib._src.obj_classes.class_misc_Dipole import Dipole as MagpyDipole + from magpylib._src.obj_classes.class_Sensor import Sensor as MagpySensor + from magpylib._src.display.display_utility import MagpyMarkers + + families = { + "magnet": MagpyMagnet, + "current": MagpyCurrent, + "dipole": MagpyDipole, + "sensor": MagpySensor, + "markers": MagpyMarkers, + } + obj_family = None + for fam, cls in families.items(): + if isinstance(obj, cls): + obj_family = fam + break # parse kwargs into style an non-style arguments style_kwargs = kwargs.get("style", {}) style_kwargs.update( @@ -41,8 +54,6 @@ def get_style(obj, default_settings, **kwargs): styles_by_family = default_settings.display.style.as_dict() # construct object specific dictionary base on style family and default style - obj_type = getattr(obj, "_object_type", None) - obj_family = Registered.families.get(obj_type, None) obj_style_default_dict = { **styles_by_family["base"], **dict(styles_by_family.get(obj_family, {}).items()), @@ -1598,11 +1609,3 @@ def markers(self): @markers.setter def markers(self, val): self._markers = validate_property_class(val, "markers", Markers, self) - - -STYLE_CLASSES = { - "magnet": MagnetStyle, - "current": CurrentStyle, - "dipole": DipoleStyle, - "sensor": SensorStyle, -} diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index ad59a0852..563e4dc30 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -1,4 +1,5 @@ """ some utility functions""" +# pylint: disable=import-outside-toplevel # import numbers from math import log10 from typing import Sequence @@ -8,59 +9,39 @@ from magpylib._src.exceptions import MagpylibBadUserInput -class Registered: - """Class decorator to register sources or sensors - - Sources get their field function assigned""" - - sensors = {} - sources = {} - families = {} - source_kwargs_ndim = {} - - def __init__(self, *, kind, family, field_func=None, source_kwargs_ndim=None): - self.kind = kind - self.family = family - self.field_func = field_func - self.source_kwargs_ndim_new = ( - {} if source_kwargs_ndim is None else source_kwargs_ndim - ) - - def __call__(self, klass): - name = klass.__name__ - setattr(klass, "_object_type", name) - setattr(klass, "_family", self.family) - setattr( - klass, - "family", - property( - lambda self: getattr(self, "_family"), - doc="""The object family (e.g. 'magnet', 'current', 'misc')""", - ), - ) - self.families[name] = self.family - - if self.kind == "sensor": - self.sensors[name] = klass - - elif self.kind == "source": - self.sources[name] = klass - if name not in self.source_kwargs_ndim: - self.source_kwargs_ndim[name] = { - "position": 2, - "orientation": 2, - "observers": 2, - } - self.source_kwargs_ndim[name].update(self.source_kwargs_ndim_new) - return klass +class Registered(type): + """Metaclass to register subclasses""" + + def __init__(cls, name, bases, nmspc): + super().__init__(name, bases, nmspc) + if not hasattr(cls, "registry"): + cls.registry = set() + if not hasattr(cls, "registry_names"): + cls.registry_names = {} + cls.registry.add(cls) + cls.registry -= set(bases) # Remove base classes + + # Metamethods, called on class objects: + def __iter__(cls): + return iter(cls.registry) + + def __str__(cls): + if cls in cls.registry: + return cls.__name__ + # pylint: disable=not-an-iterable + return cls.__name__ + ": " + ", ".join([sc.__name__ for sc in cls]) def get_allowed_sources_msg(): "Return allowed source message" + from magpylib._src.obj_classes.class_BaseExcitations import BaseSource + + srcs = [src.__name__ for src in BaseSource] return f"""Sources must be either -- one of type {list(Registered.sources)} +- one of type {srcs} - Collection with at least one of the above - 1D list of the above -- string {list(Registered.sources)}""" +- string {srcs}""" ALLOWED_OBSERVER_MSG = """Observers must be either @@ -115,15 +96,14 @@ def format_obj_input(*objects: Sequence, allow="sources+sensors", warn=True) -> ### Info: - exits if invalid sources are given """ - # pylint: disable=protected-access + from magpylib._src.obj_classes.class_BaseExcitations import BaseSource + from magpylib._src.obj_classes.class_Sensor import Sensor obj_list = [] flatten_collection = not "collections" in allow.split("+") for obj in objects: try: - if getattr(obj, "_object_type", None) in list(Registered.sources) + list( - Registered.sensors - ): + if isinstance(obj, (BaseSource, Sensor)): obj_list += [obj] else: if flatten_collection or isinstance(obj, (list, tuple)): @@ -155,7 +135,9 @@ def format_src_inputs(sources) -> list: ### Info: - raises an error if sources format is bad """ - # pylint: disable=protected-access + + from magpylib._src.obj_classes.class_BaseExcitations import BaseSource + from magpylib._src.obj_classes.class_Collection import Collection # store all sources here src_list = [] @@ -168,13 +150,12 @@ def format_src_inputs(sources) -> list: raise MagpylibBadUserInput(wrong_obj_msg(allow="sources")) for src in sources: - obj_type = getattr(src, "_object_type", "") - if obj_type == "Collection": + if isinstance(src, Collection): child_sources = format_obj_input(src, allow="sources") if not child_sources: raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources")) src_list += child_sources - elif obj_type in list(Registered.sources): + elif isinstance(src, BaseSource): src_list += [src] else: raise MagpylibBadUserInput(wrong_obj_msg(src, allow="sources")) @@ -243,18 +224,21 @@ def filter_objects(obj_list, allow="sources+sensors", warn=True): """ return only allowed objects - e.g. no sensors. Throw a warning when something is eliminated. """ - # pylint: disable=protected-access - allowed_list = [] - for allowed in allow.split("+"): - if allowed == "sources": - allowed_list.extend(list(Registered.sources)) - elif allowed == "sensors": - allowed_list.extend(list(Registered.sensors)) - elif allowed == "collections": - allowed_list.extend(["Collection"]) + from magpylib._src.obj_classes.class_BaseExcitations import BaseSource + from magpylib._src.obj_classes.class_Sensor import Sensor + from magpylib._src.obj_classes.class_Collection import Collection + + # select wanted + allowed_classes = () + if "sources" in allow.split("+"): + allowed_classes += (BaseSource,) + if "sensors" in allow.split("+"): + allowed_classes += (Sensor,) + if "collections" in allow.split("+"): + allowed_classes += (Collection,) new_list = [] for obj in obj_list: - if obj._object_type in allowed_list: + if isinstance(obj, allowed_classes): new_list += [obj] else: if warn: @@ -365,12 +349,14 @@ def cyl_field_to_cart(phi, Br, Bphi=None): def rec_obj_remover(parent, child): """remove known child from parent collection""" # pylint: disable=protected-access + from magpylib._src.obj_classes.class_Collection import Collection + for obj in parent: if obj == child: parent._children.remove(child) parent._update_src_and_sens() return True - if getattr(obj, "_object_type", "") == "Collection": + if isinstance(obj, Collection): if rec_obj_remover(obj, child): break return None diff --git a/tests/test_exceptions.py b/tests/test_exceptions.py index cbc843eb8..9cfbb2c0e 100644 --- a/tests/test_exceptions.py +++ b/tests/test_exceptions.py @@ -1,11 +1,9 @@ import unittest import numpy as np -from scipy.spatial.transform import Rotation as R import magpylib as magpy from magpylib._src.exceptions import MagpylibBadUserInput -from magpylib._src.exceptions import MagpylibInternalError from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_observers from magpylib._src.utility import format_obj_input @@ -53,18 +51,11 @@ def getBH_level2_bad_input2(): magpy.getB(pm1, [sens1, sens2]) -def getBH_level2_internal_error1(): - """somehow an unrecognized objects end up in get_src_dict""" - # pylint: disable=protected-access - sens = magpy.Sensor() - x = np.zeros((10, 3)) - magpy._src.fields.field_wrap_BH.get_src_dict([sens], 10, 10, x) - - # getBHv missing inputs ------------------------------------------------------ def getBHv_missing_input1(): """missing field""" x = np.array([(1, 2, 3)]) + # pylint: disable=missing-kwoa getBH_level2( sources="Cuboid", observers=x, magnetization=x, dimension=x, **GETBH_KWARGS ) @@ -288,7 +279,6 @@ def test_except_getBH_lev2(self): """getBH_level2 exception testing""" self.assertRaises(MagpylibBadUserInput, getBH_level2_bad_input1) self.assertRaises(MagpylibBadUserInput, getBH_level2_bad_input2) - self.assertRaises(MagpylibInternalError, getBH_level2_internal_error1) def test_except_bad_input_shape_basegeo(self): """BaseGeo bad input shapes""" diff --git a/tests/test_input_checks.py b/tests/test_input_checks.py index 56afcfd8f..5402a7a4f 100644 --- a/tests/test_input_checks.py +++ b/tests/test_input_checks.py @@ -755,7 +755,7 @@ def test_input_collection_good(): for good in goods: col = magpy.Collection(*good) - assert getattr(col, "_object_type", "") == "Collection" + assert isinstance(col, magpy.Collection) def test_input_collection_bad(): @@ -797,7 +797,7 @@ def test_input_collection_add_good(): for good in goods: col = magpy.Collection() col.add(*good) - assert getattr(col, "_object_type", "") == "Collection" + assert isinstance(col, magpy.Collection) def test_input_collection_add_bad(): diff --git a/tests/test_obj_BaseGeo.py b/tests/test_obj_BaseGeo.py index 40b75206a..6bafad0f8 100644 --- a/tests/test_obj_BaseGeo.py +++ b/tests/test_obj_BaseGeo.py @@ -386,19 +386,17 @@ def test_copy_parents(): def test_copy_order(): """Make sure copying objects of a collection does not affect order of children (#530)""" - from magpylib.magnet import Cuboid - from magpylib import Collection - thing1 = Cuboid(style_label="t1") - thing2 = Cuboid(style_label="t2") - thing3 = Cuboid(style_label="t3") - foo = Collection(thing1, thing2, thing3) + thing1 = magpy.magnet.Cuboid(style_label="t1") + thing2 = magpy.magnet.Cuboid(style_label="t2") + thing3 = magpy.magnet.Cuboid(style_label="t3") + coll = magpy.Collection(thing1, thing2, thing3) - desc_before = foo.describe(format="label", return_string=True) + desc_before = coll.describe(format="label", return_string=True) thing1.copy() - desc_after = foo.describe(format="label", return_string=True) + desc_after = coll.describe(format="label", return_string=True) assert desc_after == desc_before @@ -418,7 +416,7 @@ def test_describe(): test = ( "
Cuboid(id=REGEX, label='x1')
• parent: None
• " "position: [0. 0. 0.] mm
• orientation: [0. 0. 0.] degrees
• " - "dimension: None mm
• magnetization: None mT
• family: magnet
" + "dimension: None mm
• magnetization: None mT" ) rep = x1._repr_html_() rep = re.sub("id=[0-9]*[0-9]", "id=REGEX", rep) @@ -432,7 +430,6 @@ def test_describe(): " • orientation: [0. 0. 0.] degrees", " • dimension: None mm", " • magnetization: None mT", - " • family: magnet ", # INVISIBLE SPACE ] desc = x1.describe(return_string=True) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) @@ -445,7 +442,6 @@ def test_describe(): " • orientation: [0. 0. 0.] degrees", " • dimension: [1. 3.] mm", " • magnetization: [2. 3. 4.] mT", - " • family: magnet ", # INVISIBLE SPACE ] desc = x2.describe(return_string=True) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) @@ -457,7 +453,6 @@ def test_describe(): " • path length: 3", " • position (last): [1. 2. 3.] mm", " • orientation (last): [0. 0. 0.] degrees", - " • family: sensor ", # INVISIBLE SPACE " • pixel: 15 ", # INVISIBLE SPACE ] desc = s1.describe(return_string=True) @@ -472,7 +467,6 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" - + " • family: sensor \n" + " • pixel: 1 \n" + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," @@ -493,7 +487,6 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" - + " • family: sensor \n" + " • pixel: 1 \n" + " • style: SensorStyle(arrows=ArrowCS(x=ArrowSingle(color=None, show=True), " + "y=ArrowSingle(color=None, show=True), z=ArrowSingle(color=None, show=True))," @@ -514,7 +507,6 @@ def test_describe(): + " • parent: None \n" + " • position: [0. 0. 0.] mm\n" + " • orientation: [0. 0. 0.] degrees\n" - + " • family: sensor \n" + " • pixel: 75 (3x5x5) " ) desc = re.sub("id=*[0-9]*[0-9]", "id=REGEX", desc) diff --git a/tests/test_obj_Collection.py b/tests/test_obj_Collection.py index 293755100..94fd5442d 100644 --- a/tests/test_obj_Collection.py +++ b/tests/test_obj_Collection.py @@ -417,13 +417,11 @@ def test_collection_describe(): "│ • orientation: [0. 0. 0.] degrees", "│ • dimension: None mm", "│ • magnetization: None mT", - "│ • family: magnet", "└── y", " • position: [0. 0. 0.] mm", " • orientation: [0. 0. 0.] degrees", " • dimension: None mm", " • magnetization: None mT", - " • family: magnet", ] assert "".join(test) == re.sub("id=*[0-9]*[0-9]", "id=REGEX", "".join(desc)) From 6937acc3b18f3a5e7976dec42d5d34ac65be99a7 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 24 Jul 2022 17:13:12 +0200 Subject: [PATCH 203/207] fix subclassing sideeffect with metaclass e.g. subclasssing Cuboid with MyCuboid, adds MyCuboid but removes Cuboid --- magpylib/_src/fields/field_wrap_BH.py | 4 +- .../_src/obj_classes/class_BaseExcitations.py | 3 +- magpylib/_src/utility.py | 52 ++++++++++--------- tests/test_getBH_dict.py | 16 ++++++ 4 files changed, 46 insertions(+), 29 deletions(-) diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index b434a35b3..cf6d324ab 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -56,6 +56,7 @@ from magpylib._src.utility import check_static_sensor_orient from magpylib._src.utility import format_obj_input from magpylib._src.utility import format_src_inputs +from magpylib._src.utility import get_registered_sources def tile_group_property(group: list, n_pp: int, prop_name: str): @@ -443,10 +444,9 @@ def getBH_dict_level2( # which tells the program which dimension it should tile up. # pylint: disable=import-outside-toplevel - from magpylib._src.obj_classes.class_BaseExcitations import BaseSource try: - source_classes = {c.__name__: c for c in BaseSource.registry} + source_classes = get_registered_sources() field_func = source_classes[source_type]._field_func field_func_kwargs_ndim = {"position": 2, "orientation": 2, "observers": 2} field_func_kwargs_ndim.update( diff --git a/magpylib/_src/obj_classes/class_BaseExcitations.py b/magpylib/_src/obj_classes/class_BaseExcitations.py index 06d1c8a0c..6708ec019 100644 --- a/magpylib/_src/obj_classes/class_BaseExcitations.py +++ b/magpylib/_src/obj_classes/class_BaseExcitations.py @@ -8,10 +8,9 @@ from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo from magpylib._src.utility import format_star_input -from magpylib._src.utility import Registered -class BaseSource(BaseGeo, BaseDisplayRepr, metaclass=Registered): +class BaseSource(BaseGeo, BaseDisplayRepr): """Base class for all types of sources. Provides getB and getH methods for source objects and corresponding field function""" diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 563e4dc30..5cf358c1e 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -9,34 +9,10 @@ from magpylib._src.exceptions import MagpylibBadUserInput -class Registered(type): - """Metaclass to register subclasses""" - - def __init__(cls, name, bases, nmspc): - super().__init__(name, bases, nmspc) - if not hasattr(cls, "registry"): - cls.registry = set() - if not hasattr(cls, "registry_names"): - cls.registry_names = {} - cls.registry.add(cls) - cls.registry -= set(bases) # Remove base classes - - # Metamethods, called on class objects: - def __iter__(cls): - return iter(cls.registry) - - def __str__(cls): - if cls in cls.registry: - return cls.__name__ - # pylint: disable=not-an-iterable - return cls.__name__ + ": " + ", ".join([sc.__name__ for sc in cls]) - - def get_allowed_sources_msg(): "Return allowed source message" - from magpylib._src.obj_classes.class_BaseExcitations import BaseSource - srcs = [src.__name__ for src in BaseSource] + srcs = list(get_registered_sources()) return f"""Sources must be either - one of type {srcs} - Collection with at least one of the above @@ -360,3 +336,29 @@ def rec_obj_remover(parent, child): if rec_obj_remover(obj, child): break return None + + +def get_subclasses(cls, recursive=False): + """Return a dictionary of subclasses by name,""" + sub_cls = {} + for class_ in cls.__subclasses__(): + sub_cls[class_.__name__] = class_ + if recursive: + sub_cls.update(get_subclasses(class_, recursive=recursive)) + return sub_cls + + +def get_registered_sources(): + """Return all registered sources""" + # pylint: disable=import-outside-toplevel + from magpylib._src.obj_classes.class_BaseExcitations import ( + BaseCurrent, + BaseMagnet, + BaseSource, + ) + + return { + k: v + for k, v in get_subclasses(BaseSource, recursive=True).items() + if not v in (BaseCurrent, BaseMagnet, BaseSource) + } diff --git a/tests/test_getBH_dict.py b/tests/test_getBH_dict.py index 6909b415f..0be5ec244 100644 --- a/tests/test_getBH_dict.py +++ b/tests/test_getBH_dict.py @@ -396,3 +396,19 @@ def test_getB_dict_over_getB(): dic["sources"] = pm with pytest.raises(MagpylibBadUserInput): magpylib.getB(**dic) + + +def test_subclassing(): + """Test side effects of suclasssing a source""" + # pylint: disable=unused-variable + class MyCuboid(magpylib.magnet.Cuboid): + """Test subclass""" + + B1 = magpylib.getB( + "Cuboid", (0, 0, 0), magnetization=(1, 1, 1), dimension=(1, 1, 1) + ) + B2 = magpylib.getB( + "MyCuboid", (0, 0, 0), magnetization=(1, 1, 1), dimension=(1, 1, 1) + ) + + np.testing.assert_allclose(B1, B2) From 0065377982e6d1c5c9074d052281974a98e162ab Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Sun, 24 Jul 2022 23:59:59 +0200 Subject: [PATCH 204/207] pylint cycling import fix --- magpylib/_src/fields/field_wrap_BH.py | 1 + magpylib/_src/input_checks.py | 1 + magpylib/_src/obj_classes/class_BaseDisplayRepr.py | 1 + magpylib/_src/obj_classes/class_BaseExcitations.py | 1 + magpylib/_src/style.py | 1 + magpylib/_src/utility.py | 1 + 6 files changed, 6 insertions(+) diff --git a/magpylib/_src/fields/field_wrap_BH.py b/magpylib/_src/fields/field_wrap_BH.py index cf6d324ab..af3b72935 100644 --- a/magpylib/_src/fields/field_wrap_BH.py +++ b/magpylib/_src/fields/field_wrap_BH.py @@ -40,6 +40,7 @@ level5(sens.getB, sens.getH): <--- USER INTERFACE """ +# pylint: disable=cyclic-import import numbers from itertools import product from typing import Callable diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py index be538f59e..4c7febe88 100644 --- a/magpylib/_src/input_checks.py +++ b/magpylib/_src/input_checks.py @@ -1,5 +1,6 @@ """ input checks code""" # pylint: disable=import-outside-toplevel +# pylint: disable=cyclic-import import inspect import numbers diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index 1de7c8f36..768ebe0c5 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -1,6 +1,7 @@ """BaseGeo class code READY FOR V4 """ +# pylint: disable=cyclic-import import numpy as np from magpylib._src.display.display import show diff --git a/magpylib/_src/obj_classes/class_BaseExcitations.py b/magpylib/_src/obj_classes/class_BaseExcitations.py index 6708ec019..e0ddb10f6 100644 --- a/magpylib/_src/obj_classes/class_BaseExcitations.py +++ b/magpylib/_src/obj_classes/class_BaseExcitations.py @@ -1,6 +1,7 @@ """BaseHomMag class code DOCSTRINGS V4 READY """ +# pylint: disable=cyclic-import from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.input_checks import check_format_input_vector diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index f753cef59..4acab726a 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -1,6 +1,7 @@ """Collection of classes for display styling.""" # pylint: disable=C0302 # pylint: disable=too-many-instance-attributes +# pylint: disable=cyclic-import import numpy as np from magpylib._src.defaults.defaults_utility import color_validator diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py index 5cf358c1e..5a93bd106 100644 --- a/magpylib/_src/utility.py +++ b/magpylib/_src/utility.py @@ -1,5 +1,6 @@ """ some utility functions""" # pylint: disable=import-outside-toplevel +# pylint: disable=cyclic-import # import numbers from math import log10 from typing import Sequence From 4b09f8a9daeac6d3507c464c26ccc5fb33e8e30a Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Mon, 25 Jul 2022 11:56:34 +0200 Subject: [PATCH 205/207] finish pull from refactor-getBH-temp --- magpylib/_src/display/traces_generic.py | 89 +++++++++---------- magpylib/_src/display/traces_utility.py | 11 --- .../_src/obj_classes/class_BaseDisplayRepr.py | 2 + magpylib/_src/obj_classes/class_Collection.py | 2 + magpylib/_src/obj_classes/class_Sensor.py | 3 + .../_src/obj_classes/class_current_Line.py | 2 + .../_src/obj_classes/class_current_Loop.py | 2 + .../_src/obj_classes/class_magnet_Cuboid.py | 2 + .../_src/obj_classes/class_magnet_Cylinder.py | 2 + .../class_magnet_CylinderSegment.py | 2 + .../_src/obj_classes/class_magnet_Sphere.py | 2 + .../_src/obj_classes/class_misc_Dipole.py | 3 + magpylib/_src/style.py | 2 +- 13 files changed, 67 insertions(+), 57 deletions(-) diff --git a/magpylib/_src/display/traces_generic.py b/magpylib/_src/display/traces_generic.py index fcffd46f9..bf7036702 100644 --- a/magpylib/_src/display/traces_generic.py +++ b/magpylib/_src/display/traces_generic.py @@ -29,16 +29,48 @@ from magpylib._src.display.traces_utility import getColorscale from magpylib._src.display.traces_utility import getIntensity from magpylib._src.display.traces_utility import group_traces -from magpylib._src.display.traces_utility import MagpyMarkers from magpylib._src.display.traces_utility import merge_mesh3d from magpylib._src.display.traces_utility import merge_traces from magpylib._src.display.traces_utility import place_and_orient_model3d from magpylib._src.input_checks import check_excitations from magpylib._src.style import get_style +from magpylib._src.style import Markers from magpylib._src.utility import format_obj_input from magpylib._src.utility import unit_prefix -AUTOSIZE_OBJECTS = ("Sensor", "Dipole") + +class MagpyMarkers: + """A class that stores markers 3D-coordinates""" + + def __init__(self, *markers): + self.style = Markers() + self.markers = np.array(markers) + + def _draw_func(self, color=None, style=None, **kwargs): + """Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the + provided arguments.""" + style = self.style if style is None else style + x, y, z = self.markers.T + marker_kwargs = { + f"marker_{k}": v + for k, v in style.marker.as_dict(flatten=True, separator="_").items() + } + marker_kwargs["marker_color"] = ( + style.marker.color if style.marker.color is not None else color + ) + trace = dict( + type="scatter3d", + x=x, + y=y, + z=z, + mode="markers", + **marker_kwargs, + **kwargs, + ) + default_name = "Marker" if len(x) == 1 else "Markers" + default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" + update_trace_name(trace, default_name, default_suffix, style) + return trace def make_DefaultTrace( @@ -400,33 +432,6 @@ def make_Sensor( ) -def make_MagpyMarkers(obj, color=None, style=None, **kwargs): - """Create the plotly mesh3d parameters for a Sensor object in a dictionary based on the - provided arguments.""" - style = obj.style if style is None else style - x, y, z = obj.markers.T - marker_kwargs = { - f"marker_{k}": v - for k, v in style.marker.as_dict(flatten=True, separator="_").items() - } - marker_kwargs["marker_color"] = ( - style.marker.color if style.marker.color is not None else color - ) - trace = dict( - type="scatter3d", - x=x, - y=y, - z=z, - mode="markers", - **marker_kwargs, - **kwargs, - ) - default_name = "Marker" if len(x) == 1 else "Markers" - default_suffix = "" if len(x) == 1 else f" ({len(x)} points)" - update_trace_name(trace, default_name, default_suffix, style) - return trace - - def update_magnet_mesh(mesh_dict, mag_style=None, magnetization=None): """ Updates an existing plotly mesh3d dictionary of an object which has a magnetic vector. The @@ -482,13 +487,11 @@ def make_mag_arrows(obj, style, legendgroup, kwargs): rots, _, inds = get_rot_pos_from_path(obj, style.path.frames) # vector length, color and magnetization - if obj._object_type in ("Cuboid", "Cylinder"): - length = 1.8 * np.amax(obj.dimension) - elif obj._object_type == "CylinderSegment": - length = 1.8 * np.amax(obj.dimension[:3]) # d1,d2,h - else: - length = 1.8 * obj.diameter # Sphere - length *= style.magnetization.size + if hasattr(obj, "diameter"): + length = obj.diameter # Sphere + else: # Cuboid, Cylinder, CylinderSegment + length = np.amax(obj.dimension[:3]) + length *= 1.8 * style.magnetization.size mag = obj.magnetization # collect all draw positions and directions points = [] @@ -550,7 +553,6 @@ def make_path(input_obj, style, legendgroup, kwargs): def get_generic_traces( input_obj, - make_func=None, color=None, autosize=None, legendgroup=None, @@ -577,6 +579,7 @@ def get_generic_traces( # pylint: disable=too-many-branches # pylint: disable=too-many-statements # pylint: disable=too-many-nested-blocks + # pylint: disable=protected-access # parse kwargs into style and non style args style = get_style(input_obj, Config, **kwargs) @@ -595,11 +598,9 @@ def get_generic_traces( label = getattr(getattr(input_obj, "style", None), "label", None) label = label if label is not None else str(type(input_obj).__name__) - object_type = getattr(input_obj, "_object_type", None) - if object_type != "Collection": - make_func = globals().get(f"make_{object_type}", make_DefaultTrace) + make_func = input_obj._draw_func make_func_kwargs = kwargs.copy() - if object_type in AUTOSIZE_OBJECTS: + if getattr(input_obj, "_autosize", False): make_func_kwargs["autosize"] = autosize traces = [] @@ -608,7 +609,7 @@ def get_generic_traces( path_traces_extra_specific_backend = [] has_path = hasattr(input_obj, "position") and hasattr(input_obj, "orientation") if not has_path: - traces = [make_func(input_obj, **make_func_kwargs)] + traces = [make_func(**make_func_kwargs)] out = (traces,) if extra_backend is not False: out += (path_traces_extra_specific_backend,) @@ -619,9 +620,7 @@ def get_generic_traces( for pos_orient_ind, (orient, pos) in enumerate(zip(orientations, positions)): if style.model3d.showdefault and make_func is not None: path_traces.append( - make_func( - input_obj, position=pos, orientation=orient, **make_func_kwargs - ) + make_func(position=pos, orientation=orient, **make_func_kwargs) ) for extr in extra_model3d_traces: if extr.show: diff --git a/magpylib/_src/display/traces_utility.py b/magpylib/_src/display/traces_utility.py index 5b1b6e974..429771f1a 100644 --- a/magpylib/_src/display/traces_utility.py +++ b/magpylib/_src/display/traces_utility.py @@ -8,17 +8,6 @@ from magpylib._src.defaults.defaults_classes import default_settings as Config from magpylib._src.defaults.defaults_utility import linearize_dict -from magpylib._src.style import Markers -from magpylib._src.utility import Registered - - -@Registered(kind="nonmodel", family="markers") -class MagpyMarkers: - """A class that stores markers 3D-coordinates""" - - def __init__(self, *markers): - self.style = Markers() - self.markers = np.array(markers) # pylint: disable=too-many-branches diff --git a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py index 768ebe0c5..71b65070e 100644 --- a/magpylib/_src/obj_classes/class_BaseDisplayRepr.py +++ b/magpylib/_src/obj_classes/class_BaseDisplayRepr.py @@ -5,6 +5,7 @@ import numpy as np from magpylib._src.display.display import show +from magpylib._src.display.traces_generic import make_DefaultTrace UNITS = { "parent": None, @@ -21,6 +22,7 @@ class BaseDisplayRepr: """Provides the show and repr methods for all objects""" show = show + _draw_func = make_DefaultTrace def _property_names_generator(self): """returns a generator with class properties only""" diff --git a/magpylib/_src/obj_classes/class_Collection.py b/magpylib/_src/obj_classes/class_Collection.py index 3a45b6793..df06ad538 100644 --- a/magpylib/_src/obj_classes/class_Collection.py +++ b/magpylib/_src/obj_classes/class_Collection.py @@ -120,6 +120,8 @@ def collection_tree_generator( class BaseCollection(BaseDisplayRepr): """Collection base class without BaseGeo properties""" + _draw_func = None + def __init__(self, *children, override_parent=False): BaseDisplayRepr.__init__(self) diff --git a/magpylib/_src/obj_classes/class_Sensor.py b/magpylib/_src/obj_classes/class_Sensor.py index dc3899fb8..7ead0500e 100644 --- a/magpylib/_src/obj_classes/class_Sensor.py +++ b/magpylib/_src/obj_classes/class_Sensor.py @@ -1,6 +1,7 @@ """Sensor class code DOCSTRINGS V4 READY """ +from magpylib._src.display.traces_generic import make_Sensor from magpylib._src.fields.field_wrap_BH import getBH_level2 from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr @@ -76,6 +77,8 @@ class Sensor(BaseGeo, BaseDisplayRepr): """ _style_class = SensorStyle + _autosize = True + _draw_func = make_Sensor def __init__( self, diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 00aae5273..80eda3307 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -1,6 +1,7 @@ """Line current class code DOCSTRINGS V4 READY """ +from magpylib._src.display.traces_generic import make_Line from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.input_checks import check_format_input_vertices from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent @@ -92,6 +93,7 @@ class Line(BaseCurrent): "segment_end": 2, } _style_class = CurrentStyle + _draw_func = make_Line def __init__( self, diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index 747ed28ea..b5d8f7fa8 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -1,6 +1,7 @@ """Loop current class code DOCSTRINGS V4 READY """ +from magpylib._src.display.traces_generic import make_Loop from magpylib._src.fields.field_BH_loop import current_loop_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent @@ -82,6 +83,7 @@ class Loop(BaseCurrent): _field_func = staticmethod(current_loop_field) _field_func_kwargs_ndim = {"current": 1, "diameter": 1} _style_class = CurrentStyle + _draw_func = make_Loop def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py index 0283a83a6..a99fd5210 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -1,6 +1,7 @@ """Magnet Cuboid class code DOCSTRINGS V4 READY """ +from magpylib._src.display.traces_generic import make_Cuboid from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet @@ -83,6 +84,7 @@ class Cuboid(BaseMagnet): _field_func = staticmethod(magnet_cuboid_field) _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} _style_class = MagnetStyle + _draw_func = make_Cuboid def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py index 98a85d0d7..d35bd7b25 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -1,6 +1,7 @@ """Magnet Cylinder class code DOCSTRINGS V4 READY """ +from magpylib._src.display.traces_generic import make_Cylinder from magpylib._src.fields.field_BH_cylinder_segment import magnet_cylinder_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet @@ -83,6 +84,7 @@ class Cylinder(BaseMagnet): _field_func = staticmethod(magnet_cylinder_field) _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} _style_class = MagnetStyle + _draw_func = make_Cylinder def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index 7e3275044..b02f0337c 100644 --- a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -3,6 +3,7 @@ """ import numpy as np +from magpylib._src.display.traces_generic import make_CylinderSegment from magpylib._src.fields.field_BH_cylinder_segment import ( magnet_cylinder_segment_field_internal, ) @@ -92,6 +93,7 @@ class CylinderSegment(BaseMagnet): _field_func = staticmethod(magnet_cylinder_segment_field_internal) _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} _style_class = MagnetStyle + _draw_func = make_CylinderSegment def __init__( self, diff --git a/magpylib/_src/obj_classes/class_magnet_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py index 146f2fa40..c499a7e8c 100644 --- a/magpylib/_src/obj_classes/class_magnet_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -1,6 +1,7 @@ """Magnet Sphere class code DOCSTRINGS V4 READY """ +from magpylib._src.display.traces_generic import make_Sphere from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet @@ -83,6 +84,7 @@ class Sphere(BaseMagnet): _field_func = staticmethod(magnet_sphere_field) _field_func_kwargs_ndim = {"magnetization": 2, "diameter": 1} _style_class = MagnetStyle + _draw_func = make_Sphere def __init__( self, diff --git a/magpylib/_src/obj_classes/class_misc_Dipole.py b/magpylib/_src/obj_classes/class_misc_Dipole.py index 59047e530..15e96497d 100644 --- a/magpylib/_src/obj_classes/class_misc_Dipole.py +++ b/magpylib/_src/obj_classes/class_misc_Dipole.py @@ -1,6 +1,7 @@ """Dipole class code DOCSTRINGS V4 READY """ +from magpylib._src.display.traces_generic import make_Dipole from magpylib._src.fields.field_BH_dipole import dipole_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseExcitations import BaseSource @@ -80,6 +81,8 @@ class Dipole(BaseSource): _field_func = staticmethod(dipole_field) _field_func_kwargs_ndim = {"moment": 2} _style_class = DipoleStyle + _draw_func = make_Dipole + _autosize = True def __init__( self, diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py index 276905807..08270ec25 100644 --- a/magpylib/_src/style.py +++ b/magpylib/_src/style.py @@ -29,7 +29,7 @@ def get_style(obj, default_settings, **kwargs): ) from magpylib._src.obj_classes.class_misc_Dipole import Dipole as MagpyDipole from magpylib._src.obj_classes.class_Sensor import Sensor as MagpySensor - from magpylib._src.display.display_utility import MagpyMarkers + from magpylib._src.display.traces_generic import MagpyMarkers families = { "magnet": MagpyMagnet, From 4e3d9fb71746abfb851a7227e1b0a6d9ab07d149 Mon Sep 17 00:00:00 2001 From: Alexandre Boisselet Date: Tue, 26 Jul 2022 11:17:49 +0200 Subject: [PATCH 206/207] coverage --- tests/test_CustomSource.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/test_CustomSource.py b/tests/test_CustomSource.py index 6ee4c6279..0060ae0a0 100644 --- a/tests/test_CustomSource.py +++ b/tests/test_CustomSource.py @@ -1,4 +1,5 @@ import numpy as np +import pytest import magpylib as magpy @@ -49,6 +50,16 @@ def test_CustomSource_basicH(): np.testing.assert_allclose(H, Htest) +def test_CustomSource_None(): + "Set source field_func to None" + # pylint: disable=protected-access + external_field = magpy.misc.CustomSource(field_func=constant_field) + external_field.field_func = None + external_field._editable_field_func = False + with pytest.raises(AttributeError): + external_field.field_func = constant_field + + def test_repr(): """test __repr__""" dip = magpy.misc.CustomSource() From d3bb95b571a2bfad5474a4d4fd240d5c57ea06cd Mon Sep 17 00:00:00 2001 From: "Boisselet Alexandre (IFAT DC ATV SC D TE2)" Date: Tue, 26 Jul 2022 15:12:33 +0200 Subject: [PATCH 207/207] small refactoring of magnet and current style --- magpylib/_src/obj_classes/class_BaseExcitations.py | 6 ++++++ magpylib/_src/obj_classes/class_current_Line.py | 2 -- magpylib/_src/obj_classes/class_current_Loop.py | 2 -- magpylib/_src/obj_classes/class_magnet_Cuboid.py | 2 -- magpylib/_src/obj_classes/class_magnet_Cylinder.py | 2 -- magpylib/_src/obj_classes/class_magnet_CylinderSegment.py | 2 -- magpylib/_src/obj_classes/class_magnet_Sphere.py | 2 -- 7 files changed, 6 insertions(+), 12 deletions(-) diff --git a/magpylib/_src/obj_classes/class_BaseExcitations.py b/magpylib/_src/obj_classes/class_BaseExcitations.py index e0ddb10f6..374adfa12 100644 --- a/magpylib/_src/obj_classes/class_BaseExcitations.py +++ b/magpylib/_src/obj_classes/class_BaseExcitations.py @@ -8,6 +8,8 @@ from magpylib._src.input_checks import validate_field_func from magpylib._src.obj_classes.class_BaseDisplayRepr import BaseDisplayRepr from magpylib._src.obj_classes.class_BaseGeo import BaseGeo +from magpylib._src.style import CurrentStyle +from magpylib._src.style import MagnetStyle from magpylib._src.utility import format_star_input @@ -189,6 +191,8 @@ def getH(self, *observers, squeeze=True, pixel_agg=None, output="ndarray"): class BaseMagnet(BaseSource): """provides the magnetization attribute for homogeneously magnetized magnets""" + _style_class = MagnetStyle + def __init__(self, position, orientation, magnetization, style, **kwargs): super().__init__(position, orientation, style=style, **kwargs) self.magnetization = magnetization @@ -214,6 +218,8 @@ def magnetization(self, mag): class BaseCurrent(BaseSource): """provides scalar current attribute""" + _style_class = CurrentStyle + def __init__(self, position, orientation, current, style, **kwargs): super().__init__(position, orientation, style=style, **kwargs) self.current = current diff --git a/magpylib/_src/obj_classes/class_current_Line.py b/magpylib/_src/obj_classes/class_current_Line.py index 80eda3307..d7d66fb2d 100644 --- a/magpylib/_src/obj_classes/class_current_Line.py +++ b/magpylib/_src/obj_classes/class_current_Line.py @@ -5,7 +5,6 @@ from magpylib._src.fields.field_BH_line import current_vertices_field from magpylib._src.input_checks import check_format_input_vertices from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent -from magpylib._src.style import CurrentStyle class Line(BaseCurrent): @@ -92,7 +91,6 @@ class Line(BaseCurrent): "segment_start": 2, "segment_end": 2, } - _style_class = CurrentStyle _draw_func = make_Line def __init__( diff --git a/magpylib/_src/obj_classes/class_current_Loop.py b/magpylib/_src/obj_classes/class_current_Loop.py index b5d8f7fa8..d63b7ee5c 100644 --- a/magpylib/_src/obj_classes/class_current_Loop.py +++ b/magpylib/_src/obj_classes/class_current_Loop.py @@ -5,7 +5,6 @@ from magpylib._src.fields.field_BH_loop import current_loop_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseExcitations import BaseCurrent -from magpylib._src.style import CurrentStyle class Loop(BaseCurrent): @@ -82,7 +81,6 @@ class Loop(BaseCurrent): _field_func = staticmethod(current_loop_field) _field_func_kwargs_ndim = {"current": 1, "diameter": 1} - _style_class = CurrentStyle _draw_func = make_Loop def __init__( diff --git a/magpylib/_src/obj_classes/class_magnet_Cuboid.py b/magpylib/_src/obj_classes/class_magnet_Cuboid.py index a99fd5210..01d66ba10 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cuboid.py +++ b/magpylib/_src/obj_classes/class_magnet_Cuboid.py @@ -5,7 +5,6 @@ from magpylib._src.fields.field_BH_cuboid import magnet_cuboid_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet -from magpylib._src.style import MagnetStyle class Cuboid(BaseMagnet): @@ -83,7 +82,6 @@ class Cuboid(BaseMagnet): _field_func = staticmethod(magnet_cuboid_field) _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} - _style_class = MagnetStyle _draw_func = make_Cuboid def __init__( diff --git a/magpylib/_src/obj_classes/class_magnet_Cylinder.py b/magpylib/_src/obj_classes/class_magnet_Cylinder.py index d35bd7b25..a42208cdb 100644 --- a/magpylib/_src/obj_classes/class_magnet_Cylinder.py +++ b/magpylib/_src/obj_classes/class_magnet_Cylinder.py @@ -5,7 +5,6 @@ from magpylib._src.fields.field_BH_cylinder_segment import magnet_cylinder_field from magpylib._src.input_checks import check_format_input_vector from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet -from magpylib._src.style import MagnetStyle class Cylinder(BaseMagnet): @@ -83,7 +82,6 @@ class Cylinder(BaseMagnet): _field_func = staticmethod(magnet_cylinder_field) _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} - _style_class = MagnetStyle _draw_func = make_Cylinder def __init__( diff --git a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py index b02f0337c..255aa31c2 100644 --- a/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py +++ b/magpylib/_src/obj_classes/class_magnet_CylinderSegment.py @@ -9,7 +9,6 @@ ) from magpylib._src.input_checks import check_format_input_cylinder_segment from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet -from magpylib._src.style import MagnetStyle class CylinderSegment(BaseMagnet): @@ -92,7 +91,6 @@ class CylinderSegment(BaseMagnet): _field_func = staticmethod(magnet_cylinder_segment_field_internal) _field_func_kwargs_ndim = {"magnetization": 2, "dimension": 2} - _style_class = MagnetStyle _draw_func = make_CylinderSegment def __init__( diff --git a/magpylib/_src/obj_classes/class_magnet_Sphere.py b/magpylib/_src/obj_classes/class_magnet_Sphere.py index c499a7e8c..154f837a2 100644 --- a/magpylib/_src/obj_classes/class_magnet_Sphere.py +++ b/magpylib/_src/obj_classes/class_magnet_Sphere.py @@ -5,7 +5,6 @@ from magpylib._src.fields.field_BH_sphere import magnet_sphere_field from magpylib._src.input_checks import check_format_input_scalar from magpylib._src.obj_classes.class_BaseExcitations import BaseMagnet -from magpylib._src.style import MagnetStyle class Sphere(BaseMagnet): @@ -83,7 +82,6 @@ class Sphere(BaseMagnet): _field_func = staticmethod(magnet_sphere_field) _field_func_kwargs_ndim = {"magnetization": 2, "diameter": 1} - _style_class = MagnetStyle _draw_func = make_Sphere def __init__(