Repository: magpylib/magpylib

Issue:
[FEATURE] - CustomSource implementation 
Users should be able to implement their own custom source by defining a H and/or B-field functions. The idea behind it is to implement a source object originating from interpolated FEM or measurement data which can be treated as any classical object in the magpylib library, for instance, object rotation and displacement. The provided field function should only return a (n,3) array in the local object coordinate system, since the library takes care of orientation and displacement.

Sou
- [x] create CustomSource class
- [x] accepts `field_B_lambda` and `field_H_lambda` functions
- [x] validate inputs
- [x] docstring
- [x] docstring example
- [x] plotly representation
- [x] matplotlib representation
- [x] write tests

Current work going on at https://github.com/magpylib/magpylib/tree/custom_source

A code example can be added when docs_v4 and this branch are merged (#389)

Discussion:

Patch:
diff --git a/magpylib/_src/display/display.py b/magpylib/_src/display/display.py
index 014f3d0e..39e1c049 100644
--- a/magpylib/_src/display/display.py
+++ b/magpylib/_src/display/display.py
@@ -266,6 +266,11 @@ def display_matplotlib(
         elif obj._object_type == "Dipole":
             dipoles_color += [color]
             points += [obj.position]
+        elif obj._object_type == 'CustomSource':
+            draw_markers(np.array([obj.position]), ax, color, symbol='*', size=10)
+            name = obj.style.name if obj.style.name is not None else str(type(obj).__name__)
+            ax.text(*obj.position, name, horizontalalignment='center')
+            points += [obj.position]
         if faces is not None:
             faced_objects_color += [color]
             pts = draw_faces(faces, color, lw, ax)
@@ -293,7 +298,8 @@ def display_matplotlib(
         m = MagpyMarkers()
         style = get_style(m, Config, **kwargs)
         markers = np.array(markers)
-        draw_markers(markers, ax, style=style.marker)
+        s = style.marker
+        draw_markers(markers, ax, s.color, s.symbol, s.size)
         points += [markers]
 
     # draw direction arrows (based on src size) -------------------------
diff --git a/magpylib/_src/display/mpl_draw.py b/magpylib/_src/display/mpl_draw.py
index ad90d77d..d99beed8 100644
--- a/magpylib/_src/display/mpl_draw.py
+++ b/magpylib/_src/display/mpl_draw.py
@@ -71,16 +71,16 @@ def draw_directs_faced(faced_objects, colors, ax, show_path, size_direction):
         )
 
 
-def draw_markers(markers, ax, style):
+def draw_markers(markers, ax, color, symbol, size):
     """name = programm"""
     ax.plot(
         markers[:, 0],
         markers[:, 1],
         markers[:, 2],
-        color=style.color,
+        color=color,
         ls="",
-        marker=style.symbol,
-        ms=style.size,
+        marker=symbol,
+        ms=size,
     )
 
 
diff --git a/magpylib/_src/display/plotly_draw.py b/magpylib/_src/display/plotly_draw.py
index 42653899..3953d130 100644
--- a/magpylib/_src/display/plotly_draw.py
+++ b/magpylib/_src/display/plotly_draw.py
@@ -986,8 +986,17 @@ def get_plotly_traces(
         trace = merge_traces(*path_traces)
         trace_add_mesh3d = merge_traces(*path_traces_add_mesh3d)
         if trace_add_mesh3d:
+            name = (
+                input_obj.style.name
+                if input_obj.style.name is not None
+                else str(type(input_obj).__name__)
+            )
             trace_add_mesh3d.update(
-                {"legendgroup": f"{input_obj}", "showlegend": False}
+                {
+                    "legendgroup": f"{input_obj}",
+                    "showlegend": style.mesh3d.replace,
+                    "name": name,
+                }
             )
             traces.append(trace_add_mesh3d)
         if trace:
@@ -1128,7 +1137,7 @@ def display_plotly(
         name = getattr(style, "name", None)
         title = name if len(obj_list) == 1 else None
     else:
-        title = 'No objects to be displayed'
+        title = "No objects to be displayed"
 
     if markers is not None and markers:
         obj_list = list(obj_list) + [MagpyMarkers(*markers)]
diff --git a/magpylib/_src/fields/field_wrap_BH_level1.py b/magpylib/_src/fields/field_wrap_BH_level1.py
index 21d076b2..e7b48741 100644
--- a/magpylib/_src/fields/field_wrap_BH_level1.py
+++ b/magpylib/_src/fields/field_wrap_BH_level1.py
@@ -82,6 +82,15 @@ def getBH_level1(**kwargs:dict) -> np.ndarray:
             pos_end = kwargs['segment_end']
             B = field_BH_line(bh, current, pos_start, pos_end, pos_rel_rot)
 
+    elif src_type == 'CustomSource':
+        bh_key = 'B' if bh else 'H'
+        if kwargs[f'field_{bh_key}_lambda'] is not None:
+            B = kwargs[f'field_{bh_key}_lambda'](pos_rel_rot)
+        else:
+            raise MagpylibInternalError(
+                f'{bh_key}-field calculation not implemented for CustomSource class'
+            )
+
     else:
         raise MagpylibInternalError(f'Bad src input type "{src_type}" in level1')
 
diff --git a/magpylib/_src/fields/field_wrap_BH_level2.py b/magpylib/_src/fields/field_wrap_BH_level2.py
index bf9c9865..35675bbd 100644
--- a/magpylib/_src/fields/field_wrap_BH_level2.py
+++ b/magpylib/_src/fields/field_wrap_BH_level2.py
@@ -85,50 +85,53 @@ def get_src_dict(group: list, n_pix: int, n_pp: int, poso: np.ndarray) -> dict:
     # determine which group we are dealing with and tile up dim and exitation
     src_type = group[0]._object_type
 
+    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)
-        return {'source_type':src_type, 'magnetization':magv, 'diameter':diav, 'position':posv,
-            'observer': posov, 'orientation':rotobj}
+        kwargs.update({'magnetization':magv, 'diameter':diav})
 
-    if src_type == 'Cuboid':
+    elif src_type == 'Cuboid':
         magv = tile_mag(group, n_pp)
         dimv = tile_dim_cuboid(group, n_pp)
-        return {'source_type':src_type, 'magnetization':magv, 'dimension':dimv, 'position':posv,
-            'observer': posov, 'orientation':rotobj}
+        kwargs.update({'magnetization':magv,  'dimension':dimv})
 
-    if src_type == 'Cylinder':
+    elif src_type == 'Cylinder':
         magv = tile_mag(group, n_pp)
         dimv = tile_dim_cylinder(group, n_pp)
-        return {'source_type':'Cylinder', 'magnetization':magv, 'dimension':dimv,
-            'position':posv, 'observer': posov, 'orientation':rotobj}
+        kwargs.update({'magnetization':magv,  'dimension':dimv})
 
-    if src_type == 'CylinderSegment':
+    elif src_type == 'CylinderSegment':
         magv = tile_mag(group, n_pp)
         dimv = tile_dim_cylinder_section(group, n_pp)
-        return {'source_type':'CylinderSegment', 'magnetization':magv, 'dimension':dimv,
-            'position':posv, 'observer': posov, 'orientation':rotobj}
+        kwargs.update({'magnetization':magv,  'dimension':dimv})
 
-    if src_type == 'Dipole':
+    elif src_type == 'Dipole':
         momv = tile_moment(group, n_pp)
-        return {'source_type':src_type, 'moment':momv, 'position':posv,
-            'observer': posov, 'orientation':rotobj}
+        kwargs.update({'moment':momv})
 
-    if src_type == 'Loop':
+    elif src_type == 'Loop':
         currv = tile_current(group, n_pp)
         diav = tile_dia(group, n_pp)
-        return {'source_type':src_type, 'current':currv, 'diameter':diav, 'position':posv,
-            'observer': posov, 'orientation':rotobj}
+        kwargs.update({'current':currv, 'diameter':diav})
 
-    if src_type == 'Line':
+    elif src_type == 'Line':
         # get_BH_line_from_vert function tiles internally !
         #currv = tile_current(group, n_pp)
         currv = np.array([src.current for src in group])
         vert_list = [src.vertices for src in group]
-        return {'source_type':src_type, 'current':currv, 'vertices':vert_list,
-            'position':posv, 'observer': posov, 'orientation':rotobj}
+        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
 
 
 def getBH_level2(bh, sources, observers, sumup, squeeze) -> np.ndarray:
@@ -243,41 +246,27 @@ def getBH_level2(bh, sources, observers, sumup, squeeze) -> np.ndarray:
     n_pix = int(n_pp/m)
 
     # group similar source types----------------------------------------------
-    src_sorted = [[],[],[],[],[],[],[]]   # store groups here
-    order = [[],[],[],[],[],[],[]]        # keep track of the source order
-    for i,src in enumerate(src_list):
-        if src._object_type == 'Cuboid':
-            src_sorted[0] += [src]
-            order[0] += [i]
-        elif src._object_type == 'Cylinder':
-            src_sorted[1] += [src]
-            order[1] += [i]
-        elif src._object_type == 'CylinderSegment':
-            src_sorted[2] += [src]
-            order[2] += [i]
-        elif src._object_type == 'Sphere':
-            src_sorted[3] += [src]
-            order[3] += [i]
-        elif src._object_type == 'Dipole':
-            src_sorted[4] += [src]
-            order[4] += [i]
-        elif src._object_type == 'Loop':
-            src_sorted[5] += [src]
-            order[5] += [i]
-        elif src._object_type == 'Line':
-            src_sorted[6] += [src]
-            order[6] += [i]
+    groups = {}
+    for ind,src in enumerate(src_list):
+        if src._object_type=='CustomSource':
+            group_key = src.field_B_lambda if bh 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)
 
     # evaluate each group in one vectorized step -------------------------------
-    B = np.empty((l,m,n_pix,3))                               # allocate B
-    for iii, group in enumerate(src_sorted):
-        if group:                                             # is group empty ?
-            lg = len(group)
-            src_dict = get_src_dict(group, n_pix, n_pp, poso) # compute array dict for level1
-            B_group = getBH_level1(bh=bh, **src_dict)         # compute field
-            B_group = B_group.reshape((lg,m,n_pix,3))         # reshape (2% slower for large arrays)
-            for i in range(lg):                               # put into dedicated positions in B
-                B[order[iii][i]] = B_group[i]
+    B = np.empty((l,m,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(bh=bh, **src_dict)       # compute field
+        B_group = B_group.reshape((lg,m,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]
 
     # reshape output ----------------------------------------------------------------
     # rearrange B when there is at least one Collection with more than one source
diff --git a/magpylib/_src/input_checks.py b/magpylib/_src/input_checks.py
index 5af42d17..16003f21 100644
--- a/magpylib/_src/input_checks.py
+++ b/magpylib/_src/input_checks.py
@@ -2,141 +2,137 @@
 
 import numpy as np
 from scipy.spatial.transform import Rotation
-from magpylib._src.exceptions import (MagpylibBadUserInput, MagpylibBadInputShape,
-    MagpylibMissingInput)
+from magpylib._src.exceptions import (
+    MagpylibBadUserInput,
+    MagpylibBadInputShape,
+    MagpylibMissingInput,
+)
+
 
 def check_dimensions(sources):
-    """ check if all sources have dimension (or similar) initialized
-    """
+    """check if all sources have dimension (or similar) initialized"""
     # pylint: disable=protected-access
     for s in sources:
-        if s._object_type in ('Cuboid', 'Cylinder'):
+        if s._object_type in ("Cuboid", "Cylinder"):
             if np.isnan(s.dimension).any():
-                raise MagpylibMissingInput(f'{s} dimension must be initialized.')
-        elif s._object_type in ('Sphere', 'Loop'):
+                raise MagpylibMissingInput(f"{s} dimension must be initialized.")
+        elif s._object_type in ("Sphere", "Loop"):
             if s.diameter is None:
-                raise MagpylibMissingInput(f'{s} diameter must be initialized.')
-        elif s._object_type == 'Line':
+                raise MagpylibMissingInput(f"{s} diameter must be initialized.")
+        elif s._object_type == "Line":
             if None in s.vertices[0]:
-                raise MagpylibMissingInput(f'{s} vertices must be initialized.')
+                raise MagpylibMissingInput(f"{s} vertices must be initialized.")
 
 
 def check_excitations(sources):
-    """ check if all sources have dimension (or similar) initialized
-    """
+    """check if all sources have dimension (or similar) initialized"""
     # pylint: disable=protected-access
     for s in sources:
-        if s._object_type in ('Cuboid', 'Cylinder', 'Sphere'):
+        if s._object_type in ("Cuboid", "Cylinder", "Sphere"):
             if np.isnan(s.magnetization).any():
-                raise MagpylibMissingInput(f'{s} magnetization must be initialized.')
-        elif s._object_type in ('Loop', 'Line'):
+                raise MagpylibMissingInput(f"{s} magnetization must be initialized.")
+        elif s._object_type in ("Loop", "Line"):
             if s.current is None:
-                raise MagpylibMissingInput(f'{s} current must be initialized.')
-        elif s._object_type == 'Dipole':
+                raise MagpylibMissingInput(f"{s} current must be initialized.")
+        elif s._object_type == "Dipole":
             if np.isnan(s.moment).any():
-                raise MagpylibMissingInput(f'{s} moment must be initialized.')
+                raise MagpylibMissingInput(f"{s} moment must be initialized.")
 
 
 def check_path_format(inp, origin):
-    """ test if input has the correct shape (3,) or (N,3)
-    """
-    msg = f'Bad {origin} input shape. Must be (3,) or (N,3)'
+    """test if input has the correct shape (3,) or (N,3)"""
+    msg = f"Bad {origin} input shape. Must be (3,) or (N,3)"
     if not inp.shape[-1] == 3:
         raise MagpylibBadInputShape(msg)
-    if not inp.ndim in (1,2) :
+    if not inp.ndim in (1, 2):
         raise MagpylibBadInputShape(msg)
 
 
 def check_anchor_type(anch):
-    """ anchor input must be vector or None or 0
-    """
-    if not (isinstance(anch, (list, tuple, np.ndarray, type(None))) or anch==0):
-        msg = 'anchor input must be 0, None or a vector (list, tuple or ndarray).'
+    """anchor input must be vector or None or 0"""
+    if not (isinstance(anch, (list, tuple, np.ndarray, type(None))) or anch == 0):
+        msg = "anchor input must be 0, None or a vector (list, tuple or ndarray)."
         raise MagpylibBadUserInput(msg)
 
+
 def check_anchor_format(anch):
-    """ must be shape (3,) or 0
-    """
+    """must be shape (3,) or 0"""
     if anch is not None:
-        if not np.all(anch==np.array(0)):
-            if not anch.shape==(3,):
-                msg = 'Bad anchor input. Must be None, 0 or shape (3,).'
+        if not np.all(anch == np.array(0)):
+            if not anch.shape == (3,):
+                msg = "Bad anchor input. Must be None, 0 or shape (3,)."
                 raise MagpylibBadInputShape(msg)
 
+
 def check_rot_type(inp):
-    """ rotation input mut be scipy Rotation
-    """
+    """rotation input mut be scipy Rotation"""
     if not isinstance(inp, (Rotation, type(None))):
-        msg = 'rot input must be None or scipy Rotation object.'
+        msg = "rot input must be None or scipy Rotation object."
         raise MagpylibBadUserInput(msg)
 
 
 def check_start_type(start):
-    """ start input must be int or str
-    """
-    if not (isinstance(start, int) or start=='append'):
+    """start input must be int or str"""
+    if not (isinstance(start, int) or start == "append"):
         msg = 'start input must be int or str ("attach")'
         raise MagpylibBadUserInput(msg)
 
 
 def check_increment_type(inrc):
-    """ incremnt input must be bool
-    """
+    """incremnt input must be bool"""
     if not isinstance(inrc, bool):
-        msg = 'increment input must be bool (True or False).'
+        msg = "increment input must be bool (True or False)."
         raise MagpylibBadUserInput(msg)
 
 
 def check_angle_type(angle):
-    """ angle input must be scalar or vector
-    """
+    """angle input must be scalar or vector"""
     if not isinstance(angle, (int, float, list, tuple, np.ndarray)):
-        msg = 'angle input must be scalar (int, float) or vector (list, tuple, ndarray).'
+        msg = (
+            "angle input must be scalar (int, float) or vector (list, tuple, ndarray)."
+        )
         raise MagpylibBadUserInput(msg)
 
+
 def check_angle_format(angle):
-    """ angle format must be of shape (N,)
-    """
+    """angle format must be of shape (N,)"""
     if not angle.ndim == 1:
-        msg = 'Bad angle input shape. Must be scalar or 1D vector.'
+        msg = "Bad angle input shape. Must be scalar or 1D vector."
         raise MagpylibBadInputShape(msg)
 
 
 def check_axis_type(ax):
-    """ axis input must be vector or str
-    """
+    """axis input must be vector or str"""
     if not isinstance(ax, (list, tuple, np.ndarray, str)):
-        msg = 'axis input must be a vector (list, tuple or ndarray) or a \
-            str (\'x\', \'y\', \'z\').'
+        msg = "axis input must be a vector (list, tuple or ndarray) or a \
+            str ('x', 'y', 'z')."
         raise MagpylibBadUserInput(msg)
 
+
 def check_axis_format(axis):
     """
     - must be of shape (3,)
     - must not be (0,0,0)
     """
-    msg = 'Bad axis input shape. Must be shape (3,).'
+    msg = "Bad axis input shape. Must be shape (3,)."
     if not axis.shape == (3,):
         raise MagpylibBadInputShape(msg)
-    msg = 'Bad axis input. Cannot be (0,0,0).'
+    msg = "Bad axis input. Cannot be (0,0,0)."
     if np.all(axis == 0):
         raise MagpylibBadUserInput(msg)
 
 
 def check_degree_type(deg):
-    """ degrees input must be bool
-    """
+    """degrees input must be bool"""
     if not isinstance(deg, bool):
-        msg = 'degrees input must be bool (True or False).'
+        msg = "degrees input must be bool (True or False)."
         raise MagpylibBadUserInput(msg)
 
 
-
 def check_scalar_type(inp, origin):
-    """ scalar input must be int or float or nan
-    """
+    """scalar input must be int or float or nan"""
     if not (isinstance(inp, (int, float)) or inp is None):
-        msg = origin + ' input must be scalar (int or float).'
+        msg = origin + " input must be scalar (int or float)."
         raise MagpylibBadUserInput(msg)
 
 
@@ -148,16 +144,16 @@ def check_scalar_type(inp, origin):
 #         raise MagpylibBadUserInput(msg)
 
 
-
 def check_vector_type(inp, origin):
     """
     - vector input must be list, tuple or ndarray
     - return error msg with reference to origin
     """
     if not isinstance(inp, (list, tuple, np.ndarray)):
-        msg = origin + ' input must be vector type (list, tuple or ndarray).'
+        msg = origin + " input must be vector type (list, tuple or ndarray)."
         raise MagpylibBadUserInput(msg)
 
+
 # def check_vector_init(inp, origin):
 #     """
 #     - check if vector input was initialized (former None vector)
@@ -167,13 +163,14 @@ def check_vector_type(inp, origin):
 #         msg = origin + ' must be initialized.'
 #         raise MagpylibBadUserInput(msg)
 
+
 def check_vector_format(inp, shape, origin):
     """
     - check if vector input has correct format
     - return error msg with reference to origin
     """
     if not inp.shape == shape:
-        msg = f'Bad {origin} input shape. Must be shape {shape}.'
+        msg = f"Bad {origin} input shape. Must be shape {shape}."
         raise MagpylibBadInputShape(msg)
 
 
@@ -185,20 +182,20 @@ def check_input_cyl_sect(inp):
     - return error msg
     """
     if not inp.shape == (5,):
-        msg = '''Bad dimension input shape. Dimension must be (d1,d2,h,phi1,phi2).'''
+        msg = """Bad dimension input shape. Dimension must be (d1,d2,h,phi1,phi2)."""
         raise MagpylibBadInputShape(msg)
 
-    d1,d2,_,phi1,phi2 = inp
+    d1, d2, _, phi1, phi2 = inp
     if d1 >= d2:
-        msg = 'd1 must be smaller than d2.'
+        msg = "d1 must be smaller than d2."
         raise MagpylibBadUserInput(msg)
 
-    if phi1>=phi2:
-        msg = 'phi1 must be smaller than phi2.'
+    if phi1 >= phi2:
+        msg = "phi1 must be smaller than phi2."
         raise MagpylibBadUserInput(msg)
 
-    if (phi2-phi1)>360:
-        msg = 'phi2-phi1 cannot be larger than 360°.'
+    if (phi2 - phi1) > 360:
+        msg = "phi2-phi1 cannot be larger than 360°."
         raise MagpylibBadUserInput(msg)
 
 
@@ -207,7 +204,7 @@ def check_position_format(inp, origin):
     - test if input has the correct shape (...,3)
     - return error msg with reference to origin
     """
-    msg = f'Bad {origin} input shape. Must be (...,3).'
+    msg = f"Bad {origin} input shape. Must be (...,3)."
     if not inp.shape[-1] == 3:
         raise MagpylibBadInputShape(msg)
 
@@ -217,10 +214,28 @@ def check_vertex_format(inp):
     - test if input has the correct shape (N,3) with N >= 2
     - return err msg
     """
-    msg = 'Bad vertex input shape. Must be (N,3) with N>1'
+    msg = "Bad vertex input shape. Must be (N,3) with N>1"
     if not inp.shape[-1] == 3:
         raise MagpylibBadInputShape(msg)
     if not inp.ndim == 2:
         raise MagpylibBadInputShape(msg)
     if not inp.shape[0] > 1:
         raise MagpylibBadInputShape(msg)
+
+
+def validate_field_lambda(val, bh):
+    """
+    test if field function for custom source is valid
+    - needs to be a callable
+    - input and output schape must match
+    """
+    if val is not None:
+        assert callable(val), f"field_{bh}_lambda must be a callable"
+        out = val(np.array([[1, 2, 3], [4, 5, 6]]))
+        out_shape = np.array(out).shape
+        assert out_shape == (2, 3), (
+            f"field_{bh}_lambda input shape and output "
+            "shape must match and be of dimension (n,3)\n"
+            f"received shape={out_shape} instead"
+        )
+    return val
diff --git a/magpylib/_src/obj_classes/__init__.py b/magpylib/_src/obj_classes/__init__.py
index d18da1f9..bd74f499 100644
--- a/magpylib/_src/obj_classes/__init__.py
+++ b/magpylib/_src/obj_classes/__init__.py
@@ -1,7 +1,7 @@
 """_src.obj_classes"""
 
 __all__ = ['Cuboid', 'Cylinder', 'Sphere', 'Collection', 'Sensor',
-    'Dipole', 'Loop', 'Line', 'CylinderSegment']
+    'Dipole', 'Loop', 'Line', 'CylinderSegment','CustomSource']
 
 # create interface to outside of package
 from magpylib._src.obj_classes.class_mag_Cuboid import Cuboid
@@ -13,3 +13,4 @@ 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
diff --git a/magpylib/_src/obj_classes/class_misc_Custom.py b/magpylib/_src/obj_classes/class_misc_Custom.py
new file mode 100644
index 00000000..674d1fb3
--- /dev/null
+++ b/magpylib/_src/obj_classes/class_misc_Custom.py
@@ -0,0 +1,97 @@
+"""Custom class code"""
+
+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
+
+
+# ON INTERFACE
+class CustomSource(BaseGeo, BaseDisplayRepr, BaseGetBH):
+    """
+    Custom Source class
+
+    Parameters
+    ----------
+    field_B_lambda: function
+        field function for B-field, should accept (n,3) position array and return
+        B-field array of same shape in the global coordinate system.
+
+    field_H_lambda: function
+        field function for H-field, should accept (n,3) position array and return
+        H-field array of same shape in the global coordinate system.
+
+    position: array_like, shape (3,) or (M,3), default=(0,0,0)
+        Object position (local CS origin) in the global CS in units of [mm].
+        For M>1, the position represents a path. The position and orientation
+        parameters must always be of the same length.
+
+    orientation: scipy Rotation object with length 1 or M, default=unit rotation
+        Object orientation (local CS orientation) in the global CS. For M>1
+        orientation represents different values along a path. The position and
+        orientation parameters must always be of the same length.
+
+    Returns
+    -------
+    CustomSource object: CustomSource
+
+    Examples
+    --------
+    By default the CustomSource is initialized at position (0,0,0), with unit rotation:
+    >>> import magpylib as magpy
+    >>> import numpy as np
+
+    Define a external B-field function which returns constant vector in x-direction
+    >>> def constant_Bfield(position=((0,0,0))):
+    ...    return np.array([[1,0,0]]*len(position))
+
+    Construct a ``CustomSource`` from the field function
+    >>> external_field = magpy.misc.CustomSource(field_B_lambda=constant_Bfield)
+    >>> B = external_field.getB([[1,2,3],[4,5,6]])
+    >>> print(B)
+    [[1. 0. 0.]
+     [1. 0. 0.]]
+    The custom source can be rotated as any other source object in the library.
+    >>> external_field.rotate_from_angax(90, 'z')
+    >>> B = external_field.getB([[1,2,3],[4,5,6]])
+    >>> print(B) # Notice the outut field is now pointing in y-direction
+    [[0. 1. 0.]
+     [0. 1. 0.]]
+    """
+
+    def __init__(
+        self,
+        field_B_lambda=None,
+        field_H_lambda=None,
+        position=(0, 0, 0),
+        orientation=None,
+        style=None,
+    ):
+        # instance attributes
+        self.field_B_lambda = field_B_lambda
+        self.field_H_lambda = field_H_lambda
+        self._object_type = "CustomSource"
+
+        # init inheritance
+        BaseGeo.__init__(self, position, orientation, style=style)
+        BaseDisplayRepr.__init__(self)
+
+    @property
+    def field_B_lambda(self):
+        """field function for B-field, should accept (n,3) position array and return
+        B-field array of same shape in the global coordinate system."""
+        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 H-field, should accept (n,3) position array and return
+        H-field array of same shape in the global coordinate system."""
+        return self._field_H_lambda
+
+    @field_H_lambda.setter
+    def field_H_lambda(self, val):
+        self._field_H_lambda = validate_field_lambda(val, "H")
diff --git a/magpylib/_src/style.py b/magpylib/_src/style.py
index 4613d473..1203abd8 100644
--- a/magpylib/_src/style.py
+++ b/magpylib/_src/style.py
@@ -15,12 +15,12 @@ from magpylib._src.default_utils import (
 
 def get_style_class(obj):
     """returns style class based on object type. If class has no attribute `_object_type` or is
-    not found in `MAGPYLIB_FAMILIES` returns `BaseStyle` class."""
+    not found in `MAGPYLIB_FAMILIES` returns `Base` class."""
     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]
-    return STYLE_CLASSES.get(style_fam, BaseStyle)
+    return STYLE_CLASSES.get(style_fam, Base)
 
 
 def get_style(obj, default_settings, **kwargs):
diff --git a/magpylib/_src/utility.py b/magpylib/_src/utility.py
index a2079182..b25a94dc 100644
--- a/magpylib/_src/utility.py
+++ b/magpylib/_src/utility.py
@@ -58,7 +58,8 @@ def format_obj_input(objects: Sequence) -> list:
                     'Sensor',
                     'Dipole',
                     'Loop',
-                    'Line'):
+                    'Line',
+                    'CustomSource'):
                     obj_list += [obj]
             except Exception as error:
                 raise MagpylibBadUserInput('Unknown input object type.') from error
@@ -84,7 +85,7 @@ def format_src_inputs(sources) -> list:
     # pylint: disable=protected-access
 
     src_class_types = (
-        'Cuboid', 'Cylinder', 'CylinderSegment', 'Sphere', 'Dipole', 'Loop', 'Line')
+        'Cuboid', 'Cylinder', 'CylinderSegment', 'Sphere', 'Dipole', 'Loop', 'Line', 'CustomSource')
 
     # if bare source make into list
     if not isinstance(sources, (list,tuple)):
diff --git a/magpylib/misc/__init__.py b/magpylib/misc/__init__.py
index b566a34c..62f5d406 100644
--- a/magpylib/misc/__init__.py
+++ b/magpylib/misc/__init__.py
@@ -2,6 +2,6 @@
 This sub-package contains miscellaneous source objects.
 """
 
-__all__ = ['Dipole']
+__all__ = ['Dipole', 'CustomSource']
 
-from magpylib._src.obj_classes import Dipole
+from magpylib._src.obj_classes import Dipole, CustomSource


Test patch:
diff --git a/tests/test_CustomSource.py b/tests/test_CustomSource.py
new file mode 100644
index 00000000..67f7825a
--- /dev/null
+++ b/tests/test_CustomSource.py
@@ -0,0 +1,60 @@
+import pytest
+import numpy as np
+import magpylib as magpy
+from magpylib._src.exceptions import MagpylibInternalError
+
+# pylint: disable=assignment-from-no-return
+
+
+def constant_Bfield(position=((0, 0, 0))):
+    return np.array([[1, 2, 3]] * len(position))
+
+
+def constant_Hfield(position=((0, 0, 0))):
+    return np.array([[4, 5, 6]] * len(position))
+
+def bad_Bfield_func(position):
+    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)
+
+    B = external_field.getB([[1, 2, 3], [4, 5, 6]])
+    Btest = np.array([[1, 2, 3]] * 2)
+    assert np.allclose(B, Btest)
+
+    external_field.rotate_from_angax(45, "z")
+    B = external_field.getB([[1, 2, 3], [4, 5, 6]])
+    Btest = np.array([[-0.70710678, 2.12132034, 3.0]] * 2)
+    assert np.allclose(B, Btest)
+
+
+def test_CustomSource_basicH():
+    """Basic custom source class test"""
+    external_field = magpy.misc.CustomSource(field_H_lambda=constant_Hfield)
+
+    H = external_field.getH([[1, 2, 3], [4, 5, 6]])
+    Htest = np.array([[4, 5, 6]] * 2)
+    assert np.allclose(H, Htest)
+
+    external_field.rotate_from_angax(35, "x")
+    H = external_field.getH([[1, 2, 3], [4, 5, 6]])
+    Htest = np.array([[4.0, 0.6543016, 7.78279445]] * 2)
+    assert np.allclose(H, Htest)
+
+def test_CustomSource_bad_inputs():
+    with pytest.raises(AssertionError):
+        magpy.misc.CustomSource(field_H_lambda='not a callable')
+
+    with pytest.raises(AssertionError):
+        magpy.misc.CustomSource(field_H_lambda=bad_Bfield_func)
+
+    src = magpy.misc.CustomSource()
+    with pytest.raises(MagpylibInternalError):
+        src.getB([0,0,0])
+
+def test_repr():
+    """test __repr__"""
+    dip = magpy.misc.CustomSource()
+    assert dip.__repr__()[:12] == "CustomSource", "Custom_Source repr failed"
diff --git a/tests/test_display_matplotlib.py b/tests/test_display_matplotlib.py
index 419688fc..aee0324a 100644
--- a/tests/test_display_matplotlib.py
+++ b/tests/test_display_matplotlib.py
@@ -74,6 +74,13 @@ def test_Sensor_display():
     x = sens.display(canvas=ax, markers=[(100, 100, 100)], path=False)
     assert x is None, "display test fail"
 
+def test_CustomSource_display():
+    """testing display"""
+    ax = plt.subplot(projection="3d")
+    cs = magpy.misc.CustomSource()
+    x = cs.display(canvas=ax)
+    assert x is None, "display test fail"
+
 
 def test_Loop_display():
     """testing display for Loop source"""
diff --git a/tests/test_display_plotly.py b/tests/test_display_plotly.py
index bca05424..0b2c1fde 100644
--- a/tests/test_display_plotly.py
+++ b/tests/test_display_plotly.py
@@ -206,6 +206,13 @@ def test_extra_mesh3d():
     )
     assert x is None, "display test fail"
 
+def test_CustomSource_display():
+    """testing display"""
+    fig  = go.Figure()
+    cs = magpy.misc.CustomSource()
+    x = cs.display(canvas=fig, backend='plotly')
+    assert x is None, "display test fail"
+
 def test_empty_display():
     fig  = go.Figure()
     x = magpy.display(canvas=fig, backend='plotly')