Skip to content

Commit 1c09eca

Browse files
Plot units (#296)
* adding an option to specify plot units * updating readme * removing music box output * correcting test * Update src/acom_music_box/utils.py Co-authored-by: Jiwon Gim <55209567+boulderdaze@users.noreply.github.com> --------- Co-authored-by: Jiwon Gim <55209567+boulderdaze@users.noreply.github.com>
1 parent 3e23578 commit 1c09eca

File tree

7 files changed

+189
-30
lines changed

7 files changed

+189
-30
lines changed

README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,18 @@ music_box -e TS1 --plot O3 --plot PAN,HF
8686

8787
Note that the windows may overlap each other
8888

89+
By default all plot units are in `mol m-3`. You can see a list of unit options to specify with `--plot-output-unit`
90+
91+
```
92+
music_box -h
93+
```
94+
95+
It is used like this
96+
97+
```
98+
music_box -e TS1 --output-format csv --plot O3 --plot-output-unit "ppb"
99+
```
100+
89101
### gnuplot
90102
If you want ascii plots (maybe you're running over ssh and can't view a graphical window), you can set
91103
the plot tool to gnuplo (`--plot-tool gnuplot`) to view some output

src/acom_music_box/constants.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
1-
BOLTZMANN_CONSTANT = 1.380649e-23 # joules / Kelvin
2-
AVOGADRO_CONSTANT = 6.02214076e23 # / mole
3-
GAS_CONSTANT = BOLTZMANN_CONSTANT * AVOGADRO_CONSTANT # joules / Kelvin-mole
1+
BOLTZMANN_CONSTANT = 1.380649e-23 # joules/Kelvin (kg m2 s-2 K-1)
2+
AVOGADRO_CONSTANT = 6.02214076e23 # mol-1
3+
GAS_CONSTANT = BOLTZMANN_CONSTANT * AVOGADRO_CONSTANT # joules / Kelvin-mole (kg m2 s-2 K-1 mol-1)

src/acom_music_box/main.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,8 @@
33
import datetime
44
import logging
55
import os
6-
import sys
76
from acom_music_box import MusicBox, Examples, __version__, DataOutput, PlotOutput
7+
from acom_music_box.utils import get_available_units
88

99

1010
def format_examples_help(examples):
@@ -67,6 +67,13 @@ def parse_arguments():
6767
default='matplotlib',
6868
help='Choose plotting tool: gnuplot or matplotlib (default: matplotlib).'
6969
)
70+
parser.add_argument(
71+
'--plot-output-unit',
72+
type=str,
73+
choices=get_available_units(),
74+
default='mol m-3',
75+
help='Specify the output unit for plotting concentrations.'
76+
)
7077
return parser.parse_args()
7178

7279

src/acom_music_box/plot_output.py

Lines changed: 30 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import subprocess
55
import os
66
import tempfile
7+
from acom_music_box.utils import convert_from_number_density # Assuming a utility function for unit conversion
78

89
logger = logging.getLogger(__name__)
910

@@ -54,6 +55,7 @@ def __init__(self, df, args):
5455

5556
self.df = df.copy(deep=True)
5657
self.args = args
58+
self.output_unit = args.plot_output_unit if args.plot_output_unit else 'mol m-3'
5759
if self.args.plot:
5860
self.species_list = [self._format_species_list(group.split(',')) for group in self.args.plot]
5961
else:
@@ -88,6 +90,30 @@ def _format_species_list(self, species_list):
8890

8991
return plot_list
9092

