Repository: pyjanitor-devs/pyjanitor

Issue:
[ENH] Unit Conversion
# Brief Description

I would like to propose a set of `engineering` methods that would convert units (e.g., Fahrenheit to Celsius, or Pascals to mmHg, etc.) as well as prefixes (e.g., milligrams to kilograms, kWh to MWh, etc.).

This could go down a big rabbit hole.  But maybe we could rely on [Pint](https://pint.readthedocs.io/en/0.9/), or a similar package.

# Example API

```python
# Convert a column of temperatures in Celsius to Fahrenheit
df.convert_units(column_name='temperature_c',
                 existing_units='C',
                 to_units='F',
                 create_new_column=False)
```



Discussion:
I don’t know how they compare, but it might be worth evaluating [unyt](https://unyt.readthedocs.io/en/stable/) as well.
This is a great idea! I will suggest the following API change, though, to match it more closely to the `transform_column` API:

```python
# Convert a column of temperatures in Celsius to Fahrenheit
df.convert_units(
    column_name='temperature_c',
    existing_units='C',
    to_units='F',
    dest_column_name=None)
```

Suggesting this change only because it semantically is of the same idea as `transform_column`, so it's nice to keep these semantically-similar functions together.
After looking at pint and unyt, unyt appears to potentially be a better choice.  It definitely has more recent development, but its use will require sympy (as well as unyt itself, of course).

I would like to work on incorporating this convert_units() method.
Awesome stuff, thanks @rahosbach! Looking forward to your PRs :smile:. I'll mark this as "being hacked on".

Resolving patch:
diff --git a/janitor/engineering.py b/janitor/engineering.py
new file mode 100644
--- /dev/null
+++ b/janitor/engineering.py
@@ -0,0 +1,101 @@
+"""
+Engineering-specific data cleaning functions.
+"""
+
+import numpy as np
+import pandas as pd
+import pandas_flavor as pf
+
+from janitor import check
+
+from .utils import import_message
+
+try:
+    import unyt
+except ImportError:
+    import_message("engineering", "unyt", "conda install -c conda-forge unyt")
+
+
+@pf.register_dataframe_method
+def convert_units(
+    df: pd.DataFrame,
+    column_name: str = None,
+    existing_units: str = None,
+    to_units: str = None,
+    dest_column_name: str = None,
+) -> pd.DataFrame:
+    """
+    Converts a column of numeric values from one unit to another.
+
+    Functional usage example:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor.engineering
+
+        df = pd.DataFrame(...)
+
+        df = janitor.engineering.convert_units(
+            df=df,
+            column_name='temp_F',
+            existing_units='degF',
+            to_units='degC',
+            dest_column_name='temp_C'
+        )
+
+    Method chaining usage example:
+
+    .. code-block:: python
+
+        import pandas as pd
+        import janitor.engineering
+
+        df = pd.DataFrame(...)
+
+        df = df.convert_units(
+            column_name='temp_F',
+            existing_units='degF',
+            to_units='degC',
+            dest_column_name='temp_C'
+        )
+
+    Unit conversion can only take place if the existing_units and
+    to_units are of the same type (e.g., temperature or pressure).
+    The provided unit types can be any unit name or alternate name provided
+    in the unyt package's Listing of Units table:
+    https://unyt.readthedocs.io/en/stable/unit_listing.html#unit-listing.
+
+    Volume units are not provided natively in unyt.  However, exponents are
+    supported, and therefore some volume units can be converted.  For example,
+    a volume in cubic centimeters can be converted to cubic meters using
+    existing_units='cm**3' and to_units='m**3'.
+
+    This method mutates the original DataFrame.
+
+    :param df: A pandas dataframe.
+    :param column_name: Name of the column containing numeric
+        values that are to be converted from one set of units to another.
+    :param existing_units: The unit type to convert from.
+    :param to_units: The unit type to convert to.
+    :param dest_column_name: The name of the new column containing the
+        converted values that will be created.
+
+    :returns: A pandas DataFrame with a new column of unit-converted values.
+    """
+
+    # Check all inputs are correct data type
+    check("column_name", column_name, [str])
+    check("existing_units", existing_units, [str])
+    check("to_units", to_units, [str])
+    check("dest_column_name", dest_column_name, [str])
+
+    # Check that column_name is a numeric column
+    if not np.issubdtype(df[column_name].dtype, np.number):
+        raise TypeError(f"{column_name} must be a numeric column.")
+
+    original_vals = df[column_name].values * unyt.Unit(existing_units)
+    converted_vals = original_vals.to(to_units)
+    df[dest_column_name] = np.array(converted_vals)
+
+    return df


Test patch:
diff --git a/tests/engineering/test_convert_units.py b/tests/engineering/test_convert_units.py
new file mode 100644
--- /dev/null
+++ b/tests/engineering/test_convert_units.py
@@ -0,0 +1,97 @@
+import pytest
+from unyt.exceptions import UnitConversionError
+
+from janitor.engineering import convert_units
+
+
+@pytest.mark.engineering
+def test_datatypes_check(dataframe):
+    # All argument inputs to convert_units() must be strings
+    with pytest.raises(TypeError):
+        assert dataframe.convert_units(
+            123, existing_units="cm", to_units="m", dest_column_name="a_m"
+        )
+        assert dataframe.convert_units(
+            "a", existing_units=123, to_units="m", dest_column_name="a_m"
+        )
+        assert dataframe.convert_units(
+            "a", existing_units="cm", to_units=123, dest_column_name="a_m"
+        )
+        assert dataframe.convert_units(
+            "a", existing_units="cm", to_units="m", dest_column_name=123
+        )
+
+
+@pytest.mark.engineering
+def test_numeric_column(dataframe):
+    with pytest.raises(TypeError):
+        assert dataframe.convert_units(
+            "cities",
+            existing_units="cm",
+            to_units="m",
+            dest_column_name="len_m",
+        )
+
+
+@pytest.mark.engineering
+def test_unit_dimensions(dataframe):
+    # Attempts to convert length units to mass
+    with pytest.raises(UnitConversionError):
+        assert dataframe.convert_units(
+            "a", existing_units="cm", to_units="kg", dest_column_name="a_kg"
+        )
+    # Attempts to convert area and volume units
+    with pytest.raises(UnitConversionError):
+        assert dataframe.convert_units(
+            "a",
+            existing_units="cm**2",
+            to_units="cm**3",
+            dest_column_name="a_cm3",
+        )
+    with pytest.raises(UnitConversionError):
+        assert dataframe.convert_units(
+            "a",
+            existing_units="cm**2",
+            to_units="kg**2",
+            dest_column_name="a_kg2",
+        )
+
+
+@pytest.mark.engineering
+def test_no_conversion_equal(dataframe):
+    df = dataframe.convert_units(
+        "a", existing_units="cm", to_units="cm", dest_column_name="a_cm"
+    )
+    assert all(df["a"] == df["a_cm"])
+
+
+@pytest.mark.engineering
+def test_length_conversion(dataframe):
+    df = dataframe.convert_units(
+        "a", existing_units="cm", to_units="m", dest_column_name="a_m"
+    )
+    assert df["a"].sum() == pytest.approx(df["a_m"].sum() * 100.0)
+
+
+@pytest.mark.engineering
+def test_mass_conversion(dataframe):
+    df = dataframe.convert_units(
+        "a", existing_units="g", to_units="kg", dest_column_name="a_kg"
+    )
+    assert df["a"].sum() == pytest.approx(df["a_kg"].sum() * 1000.0)
+
+
+@pytest.mark.engineering
+def test_area_conversion(dataframe):
+    df = dataframe.convert_units(
+        "a", existing_units="cm**2", to_units="m**2", dest_column_name="a_m2"
+    )
+    assert df["a"].sum() == pytest.approx(df["a_m2"].sum() * (100.0 ** 2))
+
+
+@pytest.mark.engineering
+def test_volume_conversion(dataframe):
+    df = dataframe.convert_units(
+        "a", existing_units="cm**3", to_units="m**3", dest_column_name="a_m3"
+    )
+    assert df["a"].sum() == pytest.approx(df["a_m3"].sum() * (100.0 ** 3))