Repository: natcap/invest
Pull request #595 (2021-07-23 22:06:58)

Add unit formatting function
# Description
Related to natcap/invest.users-guide#30
Moving the `pint.Unit` -> `str` functionality into invest so we can use it in the workbench also

After finding this [style guide for SI units](https://www.nist.gov/pml/special-publication-811/nist-guide-si-chapter-6-rules-and-style-conventions-printing-and-using) and talking to @jagoldstein, I'm convinced we should use the standard unit symbols everywhere rather than spelling them out. This has the benefit of being language-independent and also easier to auto-format.

@davemfish, you were right that the pint formatting features would be useful! It's not mentioned in the tutorial, but there's a whole customizable formatting function that we can use here.

## Question
We now have several custom units defined in `spec_utils.py` which we define programmatically using `u.define()`. These add to/overwrite pint's [default units file](https://github.com/hgrecco/pint/blob/master/pint/default_en.txt). There is the option to use a custom units file to replace that, and all our custom units could go in there. Thoughts?

# Checklist
~~- [ ] Updated HISTORY.rst (if these changes are user-facing)~~
~~- [ ] Updated the user's guide (if needed)~~



Patch:
diff --git a/src/natcap/invest/spec_utils.py b/src/natcap/invest/spec_utils.py
index 361a6876a..e20cd0cd7 100644
--- a/src/natcap/invest/spec_utils.py
+++ b/src/natcap/invest/spec_utils.py
@@ -23,11 +23,19 @@ u.define('survey_foot = 1200 / 3937 * meter = sft = us_survey_foot')
 # May be converted to weight or volume, but conversion factors are specific
 # to the substance. I couldn't find a definition of its dimensionality.
 u.define('international_unit = [biologic_amount] = iu = IU')
+# use 'h' not 'hr' as the symbol for hour, as per SI guidelines
+# overwrite the default use of the symbol 'h' for henries
+u.define('henry = weber / ampere')
+u.define('hour = 60 * minute = h = hr')
+# overwrite the year definitionto use 'yr' rather than 'a' as default symbol
+# the symbol 'yr' is english-specific and the international symbol 'a' may
+# not be well-known, so we will need to translate this
+u.define('year = 365.25 * day = yr = a = julian_year')
 # Use u.none for unitless measurements
 u.define('none = []')
 
-# Specs for common arg types ##################################################
 
+# Specs for common arg types ##################################################
 WORKSPACE = {
     "name": "Workspace",
     "about": (
@@ -156,3 +164,46 @@ LINES = LINESTRING | MULTILINESTRING
 POLYGONS = POLYGON | MULTIPOLYGON
 POINTS = POINT | MULTIPOINT
 ALL_GEOMS = LINES | POLYGONS | POINTS
+
+
+def format_unit(unit):
+    """Represent a pint Unit as user-friendly unicode text.
+
+    This attempts to follow the style guidelines from the NIST
+    Guide to the SI (https://www.nist.gov/pml/special-publication-811):
+    - Use standard symbols rather than spelling out
+    - Use '/' to represent division
+    - Use the center dot ' · ' to represent multiplication
+    - Combine denominators into one, surrounded by parentheses
+
+    Args:
+        unit (pint.Unit): the unit to format
+
+    Returns:
+        String describing the unit.
+    """
+    # Optionally use a pre-set format for a particular unit
+    custom_formats = {
+        # For soil erodibility (t*h*ha/(ha*MJ*mm)), by convention the ha's
+        # are left on top and bottom and don't cancel out
+        # pint always cancels units where it can, so add them back in here
+        # this isn't a perfect solution
+        # see https://github.com/hgrecco/pint/issues/1364
+        u.t * u.hr / (u.MJ * u.mm): 't · h · ha / (ha · MJ · mm)'
+    }
+    if unit in custom_formats:
+        return custom_formats[unit]
+
+    # look up the abbreviated symbol for each unit
+    # `formatter` expects an iterable of (unit, exponent) pairs, which lives in
+    # the pint.Unit's `_units` attribute.
+    unit_items = [(u.get_symbol(key), val) for key, val in unit._units.items()]
+    return pint.formatting.formatter(
+        unit_items,
+        as_ratio=True,
+        single_denominator=True,
+        product_fmt=" · ",
+        division_fmt='/',
+        power_fmt="{}{}",
+        parentheses_fmt="({})",
+        exp_call=pint.formatting._pretty_fmt_exponent)


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 835743536..91e02d773 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -211,6 +211,7 @@ class ExponentialDecayUtilsTests(unittest.TestCase):
 
 class SandboxTempdirTests(unittest.TestCase):
     """Test Sandbox Tempdir."""
+
     def setUp(self):
         """Setup workspace."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -237,6 +238,7 @@ class SandboxTempdirTests(unittest.TestCase):
 
 class TimeFormattingTests(unittest.TestCase):
     """Test Time Formatting."""
+
     def test_format_time_hours(self):
         """Test format time hours."""
         from natcap.invest.utils import _format_time
@@ -261,6 +263,7 @@ class TimeFormattingTests(unittest.TestCase):
 
 class LogToFileTests(unittest.TestCase):
     """Test Log To File."""
+
     def setUp(self):
         """Create a temporary workspace."""
         self.workspace = tempfile.mkdtemp()
@@ -332,6 +335,7 @@ class LogToFileTests(unittest.TestCase):
 
 class ThreadFilterTests(unittest.TestCase):
     """Test Thread Filter."""
+
     def test_thread_filter_same_thread(self):
         """Test threat filter same thread."""
         from natcap.invest.utils import ThreadFilter
@@ -420,6 +424,7 @@ class MakeDirectoryTests(unittest.TestCase):
 
 class GDALWarningsLoggingTests(unittest.TestCase):
     """Test GDAL Warnings Logging."""
+
     def setUp(self):
         """Create a temporary workspace."""
         self.workspace = tempfile.mkdtemp()
@@ -455,6 +460,7 @@ class GDALWarningsLoggingTests(unittest.TestCase):
 
 class PrepareWorkspaceTests(unittest.TestCase):
     """Test Prepare Workspace."""
+
     def setUp(self):
         """Create a temporary workspace."""
         self.workspace = tempfile.mkdtemp()
@@ -494,6 +500,7 @@ class PrepareWorkspaceTests(unittest.TestCase):
 
 class BuildLookupFromCSVTests(unittest.TestCase):
     """Tests for natcap.invest.utils.build_lookup_from_csv."""
+
     def setUp(self):
         """Make temporary directory for workspace."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -518,8 +525,8 @@ class BuildLookupFromCSVTests(unittest.TestCase):
                 'foo': -1.0,
                 'bar': 'bar',
                 '_': 'apple'
-                },
-            }
+            },
+        }
         self.assertDictEqual(result, expected_dict)
 
     def test_unique_key_not_first_column(self):
@@ -537,10 +544,10 @@ class BuildLookupFromCSVTests(unittest.TestCase):
         result = utils.build_lookup_from_csv(
             table_path, 'lucode', to_lower=True)
         expected_result = {
-                1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
-                2: {'desc': 'bread', 'val1': 1, 'val2': 4, 'lucode': 2},
-                3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
-                4: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4}}
+            1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
+            2: {'desc': 'bread', 'val1': 1, 'val2': 4, 'lucode': 2},
+            3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
+            4: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4}}
 
         self.assertDictEqual(result, expected_result)
 
@@ -589,10 +596,10 @@ class BuildLookupFromCSVTests(unittest.TestCase):
         result = utils.build_lookup_from_csv(
             table_path, 'lucode', to_lower=True)
         expected_result = {
-                1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
-                2: {'desc': '', 'val1': 1, 'val2': 4, 'lucode': 2},
-                3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
-                4: {'desc': 'butter', 'val1': '', 'val2': 1, 'lucode': 4}}
+            1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
+            2: {'desc': '', 'val1': 1, 'val2': 4, 'lucode': 2},
+            3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
+            4: {'desc': 'butter', 'val1': '', 'val2': 1, 'lucode': 4}}
 
         self.assertDictEqual(result, expected_result)
 
@@ -611,9 +618,9 @@ class BuildLookupFromCSVTests(unittest.TestCase):
         result = utils.build_lookup_from_csv(
             table_path, 'lucode', to_lower=True)
         expected_result = {
-                1.0: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1.0},
-                3.0: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3.0},
-                4.0: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4.0}}
+            1.0: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1.0},
+            3.0: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3.0},
+            4.0: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4.0}}
 
         self.assertDictEqual(result, expected_result)
 
@@ -633,10 +640,10 @@ class BuildLookupFromCSVTests(unittest.TestCase):
             table_path, 'lucode', to_lower=True, column_list=['val1', 'val2'])
 
         expected_result = {
-                1: {'val1': 0.5, 'val2': 2, 'lucode': 1},
-                2: {'val1': 1, 'val2': 4, 'lucode': 2},
-                3: {'val1': 0.5, 'val2': 4, 'lucode': 3},
-                4: {'val1': 9, 'val2': 1, 'lucode': 4}}
+            1: {'val1': 0.5, 'val2': 2, 'lucode': 1},
+            2: {'val1': 1, 'val2': 4, 'lucode': 2},
+            3: {'val1': 0.5, 'val2': 4, 'lucode': 3},
+            4: {'val1': 9, 'val2': 1, 'lucode': 4}}
 
         self.assertDictEqual(result, expected_result)
 
@@ -656,10 +663,10 @@ class BuildLookupFromCSVTests(unittest.TestCase):
             table_path, 'lucode', to_lower=True)
 
         expected_result = {
-                1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
-                2: {'desc': 'bread', 'val1': 1, 'val2': 4, 'lucode': 2},
-                3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
-                4: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4}}
+            1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
+            2: {'desc': 'bread', 'val1': 1, 'val2': 4, 'lucode': 2},
+            3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
+            4: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4}}
 
         self.assertDictEqual(result, expected_result)
 
@@ -679,10 +686,10 @@ class BuildLookupFromCSVTests(unittest.TestCase):
             table_path, 'lucode', to_lower=True)
 
         expected_result = {
-                1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
-                2: {'desc': 'bread', 'val1': 1, 'val2': 4, 'lucode': 2},
-                3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
-                4: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4}}
+            1: {'desc': 'corn', 'val1': 0.5, 'val2': 2, 'lucode': 1},
+            2: {'desc': 'bread', 'val1': 1, 'val2': 4, 'lucode': 2},
+            3: {'desc': 'beans', 'val1': 0.5, 'val2': 4, 'lucode': 3},
+            4: {'desc': 'butter', 'val1': 9, 'val2': 1, 'lucode': 4}}
 
         self.assertDictEqual(result, expected_result)
 
@@ -914,7 +921,7 @@ class ReadCSVToDataframeTests(unittest.TestCase):
     def test_csv_with_integer_headers(self):
         """
         utils: CSV with integer headers should be read into strings.
-        
+
         This shouldn't matter for any of the models, but if a user inputs a CSV
         with extra columns that are labeled with numbers, it should still work.
         """
@@ -1034,6 +1041,7 @@ class CreateCoordinateTransformationTests(unittest.TestCase):
 
 class AssertVectorsEqualTests(unittest.TestCase):
     """Tests for natcap.invest.utils._assert_vectors_equal."""
+
     def setUp(self):
         """Setup workspace."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -1406,9 +1414,10 @@ class AssertVectorsEqualTests(unittest.TestCase):
 
         self.assertTrue("Vector geometry assertion fail." in str(cm.exception))
 
+
 class ReclassifyRasterOpTests(unittest.TestCase):
     """Tests for natcap.invest.utils.reclassify_raster."""
-    
+
     def setUp(self):
         """Setup workspace."""
         self.workspace_dir = tempfile.mkdtemp()
@@ -1426,8 +1435,9 @@ class ReclassifyRasterOpTests(unittest.TestCase):
         projection_wkt = srs_copy.ExportToWkt()
         origin = (1180000, 690000)
         raster_path = os.path.join(self.workspace_dir, 'tmp_raster.tif')
-        
-        array = numpy.array([[1,1,1], [2,2,2], [3,3,3]], dtype=numpy.int32)
+
+        array = numpy.array(
+            [[1, 1, 1], [2, 2, 2], [3, 3, 3]], dtype=numpy.int32)
 
         pygeoprocessing.numpy_array_to_raster(
             array, -1, (1, -1), origin, projection_wkt, raster_path)
@@ -1437,17 +1447,35 @@ class ReclassifyRasterOpTests(unittest.TestCase):
             self.workspace_dir, 'tmp_raster_out.tif')
 
         message_details = {
-            'raster_name': 'LULC', 'column_name': 'lucode', 
+            'raster_name': 'LULC', 'column_name': 'lucode',
             'table_name': 'Biophysical'}
 
         with self.assertRaises(ValueError) as context:
             utils.reclassify_raster(
-                (raster_path, 1), value_map, target_raster_path, 
+                (raster_path, 1), value_map, target_raster_path,
                 gdal.GDT_Int32, -1, error_details=message_details)
         expected_message = (
-                "Values in the LULC raster were found that are"
-                " not represented under the 'lucode' column"
-                " of the Biophysical table. The missing values found in"
-                " the LULC raster but not the table are: [3].")
+            "Values in the LULC raster were found that are"
+            " not represented under the 'lucode' column"
+            " of the Biophysical table. The missing values found in"
+            " the LULC raster but not the table are: [3].")
         self.assertTrue(
             expected_message in str(context.exception), str(context.exception))
+
+
+class SpecUtilsTests(unittest.TestCase):
+    """Tests for natcap.invest.spec_utils."""
+
+    def test_format_unit(self):
+        """spec_utils: test converting units to strings with format_unit."""
+        from natcap.invest import spec_utils
+        for unit_name, expected in [
+                ('meter', 'm'),
+                ('meter / second', 'm/s'),
+                ('foot * mm', 'ft · mm'),
+                ('t * hr * ha / ha / MJ / mm', 't · h · ha / (ha · MJ · mm)'),
+                ('mm^3 / year', 'mm³/yr')
+        ]:
+            unit = spec_utils.u.Unit(unit_name)
+            actual = spec_utils.format_unit(unit)
+            self.assertEqual(expected, actual)