Repository: microcombustion/ctwrap

Issue:
Make Parser more intuitive
Currently `Parser` requires a list to to specify parameter values with dimensions and comments, i.e.
```yaml
    T: [300., kelvin, 'temperature']
    P: [1., atmosphere, 'pressure']
    phi: [.55, dimensionless, 'equivalence ratio']
    oxidizer: 'O2:1,AR:5'
```
It would be more intuitive to simply use a string and parse values using `re` from
```yaml
    T: 300. kelvin (temperature)
    P: 1. atmosphere (pressure)
    phi: .55 (equivalence ratio)
    oxidizer: O2:1,AR:5
```

Discussion:

Patch:
diff --git a/ctwrap/__init__.py b/ctwrap/__init__.py
index 3e67077..d78ea40 100644
--- a/ctwrap/__init__.py
+++ b/ctwrap/__init__.py
@@ -1,6 +1,6 @@
 """Package exports."""
 from .simulation import Simulation, SimulationHandler
-from .parser import Parser, load_yaml, save_metadata
+from .parser import parse, write, Parser, load_yaml, save_metadata
 from . import modules
 
 from ._version import __version__, __author__
diff --git a/ctwrap/modules/adiabatic_flame.py b/ctwrap/modules/adiabatic_flame.py
index 3b96b84..b0325dc 100644
--- a/ctwrap/modules/adiabatic_flame.py
+++ b/ctwrap/modules/adiabatic_flame.py
@@ -22,19 +22,19 @@ except ImportError as err:
         UserWarning)
 
 
-# define default values for simulation parameters
+# define default values for simulation parameters (string notation)
 DEFAULTS = """\
-# default parameters for the `freeflame` module
+# default parameters for the `adiabatic_flame` module
 upstream:
-  T: [300., kelvin, 'temperature']
-  P: [1., atmosphere, 'pressure']
-  phi: [.55, dimensionless, 'equivalence ratio']
-  fuel: 'H2'
-  oxidizer: 'O2:1.,AR:5'
+  T: 300. kelvin (temperature)
+  P: 1. atmosphere (pressure)
+  phi: .55 (equivalence ratio)
+  fuel: H2
+  oxidizer: O2:1.,AR:5
 chemistry:
   mechanism: h2o2.yaml
 domain:
-  width: [30, millimeter, 'domain width']
+  width: 30 millimeter (domain width)
 """
 
 
diff --git a/ctwrap/modules/ignition.py b/ctwrap/modules/ignition.py
index 7d7e773..2bf34a2 100644
--- a/ctwrap/modules/ignition.py
+++ b/ctwrap/modules/ignition.py
@@ -22,7 +22,7 @@ except ImportError as err:
         UserWarning)
 
 
-# define default values for simulation parameters
+# define default values for simulation parameters (specified as list)
 DEFAULTS = """\
 # default parameters for the `ignition` module
 initial:
diff --git a/ctwrap/parser.py b/ctwrap/parser.py
index a63690c..ebd4b26 100644
--- a/ctwrap/parser.py
+++ b/ctwrap/parser.py
@@ -6,11 +6,72 @@ from pint import UnitRegistry
 from copy import deepcopy
 from ruamel import yaml
 import h5py
+import re
+
+
+__all__ = ['parse', 'Parser', 'load_yaml', 'save_metadata']
 
 
 ureg = UnitRegistry()
 
-__all__ = ['Parser', 'load_yaml', 'save_metadata']
+
+def parse(val: str):
+    """Parse string expression:
+
+    Arguments:
+       val: String expression
+
+    Returns:
+       `Tuple` containing value, unit, and comment
+    """
+
+    if not isinstance(val, str):
+        raise TypeError("Method requires string input")
+
+    value = re.findall(r'[-+]?\d*\.\d*|\d+', val)
+    if not (value and val[:len(value[0])] == value[0]):
+        return val, None, None
+
+    # string starts with value
+    value = value[0]
+    val = val[len(value):]
+
+    comment = re.findall(r'\(.*?\)', val)
+    if comment and val[-len(comment[-1]):] == comment[-1]:
+        val = val[:-len(comment[-1])]
+        comment = comment[-1][1:-1] # strip parentheses
+    else:
+        comment = None
+
+    val = val.strip()
+    if val:
+        unit = val
+    else:
+        unit = 'dimensionless'
+
+    return value, unit, comment
+
+
+def write(value: Union[int, float, str], unit: Optional[str]=None, comment: Optional[str]=None):
+    """Format value / unit / comment combination into parseable string
+
+    Arguments:
+       value: Value
+       unit: String describing unit
+       comment: Comment
+
+    Return:
+       Formatted string
+    """
+    out = '{}'.format(value)
+
+    if unit is not None and unit != 'dimensionless':
+        out += ' {}'.format(unit)
+
+    if comment is not None:
+        out += ' ({})'.format(comment)
+
+    return out
 
 
 class Parser(object):
@@ -28,7 +89,6 @@ class Parser(object):
         Argument:
             dct (dict) : dictionary to be parsed
         """
-
         self.raw = dct
 
     def __getattr__(self, attr: str) -> str:
@@ -39,7 +99,7 @@ class Parser(object):
              attr (str): attribute
 
         Returns:
-            Atrribute
+            Attribute
         """
 
         if attr not in self.raw:
@@ -47,24 +107,31 @@ class Parser(object):
 
         return self[attr]
 
-    def __getitem__(self, key: str) -> Union[str, bool, float, Tuple]:
+    def __getitem__(self, key: str) -> Union[str, bool, int, float, ureg.Quantity]:
         """
         Get item
 
-        Argument:
+        Arguments:
             key (str): key
 
         Returns:
             Corresponding values from the key
-
         """
-
         val = self.raw[key]
 
-        if isinstance(val, (str, bool, int, float)):
+        if isinstance(val, (bool, int, float)):
             return val
-        elif isinstance(val, list) and len(val) > 0:
-            return ureg.Quantity(val[0], ureg[val[1]])
+
+        if isinstance(val, str):
+            value, unit, comment = parse(val)
+            if value and unit:
+                return ureg.Quantity(value + unit)
+            return val
+
+        if isinstance(val, (list, tuple)) and len(val) > 1:
+            return ureg.Quantity(val[0], val[1])
+
+        raise NotImplementedError("Cannot parse {}".format(value))
 
     def __repr__(self):
         return repr(self.raw)
diff --git a/ctwrap/simulation.py b/ctwrap/simulation.py
index cffa27d..c72a016 100644
--- a/ctwrap/simulation.py
+++ b/ctwrap/simulation.py
@@ -20,7 +20,7 @@ import multiprocessing as mp
 import queue  # imported for using queue.Empty exception
 
 # ctwrap specific import
-from .parser import load_yaml, save_metadata
+from .parser import parse, write, load_yaml, save_metadata
 
 
 supported = ('.h5', '.hdf', '.hdf5')
@@ -427,6 +427,9 @@ class SimulationHandler(object):
             if len(key_list) == 1:
                 if isinstance(sub, list):
                     sub[0] = value
+                elif isinstance(sub, str):
+                    _, unit, comment = parse(sub)
+                    sub = write(value, unit, comment)
                 else:
                     sub = value
             else:
diff --git a/yaml/adiabatic_flame.yaml b/yaml/adiabatic_flame.yaml
index 258dc47..3847128 100644
--- a/yaml/adiabatic_flame.yaml
+++ b/yaml/adiabatic_flame.yaml
@@ -7,13 +7,13 @@ output:
   format: h5
 defaults:
   upstream:
-    T: [300., kelvin, 'temperature']
-    P: [1., atmosphere, 'pressure']
-    phi: [.55, dimensionless, 'equivalence ratio']
+    T: 300. kelvin (temperature)
+    P: 1. atmosphere (pressure)
+    phi: .55 (equivalence ratio)
     fuel: 'H2'
     oxidizer: 'O2:1,AR:5'
   chemistry:
     mechanism: h2o2.yaml
   domain:
-    width: [30, millimeter, 'domain width']
-ctwrap: 0.1.0
+    width: 30 millimeter (domain width)
+ctwrap: 0.2.0


Test patch:
diff --git a/tests/test_ctwrap.py b/tests/test_ctwrap.py
index 51a1076..08abc25 100644
--- a/tests/test_ctwrap.py
+++ b/tests/test_ctwrap.py
@@ -16,20 +16,6 @@ ROOT = PWD.parents[0]
 EXAMPLES = '{}'.format(ROOT / 'yaml')
 
 
-class TestParser(unittest.TestCase):
-
-    def test_parser(self):
-
-        defaults = cw.parser.load_yaml(
-            'ignition.yaml', path=EXAMPLES, keys=['defaults'])
-        p = cw.Parser(defaults[0]['initial'])
-        self.assertIsInstance(p.T, pq.Quantity)
-        self.assertIsInstance(p.T.m, float)
-        self.assertEqual(str(p.T.units), 'kelvin')
-        self.assertEqual(p.T.m - 273.15, p.T.m_as('degC'))
-        self.assertIsInstance(p.fuel, str)
-
-
 class TestWrap(unittest.TestCase):
 
     _module = cw.modules.minimal
diff --git a/tests/test_parse.py b/tests/test_parse.py
new file mode 100644
index 0000000..bae151b
--- /dev/null
+++ b/tests/test_parse.py
@@ -0,0 +1,99 @@
+#!/usr/bin/env python
+# -*- coding: utf-8 -*-
+"""rudimentary unit tests
+"""
+
+import unittest
+from pathlib import Path
+import pint.quantity as pq
+
+import ctwrap as cw
+
+
+PWD = Path(__file__).parents[0]
+ROOT = PWD.parents[0]
+EXAMPLES = '{}'.format(ROOT / 'yaml')
+
+
+class TestParse(unittest.TestCase):
+
+    def test_string(self):
+
+        value, unit, comment = cw.parse('hello world')
+        self.assertEqual(value, 'hello world')
+        self.assertIsNone(unit)
+        self.assertIsNone(comment)
+
+    def test_full(self):
+
+        value, unit, comment = cw.parse('1 spam (eggs)')
+        self.assertEqual(value, '1')
+        self.assertEqual(unit, 'spam')
+        self.assertEqual(comment, 'eggs')
+
+    def test_no_unit(self):
+
+        value, unit, comment = cw.parse('2. (spam eggs)')
+        self.assertEqual(value, '2.')
+        self.assertEqual(unit, 'dimensionless')
+        self.assertEqual(comment, 'spam eggs')
+
+    def test_no_comment(self):
+
+        value, unit, comment = cw.parse('2.3 spam eggs')
+        self.assertEqual(value, '2.3')
+        self.assertEqual(unit, 'spam eggs')
+        self.assertIsNone(comment)
+
+
+class TestWrite(unittest.TestCase):
+
+    def test_string(self):
+
+        value = cw.write('hello world')
+        self.assertEqual(value, 'hello world')
+
+    def test_full(self):
+
+        value = cw.write(1, 'spam', 'eggs')
+        self.assertEqual(value, '1 spam (eggs)')
+
+    def test_no_unit1(self):
+
+        value = cw.write(2., None, 'spam eggs')
+        self.assertEqual(value, '2.0 (spam eggs)')
+
+    def test_no_unit2(self):
+
+        value = cw.write(2., 'dimensionless', 'spam eggs')
+        self.assertEqual(value, '2.0 (spam eggs)')
+
+    def test_no_comment(self):
+
+        value = cw.write(2.3, 'spam eggs')
+        self.assertEqual(value, '2.3 spam eggs')
+
+
+class TestParser(unittest.TestCase):
+
+    def test_ignition(self):
+
+        defaults = cw.parser.load_yaml(
+            'ignition.yaml', path=EXAMPLES, keys=['defaults'])
+        p = cw.Parser(defaults[0]['initial'])
+        self.assertIsInstance(p.T, pq.Quantity)
+        self.assertIsInstance(p.T.m, float)
+        self.assertEqual(str(p.T.units), 'kelvin')
+        self.assertEqual(p.T.m - 273.15, p.T.m_as('degC'))
+        self.assertIsInstance(p.fuel, str)
+
+    def test_adiabatic_flame(self):
+
+        defaults = cw.parser.load_yaml(
+            'adiabatic_flame.yaml', path=EXAMPLES, keys=['defaults'])
+        p = cw.Parser(defaults[0]['upstream'])
+        self.assertIsInstance(p.T, pq.Quantity)
+        self.assertIsInstance(p.T.m, float)
+        self.assertEqual(str(p.T.units), 'kelvin')
+        self.assertEqual(p.T.m - 273.15, p.T.m_as('degC'))
+        self.assertIsInstance(p.fuel, str)