93+
def _convert_units(self, data):
94+
"""
95+
Convert the data to the specified output unit.
96+
97+
Parameters
98+
----------
99+
data : pandas.DataFrame
100+
The DataFrame containing the data to be converted.
101+
102+
Returns
103+
-------
104+
pandas.DataFrame
105+
The DataFrame with data converted to the specified unit.
106+
"""
107+
converted_data = data.copy()
108+
temperature = data['ENV.temperature']
109+
pressure = data['ENV.pressure']
110+
for column in data.columns:
111+
if ('time' in column) or ('ENV' in column):
112+
continue
113+
converted_data[column] = convert_from_number_density(data[column], self.output_unit, temperature=temperature, pressure=pressure) # Assuming standard conditions
114+
converted_data.rename(columns={column: column.replace('mol m-3', self.output_unit)}, inplace=True)
115+
return converted_data
116+
91117
def _plot_with_gnuplot(self):
92118
"""
93119
Plot the specified species using gnuplot.
@@ -114,8 +140,8 @@ def _plot_with_gnuplot(self):
114140
gnuplot_command = f"""
115141
set datafile separator ",";
116142
set terminal dumb size 120,25;
117-
set xlabel 'Time';
118-
set ylabel 'Value';
143+
set xlabel 'Time [s]';
144+
set ylabel 'Concentration [{self.output_unit}]';
119145
set title 'Time vs Species';
120146
plot {plot_commands}
121147
"""
@@ -139,7 +165,7 @@ def _plot_with_matplotlib(self):
139165
fig, ax = plt.subplots()
140166
indexed[species_group].plot(ax=ax)
141167

142-
ax.set(xlabel='Time [s]', ylabel='Concentration [mol m-3]', title='Time vs Species')
168+
ax.set(xlabel='Time [s]', ylabel=f'Concentration [{self.output_unit}]', title='Time vs Species')
143169

144170
ax.spines[:].set_visible(False)
145171
ax.spines['left'].set_visible(True)
@@ -167,6 +193,7 @@ def plot(self):
167193
logger.debug("No species provided for plotting.")
168194
return
169195

196+
self.df = self._convert_units(self.df)
170197
if self.args.plot_tool == 'gnuplot':
171198
self._plot_with_gnuplot()
172199
else:

src/acom_music_box/utils.py

Lines changed: 108 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,23 @@
11
import re
22
from .constants import GAS_CONSTANT, AVOGADRO_CONSTANT
3-
3+
import numpy as np
4+
5+
# The possible units we can convert to and from
6+
# functions that do conversions update this dictionary for their units in
7+
# the appropriate way
8+
unit_conversions = {
9+
'mol m-3': 0,
10+
'mol cm-3': 0,
11+
'molec m-3': 0,
12+
'molecule m-3': 0,
13+
'molec cm-3': 0,
14+
'molecule cm-3': 0,
15+
'ppth': 0,
16+
'ppm': 0,
17+
'ppb': 0,
18+
'ppt': 0,
19+
'mol mol-1': 0
20+
}
421

522
def extract_unit(data, key):
623
"""Extract the value and unit from the key in data."""
@@ -87,6 +104,7 @@ def convert_temperature(data, key):
87104
def convert_concentration(data, key, temperature, pressure):
88105
"""
89106
Convert the concentration from the input data to moles per cubic meter.
107+
This function assumes you are passing data from a music box configuration.
90108
91109
Args:
92110
data (dict): The input data.
@@ -97,36 +115,112 @@ def convert_concentration(data, key, temperature, pressure):
97115
float: The concentration in moles per cubic meter.
98116
"""
99117
concentration_value, unit = extract_unit(data, key)
118+
return convert_to_number_density(concentration_value, unit, temperature, pressure)
119+
120+
121+
def convert_to_number_density(data, input_unit, temperature, pressure):
122+
"""
123+
Convert from some other units to mol m-3
124+
125+
Args:
126+
data (float): The data to convert in the input unit.
127+
input_unit (str): The input units
128+
temperature (float): The temperature in Kelvin.
129+
pressure (float): The pressure in Pascals.
130+
Returns:
131+
float: The data in the output unit.
132+
"""
133+
100134
air_density = calculate_air_density(temperature, pressure)
101135

102-
unit_conversions = {
136+
conversions = {a: b for a, b in unit_conversions.items()}
137+
conversions.update({
103138
'mol m-3': 1, # mol m-3 is the base unit
104139
'mol cm-3': 1e6, # cm3 m-3
105140
'molec m-3': 1 / AVOGADRO_CONSTANT, # mol
106141
'molecule m-3': 1 / AVOGADRO_CONSTANT, # mol
107142
'molec cm-3': 1e6 / AVOGADRO_CONSTANT, # mol cm3 m-3
108143
'molecule cm-3': 1e6 / AVOGADRO_CONSTANT, # mol cm3 m-3
109-
'ppth': 1e-3 * air_density, # moles / m^3
110-
'ppm': 1e-6 * air_density, # moles / m^3
111-
'ppb': 1e-9 * air_density, # moles / m^3
112-
'ppt': 1e-12 * air_density, # moles / m^3
113-
'mol mol-1': 1 * air_density # moles / m^3
114-
}
115-
116-
if unit in unit_conversions:
117-
return concentration_value * unit_conversions[unit]
144+
'ppth': 1e-3 * air_density, # m3 mol-1
145+
'ppm': 1e-6 * air_density, # m3 mol-1
146+
'ppb': 1e-9 * air_density, # m3 mol-1
147+
'ppt': 1e-12 * air_density, # m3 mol-1
148+
'mol mol-1': 1 * air_density # m3 mol-1
149+
})
150+
151+
if input_unit not in conversions:
152+
raise ValueError(f"Unable to convert from {input_unit} to mol m-3")
153+
154+
conversion_factor = conversions.get(input_unit)
155+
156+
if isinstance(data, np.ndarray):
157+
return data * conversion_factor
158+
elif isinstance(data, list):
159+
return [x * conversion_factor for x in data]
118160
else:
119-
raise ValueError(f"Unsupported concentration unit: {unit}")
161+
return data * conversion_factor
162+
163+
164+
def convert_from_number_density(data, output_unit, temperature, pressure):
165+
"""
166+
Convert from mol m-3 to some other units
167+
168+
Args:
169+
data (float): The data to convert in mol m-3.
170+
output_unit (str): The output units
171+
temperature (float): The temperature in Kelvin.
172+
pressure (float): The pressure in Pascals.
173+
Returns:
174+
float: The data in the output unit.
175+
"""
120176

177+
air_density = calculate_air_density(temperature, pressure)
178+
179+
conversions = {a: b for a, b in unit_conversions.items()}
180+
conversions.update({
181+
'mol m-3': 1, # mol m-3 is the base unit
182+
'mol cm-3': 1e-6, # m3 cm-3
183+
'molec m-3': 1 * AVOGADRO_CONSTANT, # mol-1
184+
'molecule m-3': 1 * AVOGADRO_CONSTANT, # mol-1
185+
'molec cm-3': 1e-6 * AVOGADRO_CONSTANT, # m3 cm-3 mol-1
186+
'molecule cm-3': 1e-6 * AVOGADRO_CONSTANT, # m3 cm-3 mol-1
187+
'ppth': 1e3 / air_density, # unitless
188+
'ppm': 1e6 / air_density, # unitless
189+
'ppb': 1e9 / air_density, # unitless
190+
'ppt': 1e12 / air_density, # unitless
191+
'mol mol-1': 1 / air_density # unitless
192+
})
193+
194+
if output_unit not in conversions:
195+
raise ValueError(f"Unable to convert from mol m-3 to {output_unit}")
196+
197+
conversion_factor = conversions.get(output_unit)
198+
199+
if isinstance(data, np.ndarray):
200+
return data * conversion_factor
201+
elif isinstance(data, list):
202+
return [x * conversion_factor for x in data]
203+
else:
204+
return data * conversion_factor
121205

122206
def calculate_air_density(temperature, pressure):
123207
"""
124-
Calculate the air density in moles/m^3.
208+
Calculate the air density in moles m-3
125209
126210
Args:
127211
temperature (float): The temperature in Kelvin.
128212
pressure (float): The pressure in Pascals.
129213
Returns:
130-
float: The air density in moles/m^3.
214+
float: The air density in moles m-3
131215
"""
132216
return pressure / (GAS_CONSTANT * temperature)
217+
218+
219+
def get_available_units():
220+
"""
221+
Get the list of available units for conversion.
222+
223+
Returns:
224+
list: The list of available units.
225+
"""
226+
return list(unit_conversions.keys())

tests/unit/test_plot_output.py

Lines changed: 9 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@
44
import shutil
55
from argparse import Namespace
66
import matplotlib
7-
import subprocess
87

98
matplotlib.use('Agg') # Use a non-interactive backend
109

@@ -17,21 +16,23 @@ def setUp(self):
1716
'time': [0, 1, 2],
1817
'CONC.A': [1, 2, 3],
1918
'CONC.B': [4, 5, 6],
20-
'CONC.C': [7, 8, 9]
19+
'CONC.C': [7, 8, 9],
20+
'ENV.temperature': [298.15, 298.15, 298.15],
21+
'ENV.pressure': [101325, 101325, 101325]
2122
})
2223

2324
def test_format_species_list(self):
24-
args = Namespace(plot=['A', 'B'], plot_tool='matplotlib')
25+
args = Namespace(plot=['A', 'B'], plot_tool='matplotlib', plot_output_unit='mol m-3')
2526
plot_output = PlotOutput(self.df, args)
2627
expected_list = [['CONC.A'], ['CONC.B']]
2728
self.assertEqual(plot_output.species_list, expected_list)
2829

29-
args = Namespace(plot=['CONC.A', 'CONC.B'], plot_tool='matplotlib')
30+
args = Namespace(plot=['CONC.A', 'CONC.B'], plot_tool='matplotlib', plot_output_unit='mol m-3')
3031
plot_output = PlotOutput(self.df, args)
3132
self.assertEqual(plot_output.species_list, expected_list)
3233

3334
def test_plot_with_gnuplot(self):
34-
args = Namespace(plot=['A', 'B'], plot_tool='gnuplot')
35+
args = Namespace(plot=['A', 'B'], plot_tool='gnuplot', plot_output_unit='mol m-3')
3536
plot_output = PlotOutput(self.df, args)
3637
if shutil.which('gnuplot') is None:
3738
with self.assertRaises(FileNotFoundError):
@@ -40,12 +41,12 @@ def test_plot_with_gnuplot(self):
4041
plot_output.plot()
4142

4243
def test_plot_with_matplotlib(self):
43-
args = Namespace(plot=['A', 'B'], plot_tool='matplotlib')
44+
args = Namespace(plot=['A', 'B'], plot_tool='matplotlib', plot_output_unit='mol m-3')
4445
plot_output = PlotOutput(self.df, args)
4546
plot_output.plot()
4647

4748
def test_multiple_groups_with_gnuplot(self):
48-
args = Namespace(plot=['A,B', 'C'], plot_tool='gnuplot')
49+
args = Namespace(plot=['A,B', 'C'], plot_tool='gnuplot', plot_output_unit='mol m-3')
4950
plot_output = PlotOutput(self.df, args)
5051
if shutil.which('gnuplot') is None:
5152
with self.assertRaises(FileNotFoundError):
@@ -54,7 +55,7 @@ def test_multiple_groups_with_gnuplot(self):
5455
plot_output.plot()
5556

5657
def test_multiple_groups_with_matplotlib(self):
57-
args = Namespace(plot=['A,B', 'C'], plot_tool='matplotlib')
58+
args = Namespace(plot=['A,B', 'C'], plot_tool='matplotlib', plot_output_unit='mol m-3')
5859
plot_output = PlotOutput(self.df, args)
5960
plot_output.plot()
6061

tests/unit/test_utils.py

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
convert_pressure,
55
convert_temperature,
66
convert_concentration,
7-
calculate_air_density
7+
calculate_air_density,
8+
convert_from_number_density
89
)
910
import math
1011

@@ -60,3 +61,20 @@ def test_invalid_concentration():
6061
data = {'invalid_concentration': 100}
6162
with pytest.raises(ValueError):
6263
convert_concentration(data, 'invalid_concentration', 298.15, 101325)
64+
65+
@pytest.mark.parametrize("data, output_unit, temperature, pressure, expected",
66+
[
67+
(1, 'mol m-3', 298.15, 101325, 1),
68+
(1, 'mol cm-3', 298.15, 101325, 1e-6),
69+
(1, 'molec m-3', 298.15, 101325, 1 * 6.02214076e+23),
70+
(1, 'molec cm-3', 298.15, 101325, 1e-6 * 6.02214076e+23),
71+
(1, 'molecule m-3', 298.15, 101325, 1 * 6.02214076e+23),
72+
(1, 'molecule cm-3', 298.15, 101325, 1e-6 * 6.02214076e+23),
73+
(1, 'ppth', 298.15, 101325, 1e3 / calculate_air_density(298.15, 101325)),
74+
(1, 'ppm', 298.15, 101325, 1e6 / calculate_air_density(298.15, 101325)),
75+
(1, 'ppb', 298.15, 101325, 1e9 / calculate_air_density(298.15, 101325)),
76+
(1, 'ppt', 298.15, 101325, 1e12 / calculate_air_density(298.15, 101325)),
77+
(1, 'mol mol-1', 298.15, 101325, 1 / calculate_air_density(298.15, 101325)),
78+
])
79+
def test_convert_from_number_density(data, output_unit, temperature, pressure, expected):
80+
assert math.isclose(convert_from_number_density(data, output_unit, temperature, pressure), expected)

0 commit comments

Comments
 (0)