Repository: alteryx/woodwork (python)

Issue:
Initialize Series with tuple of nans fails
Initializing a series with a tuple of nans fails.


#### Code Sample

```python
ww.init_series(pd.Series([(1.0, 2.0), (float('nan'), float('nan'))]), logical_type='LatLong')
```

Returns this error
```
ValueError: Cannot initialize Woodwork. Series does not contain properly formatted LatLong data. Try reformatting before initializing or use the woodwork.init_series function to initialize.
```


Pull request description:
Fixes issue with initializing Woodwork Series with LatLong values
- Fixes issue with initializing Woodwork Series with LatLong values
- Closes #1298


Patch:
diff --git a/docs/source/release_notes.rst b/docs/source/release_notes.rst
index 2bdf624..5e8c0d5 100644
--- a/docs/source/release_notes.rst
+++ b/docs/source/release_notes.rst
@@ -14,6 +14,7 @@ Future Release
         * Add validation to Age, AgeFractional, and AgeNullable logical types (:pr:`1289`)
     * Fixes
         * Check range length in table stats without producing overflow error (:pr:`1287`)
+        * Fixes issue with initializing Woodwork Series with LatLong values (:pr:`1299`)
     * Changes
         * Remove framework for unused ``woodwork`` CLI (:pr:`1288`)
         * Add back support for Python 3.7 (:pr:`1292`)
diff --git a/woodwork/logical_types.py b/woodwork/logical_types.py
index 9527c0b..09b8157 100644
--- a/woodwork/logical_types.py
+++ b/woodwork/logical_types.py
@@ -440,7 +440,7 @@ class LatLong(LogicalType):
             series.name = name
         elif _is_koalas_series(series):
             formatted_series = series.to_pandas().apply(
-                _reformat_to_latlong, use_list=True
+                _reformat_to_latlong, is_koalas=True
             )
             series = ks.from_pandas(formatted_series)
         else:
diff --git a/woodwork/utils.py b/woodwork/utils.py
index 50ef5ff..d617390 100644
--- a/woodwork/utils.py
+++ b/woodwork/utils.py
@@ -8,6 +8,7 @@ import numpy as np
 import pandas as pd
 
 import woodwork as ww
+from woodwork.exceptions import TypeValidationError
 from woodwork.pandas_backport import guess_datetime_format
 
 # Dictionary mapping formats/content types to the appropriate pandas read function
@@ -214,42 +215,57 @@ def _is_url(string):
     return "http" in string
 
 
-def _reformat_to_latlong(latlong, use_list=False):
-    """Reformats LatLong columns to be tuples of floats. Uses (np.nan, np.nan) for null values."""
-    if _is_latlong_nan(latlong):
-        latlong = (np.nan, np.nan)
-
+def _reformat_to_latlong(latlong, is_koalas=False):
+    """
+    Accepts 2-tuple like values, or a single NaN like value.
+    NaN like values are replaced with np.nan.
+    """
     if isinstance(latlong, str):
+
+        nan_values = [x for x in ww.config.get_option("nan_values") if len(x)]
+        nan_values = "|".join(nan_values)
+
+        latlong = re.sub(nan_values, "None", latlong)
+
         try:
-            # Serialized latlong columns from csv or parquet will be strings, so null values will be
-            # read as the string 'nan' in pandas and Dask and 'NaN' in Koalas
-            # neither of which which is interpretable as a null value
-            if "nan" in latlong:
-                latlong = latlong.replace("nan", "None")
-            if "NaN" in latlong:
-                latlong = latlong.replace("NaN", "None")
             latlong = ast.literal_eval(latlong)
         except ValueError:
             pass
 
-    if isinstance(latlong, (tuple, list)):
+    if isinstance(latlong, (list, tuple)):
         if len(latlong) != 2:
-            raise ValueError(
+            raise TypeValidationError(
                 f"LatLong values must have exactly two values. {latlong} does not have two values."
             )
 
-        latitude, longitude = map(_to_latlong_float, latlong)
+        latitude, longitude = latlong
+
+        try:
+            latitude = _coerce_to_float(latitude)
+            longitude = _coerce_to_float(longitude)
+        except ValueError:
+            raise TypeValidationError(
+                f"LatLong values must be in decimal degrees. {latlong} does not have latitude or longitude values that can be converted to a float."
+            )
 
-        if use_list:
+        if is_koalas:
             return [latitude, longitude]
         return (latitude, longitude)
 
-    raise ValueError(
-        f"LatLongs must either be a tuple, a list, or a string representation of a tuple. {latlong} does not fit the criteria."
+    if _is_nan(latlong):
+        return np.nan
+
+    raise TypeValidationError(
+        f"""LatLong value is not properly formatted. Value must be one of the following:
+- A 2-tuple or list of 2 values representing decimal latitude or longitude values (NaN values are allowed).
+- A single NaN value.
+- A string representation of the above.
+
+{latlong} does not fit the criteria."""
     )
 
 
-def _to_latlong_float(val):
+def _coerce_to_float(val):
     """Attempts to convert a value to a float, propagating null values."""
     if _is_nan(val):
         return np.nan
@@ -258,7 +274,7 @@ def _to_latlong_float(val):
         return float(val)
     except (ValueError, TypeError):
         raise ValueError(
-            f"Latitude and Longitude values must be in decimal degrees. The latitude or longitude represented by {val} cannot be converted to a float."
+            f"The value represented by {val} cannot be converted to a float."
         )
 
 
@@ -266,28 +282,36 @@ def _is_valid_latlong_series(series):
     """Returns True if all elements in the series contain properly formatted LatLong values,
     otherwise returns False"""
     if ww.accessor_utils._is_dask_series(series):
-        series = series = series.get_partition(0).compute()
+        series = series.get_partition(0).compute()
     if ww.accessor_utils._is_koalas_series(series):
         series = series.to_pandas()
-        bracket_type = list
+        is_koalas = True
     else:
-        bracket_type = tuple
-    if series.apply(_is_valid_latlong_value, args=(bracket_type,)).all():
+        is_koalas = False
+    if series.apply(_is_valid_latlong_value, args=(is_koalas,)).all():
         return True
     return False
 
 
-def _is_valid_latlong_value(val, bracket_type=tuple):
+def _is_valid_latlong_value(val, is_koalas=False):
     """Returns True if the value provided is a properly formatted LatLong value for a
     pandas, Dask or Koalas Series, otherwise returns False."""
-    if isinstance(val, bracket_type) and len(val) == 2:
+    if isinstance(val, (list, tuple)):
+        if len(val) != 2:
+            return False
+
+        if not isinstance(val, list if is_koalas else tuple):
+            return False
+
         latitude, longitude = val
-        if isinstance(latitude, float) and isinstance(longitude, float):
-            if pd.isnull(latitude) and pd.isnull(longitude):
-                return False
-            return True
-    elif isinstance(val, float) and pd.isnull(val):
+        return isinstance(latitude, float) and isinstance(longitude, float)
+
+    if isinstance(val, float):
+        return np.isnan(val)
+
+    if is_koalas and val is None:
         return True
+
     return False
 
 


Test patch:
diff --git a/woodwork/tests/logical_types/test_logical_types.py b/woodwork/tests/logical_types/test_logical_types.py
index 50155ab..a1929c0 100644
--- a/woodwork/tests/logical_types/test_logical_types.py
+++ b/woodwork/tests/logical_types/test_logical_types.py
@@ -137,10 +137,8 @@ def test_latlong_transform(latlong_df):
         elif koalas:
             actual = actual.to_pandas()
 
-        actual = actual.apply(pd.Series)
-        series = pd.Series(expected_data[column])
-        expected = series.apply(pd.Series)
-        assert actual.equals(expected)
+        expected = pd.Series(expected_data[column], name=column)
+        pd.testing.assert_series_equal(actual, expected)
 
 
 def test_datetime_transform(datetimes):
diff --git a/woodwork/tests/utils/test_accessor_utils.py b/woodwork/tests/utils/test_accessor_utils.py
index ab30f73..88678c5 100644
--- a/woodwork/tests/utils/test_accessor_utils.py
+++ b/woodwork/tests/utils/test_accessor_utils.py
@@ -14,7 +14,7 @@ from woodwork.accessor_utils import (
     is_schema_valid,
 )
 from woodwork.exceptions import TypeConversionError
-from woodwork.logical_types import Categorical, Datetime, NaturalLanguage
+from woodwork.logical_types import Categorical, Datetime, LatLong, NaturalLanguage
 
 
 def test_init_series_valid_conversion_specified_ltype(sample_series):
@@ -101,6 +101,13 @@ def test_init_series_with_datetime(sample_datetime_series):
     assert isinstance(series.ww.logical_type, Datetime)
 
 
+def test_init_series_with_latlong(latlong_df):
+    for column in latlong_df:
+        series = latlong_df[column]
+        series = init_series(series, logical_type="LatLong")
+        assert isinstance(series.ww.logical_type, LatLong)
+
+
 def test_init_series_all_parameters(sample_series):
     if _is_koalas_series(sample_series):
         sample_series = sample_series.astype("str")
diff --git a/woodwork/tests/utils/test_utils.py b/woodwork/tests/utils/test_utils.py
index f64ce39..5ff7798 100644
--- a/woodwork/tests/utils/test_utils.py
+++ b/woodwork/tests/utils/test_utils.py
@@ -1,4 +1,3 @@
-import re
 from unittest.mock import patch
 
 import numpy as np
@@ -6,6 +5,7 @@ import pandas as pd
 import pytest
 
 import woodwork as ww
+from woodwork.exceptions import TypeValidationError
 from woodwork.logical_types import (
     Age,
     AgeFractional,
@@ -32,6 +32,7 @@ from woodwork.type_sys.utils import (
     list_semantic_tags,
 )
 from woodwork.utils import (
+    _coerce_to_float,
     _convert_input_to_set,
     _get_column_logical_type,
     _infer_datetime_format,
@@ -43,7 +44,6 @@ from woodwork.utils import (
     _is_valid_latlong_value,
     _parse_logical_type,
     _reformat_to_latlong,
-    _to_latlong_float,
     camel_to_snake,
     get_valid_mi_types,
     import_or_none,
@@ -243,87 +243,81 @@ def test_is_s3():
     assert not _is_s3("https://woodwork-static.s3.amazonaws.com/")
 
 
-def test_reformat_to_latlong_errors():
-    for latlong in [{1, 2, 3}, "{1, 2, 3}", "This is text"]:
-        error = f"LatLongs must either be a tuple, a list, or a string representation of a tuple. {latlong} does not fit the criteria."
-        with pytest.raises(ValueError, match=error):
-            _reformat_to_latlong(latlong)
-
-    error = re.escape(
-        "LatLongs must either be a tuple, a list, or a string representation of a tuple. (1,2) does not fit the criteria."
-    )
-    with pytest.raises(ValueError, match=error):
-        _reformat_to_latlong("'(1,2)'")
-
-    for latlong in [(1, 2, 3), "(1, 2, 3)"]:
-        error = re.escape(
-            "LatLong values must have exactly two values. (1, 2, 3) does not have two values."
-        )
-        with pytest.raises(ValueError, match=error):
-            _reformat_to_latlong(latlong)
-
-    error = re.escape(
-        "Latitude and Longitude values must be in decimal degrees. The latitude or longitude represented by 41deg52'54\" N cannot be converted to a float."
-    )
-    with pytest.raises(ValueError, match=error):
-        _reformat_to_latlong(("41deg52'54\" N", "21deg22'54\" W"))
-
-
-def test_reformat_to_latlong():
-    simple_latlong = (1, 2)
-
-    assert _reformat_to_latlong((1, 2)) == simple_latlong
-    assert _reformat_to_latlong(("1", "2")) == simple_latlong
-    assert _reformat_to_latlong("(1,2)") == simple_latlong
-
-    # Check non-standard tuple formats
-    assert _reformat_to_latlong([1, 2]) == simple_latlong
-    assert _reformat_to_latlong(["1", "2"]) == simple_latlong
-    assert _reformat_to_latlong("[1, 2]") == simple_latlong
-    assert _reformat_to_latlong("1, 2") == simple_latlong
-
-    assert _reformat_to_latlong(None) == (np.nan, np.nan)
-    assert _reformat_to_latlong((1, np.nan)) == (1, np.nan)
-    assert _reformat_to_latlong((np.nan, "1")) == (np.nan, 1)
-
-    # This is how csv and parquet will deserialize
-    assert _reformat_to_latlong("(1, nan)") == (1, np.nan)
-    assert _reformat_to_latlong("(NaN, 9)") == (np.nan, 9)
-
-
-def test_reformat_to_latlong_list():
-    simple_latlong = [1, 2]
-
-    assert _reformat_to_latlong((1, 2), use_list=True) == simple_latlong
-    assert _reformat_to_latlong(("1", "2"), use_list=True) == simple_latlong
-    assert _reformat_to_latlong("(1,2)", use_list=True) == simple_latlong
-
-    assert _reformat_to_latlong([1, 2], use_list=True) == simple_latlong
-    assert _reformat_to_latlong(["1", "2"], use_list=True) == simple_latlong
-    assert _reformat_to_latlong("[1, 2]", use_list=True) == simple_latlong
-    assert _reformat_to_latlong("1, 2", use_list=True) == simple_latlong
-
-    assert _reformat_to_latlong((1, np.nan), use_list=True) == [1, np.nan]
-    assert _reformat_to_latlong((np.nan, "1"), use_list=True) == [np.nan, 1]
-
-    # This is how csv and parquet will deserialize
-    assert _reformat_to_latlong("[1, nan]", use_list=True) == [1, np.nan]
-    assert _reformat_to_latlong("[1, NaN]", use_list=True) == [1, np.nan]
-
-
-def test_to_latlong_float():
-    assert _to_latlong_float(4) == 4.0
-    assert _to_latlong_float("2.2") == 2.2
-
-    assert _to_latlong_float(None) is np.nan
-    assert _to_latlong_float(np.nan) is np.nan
-    assert _to_latlong_float(pd.NA) is np.nan
-
-    error = re.escape(
-        "Latitude and Longitude values must be in decimal degrees. The latitude or longitude represented by [1, 2, 3] cannot be converted to a float."
-    )
-    with pytest.raises(ValueError, match=error):
-        _to_latlong_float([1, 2, 3])
+@pytest.mark.parametrize(
+    "test_input, error_msg",
+    [
+        ({1, 2, 3}, "LatLong value is not properly formatted."),
+        ("{1, 2, 3}", "LatLong value is not properly formatted."),
+        ("This is text", "LatLong value is not properly formatted."),
+        ("'(1,2)'", "LatLong value is not properly formatted."),
+        ((1, 2, 3), "LatLong values must have exactly two values"),
+        ("(1, 2, 3)", "LatLong values must have exactly two values"),
+        ("(1,)", "LatLong values must have exactly two values"),
+        (
+            ("41deg52'54\" N", "21deg22'54\" W"),
+            "LatLong values must be in decimal degrees.",
+        ),
+        ((41.5, "21deg22'54\" W"), "LatLong values must be in decimal degrees."),
+    ],
+)
+def test_reformat_to_latlong_errors(test_input, error_msg):
+    with pytest.raises(TypeValidationError, match=error_msg):
+        _reformat_to_latlong(test_input)
+
+
+@pytest.mark.parametrize(
+    "test_input,expected",
+    [
+        ((1, 2), (1, 2)),
+        (("1", "2"), (1, 2)),
+        ("(1,2)", (1, 2)),
+        ([1, 2], (1, 2)),
+        (["1", "2"], (1, 2)),
+        ("[1, 2]", (1, 2)),
+        ("1, 2", (1, 2)),
+        ((1, np.nan), (1, np.nan)),
+        ((np.nan, "1"), (np.nan, 1)),
+        ("(1, nan)", (1, np.nan)),
+        ("(NaN, 9)", (np.nan, 9)),
+        ("(1, None)", (1, np.nan)),
+        ("(<NA>, 9)", (np.nan, 9)),
+        ((np.nan, np.nan), (np.nan, np.nan)),
+        ((pd.NA, pd.NA), (np.nan, np.nan)),
+        ((None, None), (np.nan, np.nan)),
+        (None, np.nan),
+        (np.nan, np.nan),
+        (pd.NA, np.nan),
+        ("None", np.nan),
+        ("NaN", np.nan),
+        ("<NA>", np.nan),
+    ],
+)
+@pytest.mark.parametrize("is_koalas", [True, False])
+def test_reformat_to_latlong(test_input, expected, is_koalas):
+    if isinstance(expected, (list, tuple)):
+        if is_koalas:
+            assert _reformat_to_latlong(test_input, is_koalas) == list(expected)
+        else:
+            assert _reformat_to_latlong(test_input, is_koalas) == expected
+    else:
+        assert _reformat_to_latlong(test_input) is expected
+
+
+@pytest.mark.parametrize(
+    "test_input,expected",
+    [
+        (4, 4.0),
+        ("2.2", 2.2),
+        (None, np.nan),
+        (np.nan, np.nan),
+        (pd.NA, np.nan),
+    ],
+)
+def test_coerce_to_float(test_input, expected):
+    if np.isnan(expected):
+        assert _coerce_to_float(test_input) is expected
+    else:
+        assert _coerce_to_float(test_input) == expected
 
 
 def test_is_nan():
@@ -345,40 +339,52 @@ def test_is_nan():
     assert not _is_nan({"key": "value"})
 
 
-def test_is_valid_latlong_value():
-    values = [
-        (1.0, 2.0),
-        np.nan,
-        [1.0, 2.0],
-        (np.nan, np.nan),
-        ("a", 2.0),
-        (1.0, 2.0, 3.0),
-        None,
-    ]
-
-    expected_values = [True, True, False, False, False, False, False]
-
-    for index, value in enumerate(values):
-        assert _is_valid_latlong_value(value) is expected_values[index]
-
-
-def test_is_valid_latlong_value_koalas():
-    values = [
-        (1.0, 2.0),
-        np.nan,
-        [1.0, 2.0],
-        (np.nan, np.nan),
-        ("a", 2.0),
-        (1.0, 2.0, 3.0),
-        None,
-    ]
-
-    expected_values = [False, True, True, False, False, False, False]
-
-    for index, value in enumerate(values):
-        assert (
-            _is_valid_latlong_value(value, bracket_type=list) is expected_values[index]
-        )
+@pytest.mark.parametrize(
+    "test_input,expected",
+    [
+        ((1.0, 2.0), True),
+        ((1.0, np.nan), True),
+        ((np.nan, 2.0), True),
+        ((np.nan, np.nan), True),
+        (np.nan, True),
+        (pd.NA, False),
+        (2.0, False),
+        ([2.0], False),
+        ([None, None], False),
+        ("None", False),
+        ([1.0, 2.0], False),
+        ((pd.NA, pd.NA), False),
+        (("a", 2.0), False),
+        ((1.0, 2.0, 3.0), False),
+        (None, False),
+    ],
+)
+def test_is_valid_latlong_value(test_input, expected):
+    assert _is_valid_latlong_value(test_input) == expected
+
+
+@pytest.mark.parametrize(
+    "test_input,expected",
+    [
+        ([1.0, 2.0], True),
+        ([1.0, np.nan], True),
+        ([np.nan, 2.0], True),
+        ([np.nan, np.nan], True),
+        (np.nan, True),
+        (None, True),
+        (pd.NA, False),
+        (2.0, False),
+        ([2.0], False),
+        ([None, None], False),
+        ("None", False),
+        ((1.0, 2.0), False),
+        ((pd.NA, pd.NA), False),
+        (("a", 2.0), False),
+        ((1.0, 2.0, 3.0), False),
+    ],
+)
+def test_is_valid_latlong_value_koalas(test_input, expected):
+    assert _is_valid_latlong_value(test_input, is_koalas=True) == expected
 
 
 def test_is_valid_latlong_series():