Repository: sunpy/sunpy

Issue:
read_cdf() crashes if time-dependent variable doesn't provide 'UNITS'
### Describe the bug

I try to obtain STEREO in-situ particle data of the LET instrument from CDAWeb via Sunpy's new CDAWeb client (#5558). The actual data files are CDF's, so internally `read_cdf()` from sunpy.io.cdf (#5435) is used (both by @dstansby).

The download of the CDF files is working, but when I try to read the cdf files, `read_cdf()` produces a `KeyError` because it tries to read the attribute `'UNITS'` that is not provided in the CDF file for this specific variable.

### To Reproduce

``` python
>>> from sunpy.net import Fido
>>> from sunpy.net import attrs as a
>>> from sunpy.io.cdf import read_cdf

>>> trange = a.Time('2021/07/01', '2021/07/02')
>>> dataset = a.cdaweb.Dataset('STA_L1_LET')
>>> result = Fido.search(trange, dataset)
>>> downloaded_files = Fido.fetch(result)

>>> data = read_cdf(downloaded_files[0])
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
  File "/home/gieseler/anaconda3/envs/test310/lib/python3.10/site-packages/sunpy/io/cdf.py", line 74, in read_cdf
    unit_str = attrs['UNITS']
KeyError: 'UNITS'
```

### What happened?

The problem is that `read_cdf()` addresses the variable attribute `'UNITS'` without checking if it exists: https://github.com/sunpy/sunpy/blob/e21846a393d35c7fac2f99b781b400afeb08f777/sunpy/io/cdf.py#L74

So a quick workaround would be this:
``` python
# Get units
try:
    unit_str = attrs['UNITS']
    try:
        unit = u.Unit(unit_str)
    except ValueError:
        if unit_str in _known_units:
            unit = _known_units[unit_str]
        else:
            warn_user(f'astropy did not recognize units of "{unit_str}". '
                      'Assigning dimensionless units. '
                      'If you think this unit should not be dimensionless, '
                      'please raise an issue at https://github.com/sunpy/sunpy/issues')
            unit = u.dimensionless_unscaled
except KeyError as keyerr:
    if keyerr.args[0] == 'UNITS':
        warn_user('No units provided. '
                  'Assigning dimensionless units. '
                  'If you think there should be units found in the file, '
                  'please raise an issue at https://github.com/sunpy/sunpy/issues')
        unit = u.dimensionless_unscaled
        pass
    else:
        raise
```
I can submit that as a pull request, but before I wanted to check that this is an acceptable approach. Or whether the general opinion is that the problem lies in the ill-defined CDF file and that it should be fixed there. 

By the way, the (first) variable causing the KeyError in this case actually is dimensionless, while the other physical measurements do provide `'UNITS'`:
``` python
>>> import cdflib
>>> cdf = cdflib.CDF(downloaded_files[0])
>>> cdf.varattsget("CodeOK")
{'CATDESC': "LET's internal code check, 1=good, 0=bad",
 'DEPEND_0': 'Epoch',
 'FIELDNAM': 'LET Code OK',
 'FILLVAL': array([255], dtype=uint8),
 'FORMAT': 'I1',
 'LABLAXIS': 'CodeOK',
 'VALIDMIN': array([0], dtype=uint8),
 'VALIDMAX': array([2], dtype=uint8),
 'VAR_TYPE': 'support_data'}

>>> cdf.varattsget("Al_summed_flux")
{'CATDESC': 'Al unsectored flux in 3 energy bins',
 'DEPEND_0': 'Epoch',
 'DEPEND_1': 'Bins6to27',
 'DISPLAY_TYPE': 'time_series',
 'FIELDNAM': 'Al summed flux',
 'FILLVAL': array([-1.e+31]),
 'FORMAT': 'E12.2',
 'LABL_PTR_1': 'Bins6to27',
 'UNITS': '#/(cm^2*s*sr*Mev/nucleon)',
 'VALIDMIN': array([0., 0., 0.]),
 'VALIDMAX': array([1.e+10, 1.e+10, 1.e+10]),
 'VAR_TYPE': 'data',
 'SCALETYP': 'linear'}

```

### Expected behavior

Return (a list of) GenericTimeSeries

### Screenshots

_No response_

### System Details

``` python
>>> import sunpy; sunpy.util.system_info()
==============================
sunpy Installation Information
==============================

General
#######
OS: Ubuntu (20.04, Linux 5.13.0-30-generic)
Arch: 64bit, (x86_64)
sunpy: 3.1.3
Installation path: /home/gieseler/anaconda3/envs/test310/lib/python3.10/site-packages

Required Dependencies
#####################
parfive: 1.5.1
packaging: 21.3
numpy: 1.22.1
astropy: 5.0

Optional Dependencies
#####################
asdf: Missing, need asdf>=2.6.0; extra == "all"
beautifulsoup4: 4.10.0
cdflib: 0.3.20
dask: 2022.1.0
drms: 0.6.2
glymur: 0.9.7.post1
h5netcdf: 0.13.1
h5py: 3.6.0
matplotlib: 3.5.1
mpl-animators: 1.0.0
pandas: 1.4.0
python-dateutil: 2.8.2
reproject: Missing, need reproject; extra == "all"
scikit-image: 0.18.3
scipy: 1.7.3
sqlalchemy: 1.4.31
tqdm: 4.62.3
zeep: 4.1.0

```

### Installation method

conda


Discussion:
According to the ISTP guide on CDF files, which is the standard we support, UNITS or UNITS_PTR is a required field - https://spdf.gsfc.nasa.gov/istp_guide/variables.html. So if there are neither in the CDF file, I think we should be throwing an error. We should catch the KeyError though, and raise a nicer error message ourselves.
Thinking a bit more about this, maybe the pragmatic thing to do is to raise a warning, and either:
1. Not read a variable in if it doesn't have units
2. Read in the variable and assign dimensionless units

This way it's still possible to read the rest of the CDF file. I think I'd choose 1. to be on the safe side (instead of assuming untis), @jgieseler do you have any thoughts on which would be preferable?
Yeah, I already guessed that the files might not follow the standard (they started 2006).

I think a pragmatic approach is worthwhile in this case, and I actually would favor option 2. Because right now also dimensionless units are assigned if the provided unit string is not understood. We wouldn't provide "wrong" information anyhow.  And we don't know if some CDF file might contain some crucial variable that would get omitted. I think for the users, it's better to get a variable without units so that they really notice it and have the option to do something about it, rather than just drop the variables and provide a small warning that might easily get overlooked.
Good point about what we currently do for not understood unit strings 👍 Should be a pretty simple fix then - would you be up for opening a pull request with it?
Yes, I'll take care of it.

Resolving patch:
diff --git a/sunpy/io/cdf.py b/sunpy/io/cdf.py
--- a/sunpy/io/cdf.py
+++ b/sunpy/io/cdf.py
@@ -71,18 +71,23 @@ def read_cdf(fname):
 
             data = cdf.varget(var_key)
             # Get units
-            unit_str = attrs['UNITS']
-            try:
-                unit = u.Unit(unit_str)
-            except ValueError:
-                if unit_str in _known_units:
-                    unit = _known_units[unit_str]
-                else:
-                    warn_user(f'astropy did not recognize units of "{unit_str}". '
-                              'Assigning dimensionless units. '
-                              'If you think this unit should not be dimensionless, '
-                              'please raise an issue at https://github.com/sunpy/sunpy/issues')
-                    unit = u.dimensionless_unscaled
+            if 'UNITS' in attrs:
+                unit_str = attrs['UNITS']
+                try:
+                    unit = u.Unit(unit_str)
+                except ValueError:
+                    if unit_str in _known_units:
+                        unit = _known_units[unit_str]
+                    else:
+                        warn_user(f'astropy did not recognize units of "{unit_str}". '
+                                  'Assigning dimensionless units. '
+                                  'If you think this unit should not be dimensionless, '
+                                  'please raise an issue at https://github.com/sunpy/sunpy/issues')
+                        unit = u.dimensionless_unscaled
+            else:
+                warn_user(f'No units provided for variable "{var_key}". '
+                          'Assigning dimensionless units.')
+                unit = u.dimensionless_unscaled
 
             if data.ndim == 2:
                 # Multiple columns, give each column a unique label
@@ -109,6 +114,8 @@ def read_cdf(fname):
                 'None': u.dimensionless_unscaled,
                 'none': u.dimensionless_unscaled,
                 ' none': u.dimensionless_unscaled,
+                'counts': u.dimensionless_unscaled,
+                'cnts': u.dimensionless_unscaled,
 
                 'microW m^-2': u.mW * u.m**-2,
 
@@ -165,13 +172,19 @@ def read_cdf(fname):
                 'milliseconds': u.ms,
 
                 '#/cm2-ster-eV-sec': 1 / (u.cm**2 * u.sr * u.eV * u.s),
+                '#/(cm^2*s*sr*MeV/nuc)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
+                '#/(cm^2*s*sr*Mev/nuc)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
+                '#/(cm^2*s*sr*Mev/nucleon)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
+                '#/(cm2-steradian-second-MeV/nucleon) ': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
                 '1/(cm2 Sr sec MeV/nucleon)': 1 / (u.cm**2 * u.sr * u.s * u.MeV),
                 '1/(cm**2-s-sr-MeV)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
                 '1/(cm**2-s-sr-MeV/nuc.)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
                 '1/(cm^2 sec ster MeV)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
+                'cnts/sec/sr/cm^2/MeV': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
 
                 'particles / (s cm^2 sr MeV)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
                 'particles / (s cm^2 sr MeV/n)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
+                'particles/(s cm2 sr MeV/n)': 1 / (u.cm**2 * u.s * u.sr * u.MeV),
 
                 '1/(cm**2-s-sr)': 1 / (u.cm**2 * u.s * u.sr),
                 '1/(SQcm-ster-s)': 1 / (u.cm**2 * u.s * u.sr),
@@ -180,4 +193,5 @@ def read_cdf(fname):
                 'Counts/256sec': 1 / (256 * u.s),
                 'Counts/hour': 1 / u.hr,
                 'counts / s': 1/u.s,
+                'cnts/sec': 1/u.s,
                 }


Test patch: