Repository: PlasmaPy/PlasmaPy

Issue:
Create example notebook that lets users input plasma properties and get plasma parameters
It's pretty common for plasma physicists to have a bunch of plasma properties (e.g., magnetic field strength, ion/electron number densities, etc.) and want to calculate a bunch of plasma parameters (e.g., Debye length, electron & ion inertial lengths, electron & ion gyroradii and gyrofrequencies, etc.).  It would be really helpful to add a notebook in our [examples](https://docs.plasmapy.org/en/latest/examples.html) that allows users to input different plasma properties and then get a whole bunch of these plasma parameters printed out.  This notebook would use functionality in [`plasmapy.formulary`](https://docs.plasmapy.org/en/latest/formulary/index.html).

It would probably help to provide the inverses of different frequencies to get the associated time scales (i.e. the inverse of the gyrofrequency will give the gyroperiod).  The different length scales and time scales should be placed near each other.  It'd also be helpful to sort the different length and time scales from shortest to longest.  

This notebook would complement the notebook on [calculating ITER parameters](https://docs.plasmapy.org/en/latest/notebooks/physics.html) (which could probably use some updates too, since its obsolete reference to the `physics` subpackage should be changed to a reference to the `formulary` subpackage).  

Thanks to @svincena for suggesting this idea!


Discussion:
Functions in `plasmapy.formulary` to start out with include:
 - `Alfven_speed`
 - `thermal_speed`
 - `Debye_length`
 - `Debye_number`
 - `gyrofrequency`
 - `gyroradius`
 - `inertial_length`
 - `lower_hybrid_frequency`
 - `upper_hybrid_frequency`
 - `plasma_frequency`
Hi @namurphy, my project team would like take up this issue. 
Hello seems like there are no development related to this issue for a long time. Can I give this a shot?
@RAJAGOPALAN-GANGADHARAN — sure, please do!

Resolving patch:
diff --git a/plasmapy/utils/calculator/__init__.py b/plasmapy/utils/calculator/__init__.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/utils/calculator/__init__.py
@@ -0,0 +1,40 @@
+"""
+Script and utilities to launch the plasma calculator
+"""
+__all__ = ["main"]
+
+import argparse
+import os
+import shlex
+import subprocess
+
+
+def main():
+    """
+    Stub function for command line tool that launches the plasma calculator notebook.
+    """
+    parser = argparse.ArgumentParser(description="Plasma calculator")
+    parser.add_argument(
+        "--port", type=int, default=8866, help="Port to run the notebook"
+    )
+    parser.add_argument(
+        "--dark", action="store_true", help="Turn on dark mode, reduces eye strain"
+    )
+    parser.add_argument(
+        "--no-browser", action="store_true", help="Do not open the browser"
+    )
+
+    # module_path = plasmapy.__path__[0]
+    module_path = os.path.dirname(os.path.abspath(__file__))
+    computed_calculator_path = os.path.join(module_path, "plasma_calculator.ipynb")
+
+    args = parser.parse_args()
+    theme = "dark" if args.dark else "light"
+    no_browser = "--no-browser" if args.no_browser else ""
+
+    command = f"voila {no_browser} --port={args.port} --theme={theme} {computed_calculator_path} \
+        --VoilaConfiguration.file_whitelist favicon.ico"
+    try:
+        subprocess.call(shlex.split(command))
+    except KeyboardInterrupt:
+        print("Stopping calculator! Bye")
diff --git a/plasmapy/utils/calculator/main_interface.py b/plasmapy/utils/calculator/main_interface.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/utils/calculator/main_interface.py
@@ -0,0 +1,168 @@
+"""
+Collection of private functions to load properties and construct widgets
+"""
+__all__ = []
+
+import astropy.units as units
+import json
+
+from ipywidgets import widgets
+
+from plasmapy.utils.calculator.widget_helpers import (
+    _calculate_button,
+    _CheckBox,
+    _clear_button,
+    _create_label,
+    _create_widget,
+    _FloatBox,
+    _FunctionInfo,
+    _IonBox,
+    _ParticleBox,
+    _process_queue,
+)
+
+LIGHT_BLUE = "#00BFD8"
+"""
+LIGHT_BLUE: Constant for light blue color 00BFD8
+"""
+LIGHT_GRAY = "#A9A9A9"
+"""
+LIGHT_GRAY: Constant for light gray color A9A9A9
+"""
+
+grid_data = [
+    [
+        _create_label("Parameter", color=LIGHT_BLUE),
+        _create_label("Value", color=LIGHT_BLUE),
+        _create_label("Unit", color=LIGHT_BLUE),
+    ],
+    [
+        _create_label("B - Magnetic Field Magnitude:"),
+        *_create_widget(
+            _FloatBox,
+            property_name="B",
+            unit=units.T,
+            opts=[units.T, units.G, units.uG],
+        ),
+    ],
+    [
+        _create_label("Particle:"),
+        _create_widget(
+            _ParticleBox,
+            property_name="particle",
+            property_alias="particle_type",
+            placeholder="Enter particle e.g. neutron,He",
+        ),
+    ],
+    [
+        _create_label("Ion:"),
+        _create_widget(
+            _IonBox,
+            property_name="ion",
+            property_alias="ion_type",
+            placeholder="Enter ion e.g. He 2+",
+        ),
+    ],
+    [
+        _create_label("Convert to Hertz:"),
+        _create_widget(_CheckBox, property_name="to_hz"),
+    ],
+    [
+        _create_label("_" * 20, color=LIGHT_BLUE),
+        _create_label("Density Number"),
+        _create_label("_" * 20, color=LIGHT_BLUE),
+    ],
+    [
+        _create_label("n - Standard Density Number:"),
+        *_create_widget(
+            _FloatBox,
+            property_name="n",
+            unit=units.m ** -3,
+            opts=[units.m ** -3, units.cm ** -3, units.mm ** -3],
+        ),
+    ],
+    [
+        _create_label("n<sub>e</sub> - Electron Density Number:"),
+        *_create_widget(
+            _FloatBox,
+            property_name="n_e",
+            unit=units.m ** -3,
+            opts=[units.m ** -3, units.cm ** -3, units.mm ** -3],
+        ),
+    ],
+    [
+        _create_label("n<sub>i</sub> - Ion Number Density:"),
+        *_create_widget(
+            _FloatBox,
+            property_name="n_i",
+            unit=units.m ** -3,
+            opts=[units.m ** -3, units.cm ** -3, units.mm ** -3],
+        ),
+    ],
+    [
+        _create_label("_" * 20, color=LIGHT_BLUE),
+        _create_label("Temperature"),
+        _create_label("_" * 20, color=LIGHT_BLUE),
+    ],
+    [
+        _create_label("T - Standard Temperature:"),
+        _create_widget(_FloatBox, property_name="T", min=0, unit=units.K),
+        _create_label("K", color=LIGHT_GRAY),
+    ],
+    [
+        _create_label("T<sub>e</sub> - Electron Temperature:"),
+        _create_widget(_FloatBox, property_name="T_e", min=0, unit=units.K),
+        _create_label("K", color=LIGHT_GRAY),
+    ],
+    [
+        _create_label("T<sub>i</sub> - Ion Temperature:"),
+        _create_widget(_FloatBox, property_name="T_i", min=0, unit=units.K),
+        _create_label("K", color=LIGHT_GRAY),
+    ],
+]
+"""
+grid_data: Contains widgets layout for input parameters
+"""
+
+
+def _create_interactive_layout():
+    """
+    Interactive grid layout for input parameters populated in grid_data
+    """
+    grid = widgets.GridspecLayout(18, 3)
+    grid.layout.margin = "10px"
+
+    for i, row in enumerate(grid_data):
+        for j, cell in enumerate(row):
+            grid[i, j] = cell
+
+    grid[-1, 0] = _calculate_button
+    grid[-1, 1] = _clear_button
+    return grid
+
+
+def _create_output_layout():
+    """
+    Tab layout for output parameters, populated from properties_metadata.json
+    """
+    app = widgets.Tab()
+    children = []
+
+    with open("properties_metadata.json") as f:
+        data = json.load(f)
+
+    for i, title in enumerate(data):
+        grid_layout = widgets.GridspecLayout(10, 2, width="100%")
+        for j, prop in enumerate(data[title]):
+            fn = _FunctionInfo(prop["module_name"], prop["function_name"])
+            if "spec_combo" in prop:
+                for spec_combo in prop["spec_combo"]:
+                    fn.add_combo(spec_combo)
+            grid_layout[j, 0] = _create_label(prop["function_name"] + ":")
+            grid_layout[j, 1] = fn.get_output_widget()
+            _process_queue.append(fn)
+        children.append(grid_layout)
+        app.set_title(i, title)
+    app.children = children
+
+    return app
diff --git a/plasmapy/utils/calculator/widget_helpers.py b/plasmapy/utils/calculator/widget_helpers.py
new file mode 100644
--- /dev/null
+++ b/plasmapy/utils/calculator/widget_helpers.py
@@ -0,0 +1,672 @@
+"""
+Contains functions that create widgets and process properties for the calculator
+"""
+
+__all__ = []
+
+import abc
+import astropy.units as units
+import importlib
+import ipywidgets as widgets
+
+from astropy.constants.si import m_e, m_p
+from inspect import signature, trace
+
+from plasmapy.particles import Particle
+
+BLACK = (0, 0, 0)
+"""
+BLACK: RGB Constant for black color 000
+"""
+
+DARK_RED = (255, 0, 0)
+"""
+DARK_RED: RGB Constant for dark red color 255,0,0
+"""
+
+LIGHT_GREEN = (0, 128, 0)
+"""
+LIGHT_GREEN: RGB Constant for light green color 0,128,0
+"""
+
+ERROR_STYLE = "2px solid red"
+"""
+ERROR_STYLE: Constant for error style = 2px solid red
+"""
+
+EQUAL_SPACING_CONFIG = "10px 10px 10px 10px"
+"""
+EQUAL_SPACING_CONFIG: Constant for equal spacing config among widgets = 10px 10px 10px 10px
+"""
+
+values_container = dict()
+"""values_container: stores the values of widget with corresponding ``property_name``."""
+
+_process_queue = []
+"""_process_queue: stores the functions to be processed,
+This data is gathered from ``properties_metadata.json``."""
+
+
+class _GenericWidget(abc.ABC):
+    """
+    Generic widget class
+
+    Parameters
+    ----------
+    property_name: `str`
+        Name of the property the widget is associated with.
+        This value is key for the values_container.
+
+    property_alias: `str`
+        Alias of the property. Useful to display in validation error messages.
+
+    values_cont: `dict`
+        Reference to global dictionary to store the values of the widgets.
+
+    Raises
+    ------
+    `NotImplementedError`
+        If the method `create_widget` is not implemented.
+    """
+
+    def __init__(self, property_name, property_alias="", values_cont=values_container):
+        self.property_name = property_name
+        self.property_alias = property_alias or property_name
+        self.widget = None
+        self.values_cont = values_cont
+        self.unit = None
+        self.units_dropdown = None
+
+    def set_unit(self, unit):
+        """
+        Set unit for the value of widget, defaults to `None`.
+
+        Parameters
+        ----------
+        unit: `astropy.units.Unit`
+            Unit to be set for the value of widget
+        """
+        self.unit = unit
+
+    def get_widget(self):
+        """
+        Get current widget object reference
+
+        Returns
+        -------
+        `ipywidgets.Widget`
+            Current widget object reference
+        """
+        return self.widget
+
+    def get_dropdown_widget(self):
+        """
+        Get dropdown widget associated with current widget, defaults to `None`.
+
+        Returns
+        -------
+        `ipywidgets.Dropdown`
+            Dropdown widget associated with current widget
+        """
+        return self.units_dropdown
+
+    def set_place_holder(self, text):
+        """
+        Set place holder text of the widget, defaults to empty string
+
+        Parameters
+        ----------
+        text: `str`
+            Place holder text to be set
+        """
+        self.widget.placeholder = text
+
+    @abc.abstractmethod
+    def create_widget(self):
+        """
+        Virtual method to create widget
+        """
+        pass
+
+    def post_creation(self):
+        """
+        Default method that is called after widget creation.
+        Attaches change listener to the widget.
+        """
+        self.set_place_holder("")
+        self.widget.observe(self.handle_change, names="value")
+
+    def edge_case(self, value):
+        """
+        Edge case handling for the widget. This is called within handle_change.
+
+        Parameters
+        ----------
+        value: `any`
+            Value of the widget
+        """
+        pass
+
+    def edge_case_condition(self, value):
+        """
+        Edge case condition for the widget.
+
+        Parameters
+        ----------
+        value: `any`
+            Value of the widget
+
+        Returns
+        -------
+        `bool`
+            `True` if the value is an edge case, `False` otherwise
+        """
+        return False
+
+    def try_change_value(self, value):
+        """
+        Set property_name in values_container to value.
+
+        Parameters
+        ----------
+        value: `any`
+            Value to be set
+        """
+        self.values_cont[self.property_name] = value
+
+    def display_error(self, value):
+        """
+        Handle invalid input provide realtime validation.
+
+        Parameters
+        ----------
+        value: `any`
+            Value of the widget
+        """
+        if self.widget:
+            self.widget.layout.border = ERROR_STYLE
+            self.widget.description = f"Invalid {self.property_alias}"
+            self.values_cont[self.property_name] = None
+
+    def convert_to_unit(self, change):
+        """
+        Convert the value of the widget to the unit specified by the dropdown.
+
+        Parameters
+        ----------
+        change: `any`
+            New value of the widget
+
+        Returns
+        -------
+        `any`
+            Value of the widget in the unit specified by the dropdown
+        """
+        if self.unit:
+            return change.new * self.unit
+        return change.new
+
+    def attach_units_dropdown(self, options):
+        """
+        Special method that attaches dropdown widget to the input widget,
+        and handles the change event of the dropdown widget.
+
+        Parameters
+        ----------
+        options: `list`
+            List of units to be displayed in the dropdown widget
+        """
+        self.units_dropdown = widgets.Dropdown(
+            options=options, value=options[0], layout=widgets.Layout(width="100px")
+        )
+        self.units_dropdown.observe(self.handle_dropdown_change, names="value")
+
+    def handle_dropdown_change(self, change):
+        """
+        Handle change event of the dropdown widget.
+
+        Parameters
+        ----------
+        change: `any`
+            New value of the dropdown widget
+        """
+        self.set_unit(self.units_dropdown.value)
+        if self.property_name in self.values_cont:
+            self.values_cont[self.property_name] = (
+                self.values_cont[self.property_name].value * self.unit
+            )
+
+    def handle_change(self, change):
+        """
+        Handle change event of the widget, follows same process
+        for all widgets.
+
+        Gets the new value with units, checks for invalid input,
+        edge case and updates values_container accordingly.
+
+        Parameters
+        ----------
+        change: `any`
+            New value of the widget
+        """
+        value = self.convert_to_unit(change)
+        if self.edge_case_condition(value):
+            self.edge_case(value)
+        else:
+            try:
+                self.try_change_value(value)
+            except Exception:
+                self.display_error(value)
+
+
+class _FloatBox(_GenericWidget):
+    """
+    Derived from _GenericWidget, a FloatBox input widget
+    with incremental options.
+
+    Parameters
+    ----------
+    property_name: `str`
+        Name of the property the widget is associated with.
+
+    min: `float`
+        Minimum value the widget can take
+
+    max: `float`
+        Maximum value the widget can take
+    """
+
+    def __init__(self, property_name, min=-1e50, max=1e50):
+        super().__init__(property_name)
+        self.min = min
+        self.max = max
+
+    def create_widget(self, style={"description_width": "initial"}):
+        """
+        Implements create_widget. description_width is set to initial
+        to make the widget as wide as possible.
+        """
+        self.widget = widgets.BoundedFloatText(
+            name=self.property_name,
+            min=self.min,
+            max=self.max,
+            value=0,
+            step=0.1,
+            style=style,
+        )
+        self.post_creation()
+
+
+class _CheckBox(_GenericWidget):
+    """
+    Derived from _GenericWidget, a CheckBox input widget.
+
+    Parameters
+    ----------
+    property_name: `str`
+        Name of the property the widget is associated with.
+    """
+
+    def __init__(self, property_name):
+        super().__init__(property_name)
+
+    def create_widget(self):
+        """
+        Implements create_widget.
+        """
+        self.widget = widgets.Checkbox(value=False)
+        self.post_creation()
+
+
+class _ParticleBox(_GenericWidget):
+    """
+    Derived from _GenericWidget, input widget specific for particle
+    name.
+
+    Parameters
+    ----------
+    property_name: `str`
+        Name of the property the widget is associated with.
+
+    property_alias: `str`
+        Alias of the property the widget is associated with.
+        (particle_type in this case)
+    """
+
+    def __init__(self, property_name, property_alias=None):
+        super().__init__(property_name, property_alias=property_alias)
+
+    def edge_case_condition(self, value):
+        """
+        Edge case for particle box, checks if value is empty.
+
+        Parameters
+        ----------
+        value: `str`
+            Value of the widget
+
+        Returns
+        -------
+        `bool`
+            `True` if the value is empty, `False` otherwise
+        """
+        return value is None or value == ""
+
+    def edge_case(self, value):
+        """
+        Edge case to handle empty value of particle box
+        resets the container value to `None`, and resets the error status.
+        """
+        self.values_cont[self.property_name] = None
+        self.widget.description = ""
+        self.widget.layout.border = ""
+
+    def try_change_value(self, value):
+        """
+        Set property_name in values_container to value,
+        and resets the error status.
+
+        Parameters
+        ----------
+        value: `str`
+            Value to be set
+
+        Raises
+        ------
+        `~plasmapy.particles.exceptions.InvalidParticleError`
+            Raised when the particle input does not correspond to a valid
+            particle or is contradictory.
+        """
+        particle = Particle(value)
+        self.values_cont[self.property_name] = particle
+        self.widget.layout.border = ""
+        self.widget.description = ""
+
+    def create_widget(self, style={"description_width": "initial"}):
+        """
+        Implements create_widget. description_width is set to initial
+        to make the widget as wide as possible.
+        """
+        self.widget = widgets.Text(style=style)
+        self.post_creation()
+
+
+class _IonBox(_ParticleBox):
+    """
+    Derived from _ParticleBox, input widget specific for ion.
+
+    Parameters
+    ----------
+    property_name: `str`
+        Name of the property the widget is associated with.
+
+    property_alias: `str`
+        Alias of the property the widget is associated with.
+    """
+
+    def __init__(self, property_name, property_alias=None):
+        super().__init__(property_name, property_alias=property_alias)
+
+    def try_change_value(self, value):
+        """
+        Set property_name in values_container to value on validating input.
+
+        Parameters
+        ----------
+        value: `str`
+            Value to be set
+
+        Raises
+        ------
+        `~plasmapy.particles.exceptions.InvalidParticleError`
+            Raised when the particle input does not correspond to a valid
+            particle or is contradictory.
+        `ValueError`
+            Raised when the input is not a valid ion
+        """
+        ion = Particle(value)
+        if ion.is_ion:
+            self.values_cont[self.property_name] = ion
+            self.widget.layout.border = ""
+            self.widget.description = ""
+        else:
+            raise ValueError(f"{ion} is not an ion")
+
+
+class _FunctionInfo:
+    """
+    Class to store information about a function. Gets the function's parameters,
+    and uses to process input based on function signature.
+
+    Parameters
+    ----------
+    module_name: `str`
+        Name of the module the function is in
+
+    function_name: `str`
+        Name of the function
+
+    values_container: `dict`
+        Reference to global dictionary of values to be passed to the function
+    """
+
+    def __init__(self, module_name, function_name, values_cont=values_container):
+        self.module = module_name
+        self.fname = function_name
+        self.fattr = getattr(importlib.import_module(module_name), function_name)
+        self.values_cont = values_cont
+        self.spec_combo = None
+        self.sig = list(signature(self.fattr).parameters.keys())
+        self.output_widget = widgets.Output()
+        self.output_widget.layout.margin = EQUAL_SPACING_CONFIG
+        self.output_widget.layout.padding = EQUAL_SPACING_CONFIG
+
+    def add_combo(self, spec_combo):
+        """
+        Specify selective combination of parameters to be used in the function,
+        This is the case for few functions where having all parameters doesn't yield
+        output.
+
+        Parameters
+        ----------
+        spec_combo: `list`
+            List of parameters to be used in the function
+
+        Example
+        -------
+        For plasmapy.formulary.gyroradius the specific combo's are as follows:
+        ["B","particle","Vperp"] and ["B","particle","T"]
+        """
+        if not self.spec_combo:
+            self.spec_combo = []
+        self.spec_combo.append(spec_combo)
+
+    def get_output_widget(self):
+        """
+        Returns the output widget of the function.
+
+        Returns
+        -------
+        `~ipywidgets.widgets.Output`
+            Output widget of the function
+        """
+        return self.output_widget
+
+    def produce_arg(self, spec):
+        """
+        Prepares a dictionary of arguments that is present in both values_container,
+        and in spec.
+
+        Parameters
+        ----------
+        spec: `list`
+            List of parameters to be used in the function
+
+        Returns
+        -------
+        `dict`
+            Dictionary of arguments that is available
+        """
+        args_dict = dict()
+        for arg in spec:
+            if arg in self.values_cont and self.values_cont[arg] is not None:
+                args_dict[arg] = self.values_cont[arg]
+
+        return args_dict
+
+    def error_message(self, spec):
+        """
+        Generates an error message for the function when parameters are missing.
+
+        Parameters
+        ----------
+        spec: `list`
+            List of parameters to be used in the function
+        """
+        print(_colored_text(BLACK, "["), end="")
+        for arg in spec:
+            if arg in self.values_cont and self.values_cont[arg] is not None:
+                print(_colored_text(LIGHT_GREEN, arg + ":present,"), end="")
+            else:
+                print(_colored_text(DARK_RED, arg + ":missing,"), end="")
+        print(_colored_text(BLACK, "]"))
+
+    def process(self):
+        """
+        Processes the function based on signatures and spec_combo provided.
+        Spec_combo is prioritized over the function signature.
+        """
+        self.output_widget.clear_output()
+        args_dict = dict()
+        if self.spec_combo:
+            for spec in self.spec_combo:
+                args_dict = self.produce_arg(spec)
+
+                if len(args_dict) == len(spec):
+                    break
+        else:
+            args_dict = self.produce_arg(self.sig)
+        with self.output_widget:
+            try:
+                self.output_widget.layout.border = "0px"
+                print(" : " + str(self.fattr(**args_dict)))
+
+            except Exception as e:
+                self.output_widget.layout.border = ERROR_STYLE
+                print(e)
+                print(
+                    " : could not be computed one or more parameter is missing - check below for missing parameters"
+                )
+                if self.spec_combo:
+                    for spec in self.spec_combo:
+                        self.error_message(spec)
+                else:
+                    self.error_message(self.sig)
+
+
+def _create_label(label, color="black"):
+    """
+    Creates a label widget with the given text and color.
+
+    Parameters
+    ----------
+    label: `str`
+        Text of the label
+
+    color: `str`
+        Color of the label, defaults to black
+    """
+    # This is done so voila switches colors according to theme
+    color_param = f"color:{color}" if color != "black" else ""
+    return widgets.HTML(f"<h3 style='margin:0px;{color_param}'>{label}<h3>")
+
+
+def _handle_button_click(event):
+    """
+    Handles the click event of the calculate properties button.
+    """
+    for fn in _process_queue:
+        fn.process()
+
+
+def _handle_clear_click(event):
+    """
+    Handles the click event of the clear properties button.
+    Clears output of all functions.
+    """
+    for fn in _process_queue:
+        fn.output_widget.clear_output()
+
+
+def _colored_text(color, text):
+    """
+    Prepares an inline string with the given color.
+
+    Parameters
+    ----------
+    color: `list`
+        RGB color of the text
+
+    text: `str`
+        Text to be colored
+
+    Returns
+    -------
+    `str`
+        Colored text
+    """
+    return f"\033[38;2;{color[0]};{color[1]};{color[2]}m{text} \033[38;2;255;255;255m"
+
+
+_calculate_button = widgets.Button(
+    description="Calculate Properties", button_style="info"
+)
+_calculate_button.on_click(_handle_button_click)
+
+_clear_button = widgets.Button(description="Clear Output", button_style="danger")
+_clear_button.on_click(_handle_clear_click)
+
+
+def _create_widget(widget_type, **kwargs):
+    """
+    Creates a widget of the given type with the given parameters.
+    Widget can be with/without units dropdown.
+
+    Parameters
+    ----------
+    widget_type: `any`
+        Type of the widget to be created
+
+    **kwargs: `dict`
+        Parameters specific to the widget
+
+    Returns
+    -------
+    `~ipywidgets.widgets.Widget or [~ipywidgets.widgets.Widget, ~ipywidgets.widgets.Widget]`
+        widget or [widget, units_dropdown]
+    """
+    unit = None
+    placeholder = None
+    opts = None
+    if "unit" in kwargs:
+        unit = kwargs["unit"]
+        del kwargs["unit"]
+    if "placeholder" in kwargs:
+        placeholder = kwargs["placeholder"]
+        del kwargs["placeholder"]
+    if "opts" in kwargs:
+        opts = kwargs["opts"]
+        del kwargs["opts"]
+    widget_element = widget_type(**kwargs)
+    widget_element.create_widget()
+    if unit:
+        widget_element.set_unit(unit)
+    if placeholder:
+        widget_element.set_place_holder(placeholder)
+
+    if opts:
+        widget_element.attach_units_dropdown(opts)
+        widgets = [widget_element.get_widget(), widget_element.get_dropdown_widget()]
+    else:
+        widgets = widget_element.get_widget()
+    return widgets


Test patch: