Repository: hgrecco/pint-pandas

Issue:
Parsing CSV with units in the header
This is not really a bug report, but more a suggestion for who may have the same issue as me (however, I would not be angry if a similar solution would be integrated in pint-pandas).

Let suppose you have a CSV like this:
```
molecule,reduced_field [1e-21 * V * m^2],magboltz_drift_velocity [m / s],magboltz_drift_velocity_precision,bolsig_reduced_mobility [1 / V / m / s],bolsig_drift_velocity [m / s],bolsig_delta,bolsig_validation,betaboltz_drift_velocity [m / s],betaboltz_drift_velocity_stdev [m / s],betaboltz_delta,betaboltz_validation
Ar,0.1,1702.9999999999998,0.45999999999999996,1.647e+25,1647.0,-3.2883147386964144,OK,1806.69,623.853,6.0886670581327165,OK
Ar,0.12589254117941673,1811.9999999999998,0.42,1.394e+25,1755.046,-3.143156732891819,OK,1891.57,504.651,4.3912803532008935,OK
Ar,0.15848931924611134,1932.9999999999998,0.62,1.179e+25,1868.715,-3.32565959648215,OK,2209.5899999999997,619.583,14.30884635281946,NOK
Ar,0.19952623149688797,2047.9999999999998,0.43,9.957e+24,1986.4215000000002,-3.006762695312487,OK,2263.21,573.505,10.50830078125002,NO
```
And you want to read these data using Pandas, you can use this code:
```python
import pandas as pd
import pint
import pint_pandas
import re

def fix_pint_pandas_units(df):
    p = re.compile(r'^(.*)\s*\[(.*)\]\s*$')
    for column in df.columns:
        m = p.match(column)
        if m:
            name = m.group(1).strip()
            unit = m.group(2).strip()
            df.rename(columns={column: name}, inplace=True)
            df[name] = pd.Series(df[name], dtype='pint[' + unit + ']')
            
if __name__ == '__main__':
    df = pd.read_csv('results.csv')
    fix_pint_pandas_units(df)
    print(df.dtypes)
```

May be this could be integrated directly in pint-pandas, eventually activated by a flag.

Discussion:
@gdmcbain: Indeed, I hit this problem too because the CSV were to be stored either in 

- [Confluence databases](<https://www.atlassian.com/software/confluence/databases>) or 
- [Microsoft Lists](<https://www.microsoft.com/en-us/microsoft-365/microsoft-lists>)

both of which insist on single-line headers.   Note that I am far from endorsing either of these products (don't get me started&hellip;), but they're out there and some of us have to work with them.

I had handrolled a solution much like the OP's; mine used a solidus as that's how I think of units, as in `reduced_field / (1e-21 * V * m **2)`, but that's a matter of taste, and I'd be content with the original.

I'd be up for proposing a PR and seeing it through to merging, if there's a taste for an alternative to multiline headers.  Apart from this, pint-pandas is awesome&mdash;thank you very much!@gdmcbain: By the way, when I run the code in the OP, I get

> `TypeError`: data type 'pint[1e-21 * V * m^2]' not understood

If I switch `pd.Series` to `pint_pandas.PintArray`, I get instead 

> `ValueError`: Unit expression cannot have a scaling factor.

which looks like [a pint thing](<https://github.com/hgrecco/pint/blob/dc94eab312a4a33917166a0d999bd50c81922824/pint/facets/plain/registry.py#L1256>).

Did scaling factors used to work?  @andrewgsavage: > I'd be up for proposing a PR and seeing it through to merging, if there's a taste for an alternative to multiline headers. Apart from this, pint-pandas is awesome—thank you very much!

go for it. Although I think it's best to suggest a solution first - I feel like you can do the same in 2-3 lines of pandas by splitting the column headers into a multiindex, at which point I'm not sure it's worth including as a function (but is worth adding to docs)


> Did scaling factors used to work?

idk, I never tested it. looks like that error message was at least 3 years old@gdmcbain: That's right, only a few lines are required. For my use-case, which is to encourage a standard way of storing and imbibing a table of data like material properties as function of temperature (quite similar to the OP), those 2–3 lines would be identical each time and so I'm thinking DRY and do want them in a single function rather than having to copy'n'paste. The question is whether that function lives in pint-pandas or some thin layer that sits on top that I can import; I'm happy to support the former but can live with the latter.
   What I've knocked up so far, which works for the first three tables I've tried is 

https://github.com/gdmcbain/csvu/blob/3fe99eb29a17bfec873bbc1890e6759c12d7b028/src/csvu/__init__.py#L7-L18

```py
def read_csv(filepath: Path) -> pandas.DataFrame:
    raw = pandas.read_csv(filepath)
    units = {}
    for k, c in enumerate(raw):
        if "/" in c:
            column, unit = c.split("/", 1)
        else:
            column = c
            unit = "dimensionless"
        units[column.rstrip()] = f"pint[{unit.strip(' ()')}]"
    raw.columns = list(units.keys())
    return raw.astype(units)
```

Ha, obviously a work in progress; I see `k` isn't used so the enumeration can be elided. Also I expect shortcomings of generality to be exposed as I throw more and more tables at it.@andrewgsavage: my thinking is that this part here:
```python3
    for k, c in enumerate(raw):
        if "/" in c:
            column, unit = c.split("/", 1)
        else:
            column = c
            unit = "dimensionless"
        units[column.rstrip()] = f"pint[{unit.strip(' ()')}]"
```
is going to change for each user, as they may use a different seperator or brackets or whatever. so that should be a function for the user to supply, ie

```python3
def parse_raw_name(raw_name):
   ...
   return column, unit
```
or in your case

```python3
def parse_raw_name(c):
        if "/" in c:
            column, unit = c.split("/", 1)
        else:
            column = c
            unit = "dimensionless"
        column = column.rstrip()
        unit = unit.strip(' ()')
   return column, unit
```
once you have this function, you can do something like
```python3
df = pd.read_csv(filename)
df.columns = pd.MultiIndex.from_tuples([parse_raw_name(c) for c in df.columns])
df = df.pint.quantify()
```

The benefit of doing it this way is it's a little easier to debug - errors will occur in `[parse_raw_name(c) for c in df.columns]` which users can see, rather than in a pint-pandas file somewhere@andrewgsavage: > For my use-case, which is to encourage a standard way of storing and imbibing a table of data like material properties as function of temperature

I've been doing the same thing so am sympathetic to the cause! 

I am thinking an arguement could be added to quantify for `parse_raw_name`. We could also make this a module variable `QUANTIFY_PARSE_RAW_NAME` so quantify defaults to parsing single line column headers using this function.

And presumably the same for writing.@gdmcbain: > is going to change for each user

Yes, which reminds me of Hadley Wickham's ["Tidy datasets are all alike, but every messy dataset is messy in its own way.”](https://r4ds.hadley.nz/data-tidy.html)

> . so that should be a function for the user to supply,

Yes, for sure.   ...Though I'm wondering about the prospects for a nice default, rendering easy things easy, after having made hard things possible.  At the moment, `pandas.read_csv` does a pretty good job of this for CSV -> DataFrame; it's got a lot options, but reasonable defaults and moreover once I know what those defaults are, if I have a say in what the CSV looks like, then I can write the CSV in the first place so that it'll read into pandas without needing explicit options.  Of course I don't presume to dictate what that CSV format should be that is read in with units by default (unless it's just in my module, rather than here).

> I've been doing the same thing so am sympathetic to the cause!

What does your CSV-with-units format look like?  I should provide some examples too, shouldn't I.  I'll scrub some up.

Thanks very much for your feedback and suggestions.  I'll incorporate them immediately into https://github.com/gdmcbain/csvu/issues/3 and report back here with progress.
@andrewgsavage: the format I have is `variable name (unit)`
eg
```
plastic strain (strain), temperature (degC)
```

you'll also need to consider columns with no unit, eg 'tester name', which can be denoted by setting the unit to 'No Unit' @gdmcbain: Ta.

Strain's an interesting case; it's dimensionless but strains can be added, or a stress can be divided by a strain to give say a Young modulus or two strains divided to give a Poisson ratio, but one wouldn't add a strain and a mass fraction.  Does Pint distinguish such incommensurate dimensionless quantities?  That sounds almost type-theoretic, like Haskell, where in the [`units`](<https://github.com/goldfirere/units/blob/455396ebc7dc42d483263acfc03cf1580f646ac5/units-defs/Data/Dimensions/SI.hs#L29-L33>) they do things like:

```hs
-- | A plane angle is dimensionless; alternatively, it has dimension
-- length/length. It would be wrong to divide 2 meters by 1
-- meter and conclude that the quantity is 2 radians or degrees. To make
-- plane angle safe to use, we define it as a fundamental dimension.
declareDimension "PlaneAngle"
``` 

which is appealing, though I wasn't thinking of going that far here.

But I don't see strain in the [Default Pint units definition file](<https://github.com/hgrecco/pint/blob/master/pint/default_en.txt>), and when I throw it in Pint raises

> `pint.errors.UndefinedUnitError`: 'strain' is not defined in the unit registry

Do you have extra code to handle "plastic strain (strain)"?  Or is an unknown unit to be treated as a special way?@gdmcbain: Another data-point on idioms: ["Great Tables for Scientific Publishing"](<https://posit-dev.github.io/great-tables/blog/tables-for-scientific-publishing/>) separates the name and unit with a comma; e.g., 

> Reaction Rate Constant (298 K), cm&sup3; molecules<sup>–1</sup> s<sup>–1</sup> 

or

> Temperature, °C

Not ideal for CSV, but I suppose it might work if quoted. 

I'm also sticking to ASCII myself because various operating systems which don't need to be named still muck up Unicode.@gdmcbain: The [American Physical Society](<https://cdn.journals.aps.org/files/styleguide-pr.pdf>) recommends parentheses, like @andrewgsavage *supra*.  Looks like a good choice for the default then.@andrewgsavage: > That sounds almost type-theoretic, like Haskell, where in the [`units`](https://github.com/goldfirere/units/blob/455396ebc7dc42d483263acfc03cf1580f646ac5/units-defs/Data/Dimensions/SI.hs#L29-L33) ...

Dealing with 'quantitykinds' is something we'd like to add to pint but haven't yet.
One issue is there are more quantitykinds than units so we'd also like to piggyback off https://www.qudt.org/ which has a database of hundreds or thousands of them!

.
strain probably needs adding to pint's definitions if its not there. I may have added it to a custom registry file
.
those tables look very nice, I want to try that now!
.
there's a related issue in pint https://github.com/hgrecco/pint/issues/1704

yea '{name} ({unit})' is fine as a default@gdmcbain: Ah, yes, I see from the _IUPAC Green Book_ cited in hgrecco/pint#1704 the idea of [quantity calculus](<https://en.wikipedia.org/wiki/Quantity_calculus>) is what I was reaching for [above](<https://github.com/hgrecco/pint-pandas/issues/166#issuecomment-2741945829>) in writing `f"{name} / {unit}".  Ta.  So now we've got APS v. IUPAC; so many standards to choose from&hellip;.@andrewgsavage: I think it's reasonable to assume a format of `f"{name}{separator}{unit}{suffix}"`
where in your example, `separator = " / ("` and `suffix = ")"`

we could loop through the common separators and suffixes and use the first one that works. Also store which is the first that works, and use that for writing later.

what are your thoughts on this approach?@gdmcbain: Ah I can imagine so many things that can go wrong. The IUPAC solidus, besides being used for division of a quantity by its unit in the quantity calculus, might also mean disjunction, like an alternative, like a type-theoretic union, as in "oxide/nitride", or even a subdirectory, like "data/residue"; the APS parentheses might be explanatory, like "oxide (dry)". But yes, this is good, I think this will be best, and the user can be trusted to beware such troublesome cases.  I'll put this in tomorrow, after I check on the formatters sent to matplotlib in that other issue, reusing them if possible. Ta.@andrewgsavage: some documentation here
https://pint-pandas.readthedocs.io/en/latest/user/reading.html#single-row-headers


Solution patch:
diff --git a/.github/workflows/ci-check-dependencies.yml b/.github/workflows/ci-check-dependencies.yml
index 23739aa..d283302 100644
--- a/.github/workflows/ci-check-dependencies.yml
+++ b/.github/workflows/ci-check-dependencies.yml
@@ -6,7 +6,7 @@ jobs:
   test:
     strategy:
       matrix:
-        python-version: ["3.10", "3.11", "3.12"]
+        python-version: ["3.11", "3.12"]
 
     runs-on: ubuntu-latest
 
@@ -31,7 +31,7 @@ jobs:
         run: echo "::set-output name=dir::$(pip cache dir)"
 
       - name: Setup caching
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
           key: pip-${{ matrix.python-version }}
diff --git a/.github/workflows/ci-pandas-pre.yml b/.github/workflows/ci-pandas-pre.yml
index 445e82d..36b2cfc 100644
--- a/.github/workflows/ci-pandas-pre.yml
+++ b/.github/workflows/ci-pandas-pre.yml
@@ -6,7 +6,7 @@ jobs:
   test:
     strategy:
       matrix:
-        python-version: ["3.10", "3.11", "3.12"]
+        python-version: ["3.11", "3.12"]
         numpy: ["numpy>=1.20.3,<2.0.0", "numpy>=2.0.0"]
         pandas: ["pandas>=3.0.0"]
         pint: ["pint>=0.24"]
@@ -34,7 +34,7 @@ jobs:
         run: echo "::set-output name=dir::$(pip cache dir)"
 
       - name: Setup caching
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
           key: pip-${{ matrix.python-version }}
diff --git a/.github/workflows/ci-pint-master.yml b/.github/workflows/ci-pint-master.yml
index a96c01b..bbb8d2c 100644
--- a/.github/workflows/ci-pint-master.yml
+++ b/.github/workflows/ci-pint-master.yml
@@ -6,7 +6,7 @@ jobs:
   test:
     strategy:
       matrix:
-        python-version: ["3.10", "3.11", "3.12"]
+        python-version: ["3.11", "3.12"]
         numpy: ["numpy>=1.20.3,<2.0.0"]
         pandas: ["pandas==2.2.2"]
         pint: ["pint>=0.24"]
@@ -37,7 +37,7 @@ jobs:
         run: echo "::set-output name=dir::$(pip cache dir)"
 
       - name: Setup caching
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
           key: pip-${{ matrix.python-version }}
diff --git a/.github/workflows/ci-pint-pre.yml b/.github/workflows/ci-pint-pre.yml
index 2003552..df1115c 100644
--- a/.github/workflows/ci-pint-pre.yml
+++ b/.github/workflows/ci-pint-pre.yml
@@ -6,7 +6,7 @@ jobs:
   test:
     strategy:
       matrix:
-        python-version: ["3.10", "3.11", "3.12"]
+        python-version: ["3.11", "3.12"]
         numpy: ["numpy>=1.20.3,<2.0.0"]
         pandas: ["pandas==2.2.2"]
         pint: ["pint>=0.24"]
@@ -37,7 +37,7 @@ jobs:
         run: echo "::set-output name=dir::$(pip cache dir)"
 
       - name: Setup caching
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
           key: pip-${{ matrix.python-version }}
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 85fb779..40d33e9 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -6,7 +6,7 @@ jobs:
   test:
     strategy:
       matrix:
-        python-version: ["3.10", "3.11", "3.12"]
+        python-version: ["3.11", "3.12"]
         numpy: ["numpy>=1.20.3,<2.0.0", "numpy>=2.0.0"]
         pandas: ["pandas==2.2.2"]
         pint: ["pint>=0.24"]
@@ -34,7 +34,7 @@ jobs:
         run: echo "::set-output name=dir::$(pip cache dir)"
 
       - name: Setup caching
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
           key: pip-${{ matrix.python-version }}
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 8ebea5e..f1f200e 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -24,7 +24,7 @@ jobs:
         run: echo "::set-output name=dir::$(pip cache dir)"
 
       - name: Setup pip cache
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: ${{ steps.pip-cache.outputs.dir }}
           key: pip-docs
diff --git a/.github/workflows/lint-autoupdate.yml b/.github/workflows/lint-autoupdate.yml
index 95cefa5..144540c 100644
--- a/.github/workflows/lint-autoupdate.yml
+++ b/.github/workflows/lint-autoupdate.yml
@@ -15,7 +15,7 @@ jobs:
       - name: checkout
         uses: actions/checkout@v2
       - name: Cache pip and pre-commit
-        uses: actions/cache@v2
+        uses: actions/cache@v4
         with:
           path: |
             ~/.cache/pre-commit
diff --git a/docs/user/reading.rst b/docs/user/reading.rst
index 384b166..db89f75 100644
--- a/docs/user/reading.rst
+++ b/docs/user/reading.rst
@@ -143,3 +143,55 @@ Let's convert a column to a different unit and plot two columns with different u
     ax.yaxis.label
 
 .. TODO add index with units example
+
+
+Single row headers
+-----------------------
+
+A parsing function can be passed into :py:class:`df.pint.quantify` to handle single row headers.
+
+.. ipython:: python
+
+    df = pd.DataFrame(
+        {
+            "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+            "torque [lbf ft]": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="Float64"),
+        }
+    )
+    def parsing_function(column_name):
+        if "[" in column_name:
+            return column_name.split("]")[0].split(" [")
+        return column_name, pint_pandas.pint_array.NO_UNIT
+
+    df.pint.quantify(parsing_function=parsing_function)
+
+Alternatively :py:func:`df.pint.quantify` will attempt to parse single row headers that adhere to the following formats:
+
+* :code:`{column_name} [{unit}]`
+* :code:`{column_name} ({unit})`
+* :code:`{column_name} / {unit}`
+
+.. ipython:: python
+
+    df = pd.DataFrame(
+        {
+            "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+            "torque [lbf ft]": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="Float64"),
+        }
+    )
+
+    df_ = df.pint.quantify()
+    df_
+
+The reverse operation can be done with :py:func:`df.pint.dequantify` and the :py:obj:`writing_function` argument.
+
+.. ipython:: python
+
+    df_.pint.dequantify()
+
+    def writing_function(column_name, unit):
+        if unit == pint_pandas.pint_array.NO_UNIT:
+            return column_name
+        return f"{column_name} [{unit}]"
+
+    df_.pint.dequantify(writing_function=writing_function)
diff --git a/pint_pandas/pint_array.py b/pint_pandas/pint_array.py
index b957074..3468c8c 100644
--- a/pint_pandas/pint_array.py
+++ b/pint_pandas/pint_array.py
@@ -36,6 +36,9 @@ from pint import compat, errors
 # quantify/dequantify
 NO_UNIT = "No Unit"
 DEFAULT_SUBDTYPE = "Float64"
+SINGLE_ROW_HEADER_SEPARATOR: str | None = None
+SINGLE_ROW_HEADER_SUFFIX: str | None = None
+
 
 pandas_version = version("pandas")
 pandas_version_info = tuple(
@@ -1123,13 +1126,63 @@ PintArray._add_comparison_ops()
 register_extension_dtype(PintType)
 
 
+def _parse_column_name(column_name, separator, suffix):
+    if separator in column_name:
+        return column_name.rstrip(suffix).split(separator)
+    return column_name, NO_UNIT
+
+
+def _parsing_function(column_name):
+    global SINGLE_ROW_HEADER_SEPARATOR, SINGLE_ROW_HEADER_SUFFIX
+    # Use defined options if they exist
+    if SINGLE_ROW_HEADER_SEPARATOR is not None and SINGLE_ROW_HEADER_SUFFIX is not None:
+        return _parse_column_name(
+            column_name, SINGLE_ROW_HEADER_SEPARATOR, SINGLE_ROW_HEADER_SUFFIX
+        )
+
+    # Otherwise, check for the first separator in the column name
+    # and set the global variables for future use
+    for separator, suffix in [
+        (" [", "]"),
+        (" (", ")"),
+        (" / ", ""),
+    ]:
+        if separator in column_name:
+            SINGLE_ROW_HEADER_SEPARATOR = separator
+            SINGLE_ROW_HEADER_SUFFIX = suffix
+            return _parse_column_name(column_name, separator, suffix)
+    # If no separator is found, return the column name and no unit
+    return column_name, NO_UNIT
+
+
+def _formatter_func(dtype):
+    # TODO: remove once pint 0.24 is min version supported
+    if version_parse(pint.__version__).base_version < "0.24":
+        formatter = "{:" + dtype.ureg.default_format + "}"
+    else:
+        formatter = "{:" + dtype.ureg.formatter.default_format + "}"
+    return formatter.format(dtype.units)
+
+
+def _writing_function(column_name, units):
+    global SINGLE_ROW_HEADER_SEPARATOR, SINGLE_ROW_HEADER_SUFFIX
+    # Use defined options if they exist
+    return column_name + SINGLE_ROW_HEADER_SEPARATOR + units + SINGLE_ROW_HEADER_SUFFIX
+
+
 @register_dataframe_accessor("pint")
 class PintDataFrameAccessor(object):
     def __init__(self, pandas_obj):
         self._obj = pandas_obj
 
-    def quantify(self, level=-1):
+    def quantify(self, level=-1, parsing_function=_parsing_function):
         df = self._obj
+
+        if not isinstance(df.columns, pd.MultiIndex):
+            df.columns = pd.MultiIndex.from_tuples(
+                [parsing_function(col) for col in df.columns]
+            )
+
         df_columns = df.columns.to_frame()
         unit_col_name = df_columns.columns[level]
         units = df_columns[unit_col_name]
@@ -1147,25 +1200,38 @@ class PintDataFrameAccessor(object):
 
         return df_new
 
-    def dequantify(self):
-        def formatter_func(dtype):
-            # TODO: remove once pint 0.24 is min version supported
-            if version_parse(pint.__version__).base_version < "0.24":
-                formatter = "{:" + dtype.ureg.default_format + "}"
-            else:
-                formatter = "{:" + dtype.ureg.formatter.default_format + "}"
-            return formatter.format(dtype.units)
+    def dequantify(self, writing_function=None):
+        global SINGLE_ROW_HEADER_SEPARATOR, SINGLE_ROW_HEADER_SUFFIX
 
         df = self._obj
 
         df_columns = df.columns.to_frame()
         df_columns["units"] = [
-            formatter_func(df.dtypes.iloc[i])
+            _formatter_func(df.dtypes.iloc[i])
             if isinstance(df.dtypes.iloc[i], PintType)
             else NO_UNIT
             for i, col in enumerate(df.columns)
         ]
 
+        # Use default writing function when a function isn't provided,
+        # and a single line header has been read in previously
+        if writing_function is None:
+            if (
+                SINGLE_ROW_HEADER_SEPARATOR is not None
+                and SINGLE_ROW_HEADER_SUFFIX is not None
+            ):
+                writing_function = _writing_function
+
+        if not isinstance(df.columns, pd.MultiIndex) and writing_function is not None:
+            # If the columns are a MultiIndex, we need to drop the unit column
+            # and keep the rest of the columns
+            df_columns = pd.DataFrame(
+                [
+                    writing_function(*row) if row[1] != NO_UNIT else row[0]
+                    for row in df_columns.values
+                ]
+            )
+
         data_for_df = []
         for i, col in enumerate(df.columns):
             if isinstance(df.dtypes.iloc[i], PintType):
@@ -1188,8 +1254,10 @@ class PintDataFrameAccessor(object):
                 )
 
         df_new = pd.concat(data_for_df, axis=1, copy=False)
-        df_new.columns.names = df.columns.names + ["unit"]
-
+        if len(df_columns.columns) > 1:
+            df_new.columns.names = df.columns.names + ["unit"]
+        else:
+            df_new.columns = df_new.columns.get_level_values(0)
         return df_new
 
     def to_base_units(self):


Test patch:
diff --git a/pint_pandas/testsuite/test_pandas_interface.py b/pint_pandas/testsuite/test_pandas_interface.py
index 10d21fd..858210b 100644
--- a/pint_pandas/testsuite/test_pandas_interface.py
+++ b/pint_pandas/testsuite/test_pandas_interface.py
@@ -15,6 +15,8 @@ from pandas.tests.extension.conftest import (
 from pint.testsuite import QuantityTestCase, helpers
 
 from pint_pandas import PintArray, PintType
+from pint_pandas.pint_array import NO_UNIT
+import pint_pandas
 
 ureg = PintType.ureg
 
@@ -89,6 +91,96 @@ class TestUserInterface(object):
 
         df_.pint.to_base_units().pint.dequantify()
 
+    @pytest.mark.parametrize(
+        "in_unit,out_unit",
+        [
+            pytest.param(" [lbf ft]", " [foot * force_pound]", id=" []"),
+            pytest.param(" (lbf ft)", " (foot * force_pound)", id=" ()"),
+            pytest.param(" / lbf ft", " / foot * force_pound", id=" / "),
+        ],
+    )
+    def test_roundtrip_singlerow(self, in_unit, out_unit):
+        expected = pd.DataFrame(
+            {
+                "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+                "torque": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="pint[lbf ft]"),
+            }
+        )
+        df = pd.DataFrame(
+            {
+                "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+                "torque" + in_unit: pd.Series([1.0, 2.0, 2.0, 3.0], dtype="Float64"),
+            }
+        )
+
+        result = df.pint.quantify()
+        pd.testing.assert_frame_equal(result, expected)
+
+        expected = pd.DataFrame(
+            {
+                "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+                "torque" + out_unit: pd.Series([1.0, 2.0, 2.0, 3.0], dtype="Float64"),
+            }
+        )
+        result = result.pint.dequantify()
+        pd.testing.assert_frame_equal(result, expected)
+        pint_pandas.pint_array.SINGLE_ROW_HEADER_SEPARATOR = None
+        pint_pandas.pint_array.SINGLE_ROW_HEADER_SUFFIX = None
+
+    def test_quantify_singlerow(self):
+        expected = pd.DataFrame(
+            {
+                "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+                "torque": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="pint[lbf ft]"),
+                "angular_velocity": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="pint[rpm]"),
+            }
+        )
+        df = pd.DataFrame(
+            {
+                "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+                "torque [lbf ft]": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="Float64"),
+                "angular_velocity [rpm]": pd.Series(
+                    [1.0, 2.0, 2.0, 3.0], dtype="Float64"
+                ),
+            }
+        )
+
+        def parsing_function(column_name):
+            if "[" in column_name:
+                return column_name.split("]")[0].split(" [")
+            return column_name, NO_UNIT
+
+        result = df.pint.quantify(parsing_function=parsing_function)
+        pd.testing.assert_frame_equal(result, expected)
+
+    def test_dequantify_singlerow(self):
+        df = pd.DataFrame(
+            {
+                "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+                "torque": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="pint[lbf ft]"),
+                "angular_velocity": pd.Series([1.0, 2.0, 2.0, 3.0], dtype="pint[rpm]"),
+            }
+        )
+        expected = pd.DataFrame(
+            {
+                "no_unit_column": pd.Series([i for i in range(4)], dtype="Float64"),
+                "torque [foot * force_pound]": pd.Series(
+                    [1.0, 2.0, 2.0, 3.0], dtype="Float64"
+                ),
+                "angular_velocity [revolutions_per_minute]": pd.Series(
+                    [1.0, 2.0, 2.0, 3.0], dtype="Float64"
+                ),
+            }
+        )
+
+        def writing_function(column_name, unit):
+            if unit == NO_UNIT:
+                return column_name
+            return column_name + " [" + str(unit) + "]"
+
+        result = df.pint.dequantify(writing_function=writing_function)
+        pd.testing.assert_frame_equal(result, expected)
+
     def test_dequantify(self):
         df = pd.DataFrame(
             {