Repository: GenericMappingTools/pygmt

Issue:
Support passing in degrees, minutes, seconds (DMS) geographical coordinates 
**Description of the desired feature**

In GMT, geographical coordinates can be given in floating points or many [different geographical formats](https://docs.generic-mapping-tools.org/latest/cookbook/features.html#input-data-formats) (e.g., `23:30:30E`), but PyGMT doesn't support these common geographical formats yet.

Here is a script to show what it looks like:

```python
import pygmt

fig = pygmt.Figure()
fig.basemap(region=[-10, 10, -10, 10], projection='M10c', frame=True)
fig.plot(x=5.0, y=5.0, style='c0.5c', color='red')
fig.plot(x='3:30W', y='5:30W', style='c0.5c', color='blue')
fig.show()
```

**Expected output:**

![image](https://user-images.githubusercontent.com/3974108/95646222-48568780-0a94-11eb-97cc-bb7b861c4b57.png)

**Actual output**

```
Traceback (most recent call last):
  File "/Users/seisman/Gits/gmt/pygmt/pygmt/clib/session.py", line 728, in _check_dtype_and_dim
    array = np.asarray(array, dtype=np.datetime64)
  File "/Users/seisman/.anaconda/lib/python3.8/site-packages/numpy/core/_asarray.py", line 85, in asarray
    return array(a, dtype, copy=False, order=order)
ValueError: Error parsing datetime string "3:30W" at position 1

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  File "test.py", line 6, in <module>
    fig.plot(x='3:30W', y='5:30W', style='c0.5c', color='blue')
  File "/Users/seisman/Gits/gmt/pygmt/pygmt/helpers/decorators.py", line 270, in new_module
    return module_func(*args, **kwargs)
  File "/Users/seisman/Gits/gmt/pygmt/pygmt/helpers/decorators.py", line 411, in new_module
    return module_func(*args, **kwargs)
  File "/Users/seisman/Gits/gmt/pygmt/pygmt/base_plotting.py", line 720, in plot
    with file_context as fname:
  File "/Users/seisman/.anaconda/lib/python3.8/contextlib.py", line 113, in __enter__
    return next(self.gen)
  File "/Users/seisman/Gits/gmt/pygmt/pygmt/clib/session.py", line 1174, in virtualfile_from_vectors
    self.put_vector(dataset, column=col, vector=array)
  File "/Users/seisman/Gits/gmt/pygmt/pygmt/clib/session.py", line 778, in put_vector
    gmt_type = self._check_dtype_and_dim(vector, ndim=1)
  File "/Users/seisman/Gits/gmt/pygmt/pygmt/clib/session.py", line 730, in _check_dtype_and_dim
    raise GMTInvalidInput(
pygmt.exceptions.GMTInvalidInput: Unsupported numpy data type '<class 'numpy.str_'>'.
```

**Possible solutions**

Here are two possible solutions in my mind:

1. Currently, `GMT_Put_Vector` doesn't support string-like geographical coordinates. It could be a missing feature of the GMT API. With the new data type added, PyGMT still needs to determine if a string is a valid geographical coordinate before passing to GMT.
2. PyGMT may provide a utility function to convert these geographical formats into floating points data. In this case, we don't need the feature in the GMT API. However, if I understand it correctly, geographical coordinates like `3:30W` is still a little different from floating points `-3.5`. For `3:30W`, GMT knows the input is geographical coordinates and may have more automatic settings for geographical coordinates.

**Are you willing to help implement and maintain this feature?** Yes, but contributions are welcome.


Discussion:
seisman: /test-gmt-dev
weiji14: Can you add a doc API line at https://github.com/GenericMappingTools/pygmt/blame/v0.3.0/doc/api/index.rst#L214 for `clib.Session.put_strings`? Just realized we forgot to add it during #520.
seisman: > Can you add a doc API line at [`v0.3.0`/doc/api/index.rst#L214 (blame)](https://github.com/GenericMappingTools/pygmt/blame/v0.3.0/doc/api/index.rst#L214) for `clib.Session.put_strings`? Just realized we forgot to add it during #520.

Done in 24ad0afd69e0d958e4a9c469b48354178b18debb
seisman: See https://github.com/GenericMappingTools/pygmt/actions/runs/609196993 for the tests with GMT dev versions.

All the tests (including the new one) pass. 

Tests with GMT 6.1.1 fails, because in the old codes, datetime strings can processed by `pd.to_datetime`, but now datetime strings are passed to GMT directly. As expected, passing datetime strings to GMT works for GMT dev but not GMT 6.1.1, so we can't merge the PR unless GMT 6.2.0 is released.


seisman: /test-gmt-dev
seisman: /test-gmt-dev

Patch:
diff --git a/doc/api/index.rst b/doc/api/index.rst
index 6460eb53..0c07c18a 100644
--- a/doc/api/index.rst
+++ b/doc/api/index.rst
@@ -224,6 +224,7 @@ Low level access (these are mostly used by the :mod:`pygmt.clib` package):
     clib.Session.get_default
     clib.Session.create_data
     clib.Session.put_matrix
+    clib.Session.put_strings
     clib.Session.put_vector
     clib.Session.write_data
     clib.Session.open_virtual_file
diff --git a/pygmt/clib/session.py b/pygmt/clib/session.py
index 3e79f558..d13cb911 100644
--- a/pygmt/clib/session.py
+++ b/pygmt/clib/session.py
@@ -60,6 +60,7 @@ DTYPES = {
     np.uint64: "GMT_ULONG",
     np.uint32: "GMT_UINT",
     np.datetime64: "GMT_DATETIME",
+    np.str_: "GMT_TEXT",
 }
 
 
@@ -719,9 +720,7 @@ class Session:
         """
         # check the array has the given dimension
         if array.ndim != ndim:
-            raise GMTInvalidInput(
-                "Expected a numpy 1d array, got {}d.".format(array.ndim)
-            )
+            raise GMTInvalidInput(f"Expected a numpy 1d array, got {array.ndim}d.")
 
         # check the array has a valid/known data type
         if array.dtype.type not in DTYPES:
@@ -745,7 +744,7 @@ class Session:
         first. Use ``family='GMT_IS_DATASET|GMT_VIA_VECTOR'``.
 
         Not at all numpy dtypes are supported, only: float64, float32, int64,
-        int32, uint64, and uint32.
+        int32, uint64, uint32, datetime64 and str_.
 
         .. warning::
             The numpy array must be C contiguous in memory. If it comes from a
@@ -777,11 +776,14 @@ class Session:
         )
 
         gmt_type = self._check_dtype_and_dim(vector, ndim=1)
-        if gmt_type == self["GMT_DATETIME"]:
+        if gmt_type in (self["GMT_TEXT"], self["GMT_DATETIME"]):
             vector_pointer = (ctp.c_char_p * len(vector))()
-            vector_pointer[:] = np.char.encode(
-                np.datetime_as_string(array_to_datetime(vector))
-            )
+            if gmt_type == self["GMT_DATETIME"]:
+                vector_pointer[:] = np.char.encode(
+                    np.datetime_as_string(array_to_datetime(vector))
+                )
+            else:
+                vector_pointer[:] = np.char.encode(vector)
         else:
             vector_pointer = vector.ctypes.data_as(ctp.c_void_p)
         status = c_put_vector(
@@ -789,11 +791,9 @@ class Session:
         )
         if status != 0:
             raise GMTCLibError(
-                " ".join(
-                    [
-                        "Failed to put vector of type {}".format(vector.dtype),
-                        "in column {} of dataset.".format(column),
-                    ]
+                (
+                    f"Failed to put vector of type {vector.dtype} "
+                    f"in column {column} of dataset."
                 )
             )
 


Test patch:
diff --git a/pygmt/tests/test_clib_put_vector.py b/pygmt/tests/test_clib_put_vector.py
index 72a4c858..1d085eb9 100644
--- a/pygmt/tests/test_clib_put_vector.py
+++ b/pygmt/tests/test_clib_put_vector.py
@@ -2,6 +2,7 @@
 Test the functions that put vector data into GMT.
 """
 import itertools
+from datetime import datetime
 
 import numpy as np
 import numpy.testing as npt
@@ -90,6 +91,75 @@ def test_put_vector_mixed_dtypes():
                 npt.assert_allclose(newy, y)
 
 
+def test_put_vector_string_dtype():
+    """
+    Passing string type vectors to a dataset.
+    """
+    # input string vectors: numbers, longitudes, latitudes, and datetimes
+    vectors = np.array(
+        [
+            ["10", "20.0", "-30.0", "3.5e1"],
+            ["10W", "30.50E", "30:30W", "40:30:30.500E"],
+            ["10N", "30.50S", "30:30N", "40:30:30.500S"],
+            ["2021-02-03", "2021-02-03T04", "2021-02-03T04:05:06.700", "T04:50:06.700"],
+        ]
+    )
+    # output vectors in double or string type
+    # Notes:
+    # 1. longitudes and latitudes are stored in double in GMT
+    # 2. The default output format for datetime is YYYY-mm-ddTHH:MM:SS
+    expected_vectors = [
+        [10.0, 20.0, -30.0, 35],
+        [-10, 30.5, -30.5, 40.508472],
+        [10, -30.50, 30.5, -40.508472],
+        [
+            "2021-02-03T00:00:00",
+            "2021-02-03T04:00:00",
+            "2021-02-03T04:05:06",
+            f"{datetime.utcnow().strftime('%Y-%m-%d')}T04:50:06",
+        ],
+    ]
+
+    # loop over all possible combinations of input types
+    for i, j in itertools.combinations_with_replacement(range(4), r=2):
+        with clib.Session() as lib:
+            dataset = lib.create_data(
+                family="GMT_IS_DATASET|GMT_VIA_VECTOR",
+                geometry="GMT_IS_POINT",
+                mode="GMT_CONTAINER_ONLY",
+                dim=[2, 4, 1, 0],  # columns, rows, layers, dtype
+            )
+            lib.put_vector(dataset, column=lib["GMT_X"], vector=vectors[i])
+            lib.put_vector(dataset, column=lib["GMT_Y"], vector=vectors[j])
+            # Turns out wesn doesn't matter for Datasets
+            wesn = [0] * 6
+            # Save the data to a file to see if it's being accessed correctly
+            with GMTTempFile() as tmp_file:
+                lib.write_data(
+                    "GMT_IS_VECTOR",
+                    "GMT_IS_POINT",
+                    "GMT_WRITE_SET",
+                    wesn,
+                    tmp_file.name,
+                    dataset,
+                )
+                # Load the data
+                output = np.genfromtxt(
+                    tmp_file.name, dtype=None, names=("x", "y"), encoding=None
+                )
+                # check that the output is correct
+                # Use npt.assert_allclose for numeric arrays
+                # and npt.assert_array_equal for string arrays
+                if i != 3:
+                    npt.assert_allclose(output["x"], expected_vectors[i])
+                else:
+                    npt.assert_array_equal(output["x"], expected_vectors[i])
+                if j != 3:
+                    npt.assert_allclose(output["y"], expected_vectors[j])
+                else:
+                    npt.assert_array_equal(output["y"], expected_vectors[j])
+
+
 def test_put_vector_invalid_dtype():
     """
     Check that it fails with an exception for invalid data types.