commodutil 0.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- commodutil-0.0.0/PKG-INFO +17 -0
- commodutil-0.0.0/commodutil/__init__.py +0 -0
- commodutil-0.0.0/commodutil/arb.py +14 -0
- commodutil-0.0.0/commodutil/convfactors.py +474 -0
- commodutil-0.0.0/commodutil/dates.py +57 -0
- commodutil-0.0.0/commodutil/forward/__init__.py +0 -0
- commodutil-0.0.0/commodutil/forward/calendar.py +279 -0
- commodutil-0.0.0/commodutil/forward/continuous.py +106 -0
- commodutil-0.0.0/commodutil/forward/fly.py +82 -0
- commodutil-0.0.0/commodutil/forward/quarterly.py +200 -0
- commodutil-0.0.0/commodutil/forward/spreads.py +104 -0
- commodutil-0.0.0/commodutil/forward/structure.py +32 -0
- commodutil-0.0.0/commodutil/forward/util.py +91 -0
- commodutil-0.0.0/commodutil/forwards.py +355 -0
- commodutil-0.0.0/commodutil/pandasutil.py +118 -0
- commodutil-0.0.0/commodutil/stats.py +50 -0
- commodutil-0.0.0/commodutil/transforms.py +233 -0
- commodutil-0.0.0/commodutil.egg-info/PKG-INFO +17 -0
- commodutil-0.0.0/commodutil.egg-info/SOURCES.txt +29 -0
- commodutil-0.0.0/commodutil.egg-info/dependency_links.txt +1 -0
- commodutil-0.0.0/commodutil.egg-info/requires.txt +7 -0
- commodutil-0.0.0/commodutil.egg-info/top_level.txt +1 -0
- commodutil-0.0.0/pyproject.toml +37 -0
- commodutil-0.0.0/setup.cfg +4 -0
- commodutil-0.0.0/tests/test_arb.py +25 -0
- commodutil-0.0.0/tests/test_conv.py +335 -0
- commodutil-0.0.0/tests/test_dates.py +34 -0
- commodutil-0.0.0/tests/test_forwards.py +140 -0
- commodutil-0.0.0/tests/test_pandasutils.py +58 -0
- commodutil-0.0.0/tests/test_stats.py +57 -0
- commodutil-0.0.0/tests/test_transforms.py +98 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: commodutil
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: common commodity/oil analytics utils
|
|
5
|
+
Author-email: aeorxc <author@example.com>
|
|
6
|
+
Project-URL: Homepage, https://github.com/aeorxc/commodutil
|
|
7
|
+
Project-URL: Source, https://github.com/aeorxc/commodutil
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Operating System :: OS Independent
|
|
11
|
+
Requires-Python: >=3.9
|
|
12
|
+
Requires-Dist: pandas
|
|
13
|
+
Requires-Dist: dask[delayed]
|
|
14
|
+
Requires-Dist: pint
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest; extra == "test"
|
|
17
|
+
Requires-Dist: pytest-runner; extra == "test"
|
|
File without changes
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import pandas as pd
|
|
2
|
+
from commodutil.pandasutil import apply_formula
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def calc(df: pd.DataFrame, dest: str, orig: str, freight: str = None) -> pd.DataFrame:
|
|
6
|
+
"""Calculate an arb by pulling all load and discharge symbols and subtract freight
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
formula = f"{dest} - {orig}"
|
|
10
|
+
if freight is not None:
|
|
11
|
+
formula = f"{formula} - {freight}"
|
|
12
|
+
|
|
13
|
+
df = apply_formula(df, formula)
|
|
14
|
+
return df
|
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Modern implementation of commodity unit conversions using Pint.
|
|
3
|
+
Clean-slate design with no backward-compatibility constraints.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
import pint
|
|
7
|
+
from pint.errors import DimensionalityError
|
|
8
|
+
from typing import Union, Optional
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
import pandas as pd
|
|
11
|
+
from functools import lru_cache
|
|
12
|
+
|
|
13
|
+
# Initialize pint with custom definitions
|
|
14
|
+
ureg = pint.UnitRegistry()
|
|
15
|
+
|
|
16
|
+
# Define oil & gas specific units
|
|
17
|
+
ureg.define('barrel = 158.987294928 liter = bbl')
|
|
18
|
+
ureg.define('gallon = 3.785411784 liter = gal')
|
|
19
|
+
ureg.define('metric_ton = 1000 kilogram = mt')
|
|
20
|
+
ureg.define('kiloton = 1000 metric_ton = kt')
|
|
21
|
+
ureg.define('cubic_kilometer = 1e9 meter**3 = km3') # 1 km^3 = 1 billion m^3
|
|
22
|
+
ureg.define('gigajoule = 1e9 joule = gj = GJ')
|
|
23
|
+
ureg.define('petajoule = 1e15 joule = pj = PJ')
|
|
24
|
+
ureg.define('billion_cubic_meter = 1e9 meter**3 = bcm = BCM')
|
|
25
|
+
ureg.define('billion_cubic_foot = 1e9 foot**3 = bcf = BCF')
|
|
26
|
+
ureg.define('tonne_of_oil_equivalent = 41.868e9 joule = toe = TOE')
|
|
27
|
+
ureg.define('million_tonne_of_oil_equivalent = 1e6 tonne_of_oil_equivalent = Mtoe')
|
|
28
|
+
ureg.define('barrel_of_oil_equivalent = 6.119e9 joule = boe = BOE')
|
|
29
|
+
ureg.define('million_barrel_of_oil_equivalent = 1e6 barrel_of_oil_equivalent = Mboe')
|
|
30
|
+
ureg.define('megatonne = 1e6 metric_ton = Mt')
|
|
31
|
+
|
|
32
|
+
@dataclass
|
|
33
|
+
class Commodity:
|
|
34
|
+
"""Represents a commodity with its physical properties"""
|
|
35
|
+
name: str
|
|
36
|
+
density: pint.Quantity # kg/L or API gravity
|
|
37
|
+
energy_content: Optional[pint.Quantity] = None # GJ/m^3 or similar
|
|
38
|
+
|
|
39
|
+
def __post_init__(self):
|
|
40
|
+
# Ensure quantities have correct dimensions
|
|
41
|
+
if not isinstance(self.density, pint.Quantity):
|
|
42
|
+
self.density = self.density * ureg.kg / ureg.liter
|
|
43
|
+
if self.energy_content and not isinstance(self.energy_content, pint.Quantity):
|
|
44
|
+
self.energy_content = self.energy_content * ureg.GJ / ureg.m**3
|
|
45
|
+
|
|
46
|
+
# Define commodities with their properties and correct industry factors
|
|
47
|
+
COMMODITIES = {
|
|
48
|
+
# Crude oil (BP approximate conversion factors)
|
|
49
|
+
# 1 mt ≈ 7.33 bbl and ≈ 1.165 kL => density ≈ 0.85809 kg/L
|
|
50
|
+
'crude': Commodity('crude', 0.85809151 * ureg.kg/ureg.L, None),
|
|
51
|
+
|
|
52
|
+
# Light ends - tuned to match kbbl/kt figures exactly
|
|
53
|
+
'gasoline': Commodity('gasoline', 0.755079324 * ureg.kg/ureg.L, 33.7898 * ureg.GJ/ureg.m**3), # BP: 44.75 GJ/t
|
|
54
|
+
'naphtha': Commodity('naphtha', 0.706720311 * ureg.kg/ureg.L, None), # 8.90 kbbl/kt
|
|
55
|
+
'ethanol': Commodity('ethanol', 0.755079324 * ureg.kg/ureg.L, 21 * ureg.GJ/ureg.m**3), # 8.33 kbbl/kt
|
|
56
|
+
|
|
57
|
+
# Middle distillates
|
|
58
|
+
'diesel': Commodity('diesel', 0.844269902 * ureg.kg/ureg.L, 36.624428 * ureg.GJ/ureg.m**3), # BP: 43.38 GJ/t
|
|
59
|
+
'jet': Commodity('jet', 0.798199336 * ureg.kg/ureg.L, 35.056915 * ureg.GJ/ureg.m**3), # BP: 43.92 GJ/t
|
|
60
|
+
'fame': Commodity('fame', 0.892001564 * ureg.kg/ureg.L, 33 * ureg.GJ/ureg.m**3), # 7.051345 kbbl/kt
|
|
61
|
+
'hvo': Commodity('hvo', 0.781731391 * ureg.kg/ureg.L, 34 * ureg.GJ/ureg.m**3), # 8.046 kbbl/kt
|
|
62
|
+
|
|
63
|
+
# Heavy products
|
|
64
|
+
'vgo': Commodity('vgo', 0.911566778 * ureg.kg/ureg.L, None), # 6.90 kbbl/kt
|
|
65
|
+
'fuel_oil': Commodity('fuel_oil', 0.990521381 * ureg.kg/ureg.L, 41.175974 * ureg.GJ/ureg.m**3), # BP: 41.57 GJ/t
|
|
66
|
+
|
|
67
|
+
# LPG and Natural gas (liquefied)
|
|
68
|
+
'lpg': Commodity('lpg', 0.541 * ureg.kg/ureg.L, 24.96715 * ureg.GJ/ureg.m**3), # BP: LPG 46.15 GJ/t
|
|
69
|
+
'natgas': Commodity('natgas', 0.542225066 * ureg.kg/ureg.L, 26.137 * ureg.GJ/ureg.m**3),
|
|
70
|
+
|
|
71
|
+
# Natural gas (gaseous, pipeline): BP approx 36 PJ per bcm => 0.036 GJ/m**3
|
|
72
|
+
'natural_gas': Commodity('natural_gas', 0.0 * ureg.kg/ureg.L, 0.036 * ureg.GJ/ureg.m**3), # LNG figures
|
|
73
|
+
|
|
74
|
+
# Light gases
|
|
75
|
+
'ethane': Commodity('ethane', 0.373 * ureg.kg/ureg.L, 18.4262 * ureg.GJ/ureg.m**3), # BP: 49.4 GJ/t
|
|
76
|
+
|
|
77
|
+
# BP product basket (optional reference)
|
|
78
|
+
'product_basket': Commodity('product_basket', 0.781 * ureg.kg/ureg.L, 33.642356 * ureg.GJ/ureg.m**3),
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
# Aliases for compatibility
|
|
82
|
+
ALIASES = {
|
|
83
|
+
# Common synonyms and marketing terms
|
|
84
|
+
'ulsd': 'diesel',
|
|
85
|
+
'gasoil': 'diesel',
|
|
86
|
+
'gas_oil': 'diesel',
|
|
87
|
+
'gas oil': 'diesel',
|
|
88
|
+
'go': 'diesel',
|
|
89
|
+
'kerosene': 'jet',
|
|
90
|
+
|
|
91
|
+
# Motor gasoline
|
|
92
|
+
'gas': 'gasoline',
|
|
93
|
+
'mogas': 'gasoline',
|
|
94
|
+
|
|
95
|
+
# Fuel oil
|
|
96
|
+
'fueloil': 'fuel_oil',
|
|
97
|
+
'fuel oil': 'fuel_oil',
|
|
98
|
+
'fo': 'fuel_oil',
|
|
99
|
+
|
|
100
|
+
# Crude
|
|
101
|
+
'crude oil': 'crude',
|
|
102
|
+
'crudeoil': 'crude',
|
|
103
|
+
|
|
104
|
+
# LPG and nat gas
|
|
105
|
+
'propane': 'lpg',
|
|
106
|
+
'lng': 'natgas', # LNG (liquefied natural gas)
|
|
107
|
+
'ng': 'natural_gas', # pipeline natural gas (gaseous)
|
|
108
|
+
'naturalgas': 'natural_gas',
|
|
109
|
+
'nat_gas': 'natural_gas',
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
class CommodityConverter:
|
|
113
|
+
"""Clean, modern interface for commodity unit conversions"""
|
|
114
|
+
|
|
115
|
+
def __init__(self):
|
|
116
|
+
self.ureg = ureg
|
|
117
|
+
self.commodities = COMMODITIES
|
|
118
|
+
self.aliases = ALIASES
|
|
119
|
+
|
|
120
|
+
@lru_cache(maxsize=128)
|
|
121
|
+
def get_commodity(self, name: str) -> Commodity:
|
|
122
|
+
"""Get commodity object, resolving aliases"""
|
|
123
|
+
name = name.lower()
|
|
124
|
+
name = self.aliases.get(name, name)
|
|
125
|
+
if name not in self.commodities:
|
|
126
|
+
raise ValueError(f"Unknown commodity: {name}")
|
|
127
|
+
return self.commodities[name]
|
|
128
|
+
|
|
129
|
+
def convert(self,
|
|
130
|
+
value: Union[float, pd.Series],
|
|
131
|
+
from_unit: str,
|
|
132
|
+
to_unit: str,
|
|
133
|
+
commodity: Optional[str] = None) -> Union[float, pd.Series]:
|
|
134
|
+
"""
|
|
135
|
+
Convert between units, using commodity properties when needed
|
|
136
|
+
|
|
137
|
+
Examples:
|
|
138
|
+
# Simple unit conversion (no commodity needed)
|
|
139
|
+
convert(100, 'bbl', 'L')
|
|
140
|
+
|
|
141
|
+
# Mass to volume (needs commodity density)
|
|
142
|
+
convert(100, 'kt', 'bbl', commodity='diesel')
|
|
143
|
+
|
|
144
|
+
# Energy conversions
|
|
145
|
+
convert(1000, 'm^3', 'GJ', commodity='diesel')
|
|
146
|
+
|
|
147
|
+
# With pandas Series and daily rates
|
|
148
|
+
convert(series, 'kt/month', 'bbl/day', commodity='gasoline')
|
|
149
|
+
"""
|
|
150
|
+
# Normalize and parse units to handle daily/monthly rates
|
|
151
|
+
from_unit = self._normalize_unit(from_unit)
|
|
152
|
+
to_unit = self._normalize_unit(to_unit)
|
|
153
|
+
from_rate = self._parse_rate_unit(from_unit)
|
|
154
|
+
to_rate = self._parse_rate_unit(to_unit)
|
|
155
|
+
|
|
156
|
+
# Get base units
|
|
157
|
+
from_base = from_rate['base']
|
|
158
|
+
to_base = to_rate['base']
|
|
159
|
+
|
|
160
|
+
# Create quantity
|
|
161
|
+
if isinstance(value, pd.Series):
|
|
162
|
+
result = self._convert_series(value, from_base, to_base, commodity,
|
|
163
|
+
from_rate['period'], to_rate['period'])
|
|
164
|
+
else:
|
|
165
|
+
result = self._convert_scalar(value, from_base, to_base, commodity)
|
|
166
|
+
if from_rate['period'] or to_rate['period']:
|
|
167
|
+
factor = self._rate_factor_scalar(from_rate['period'], to_rate['period'])
|
|
168
|
+
result = result * factor
|
|
169
|
+
|
|
170
|
+
return result
|
|
171
|
+
|
|
172
|
+
def _convert_scalar(self, value: float, from_unit: str, to_unit: str,
|
|
173
|
+
commodity: Optional[str]) -> float:
|
|
174
|
+
"""Convert a scalar value across mass/volume/energy using commodity context when needed."""
|
|
175
|
+
from_unit = self._normalize_unit(from_unit)
|
|
176
|
+
to_unit = self._normalize_unit(to_unit)
|
|
177
|
+
qty = value * self.ureg(from_unit)
|
|
178
|
+
|
|
179
|
+
# Try direct conversion first
|
|
180
|
+
try:
|
|
181
|
+
return qty.to(to_unit).magnitude
|
|
182
|
+
except DimensionalityError:
|
|
183
|
+
pass
|
|
184
|
+
|
|
185
|
+
# Determine unit types
|
|
186
|
+
is_from_energy = self._is_energy(from_unit)
|
|
187
|
+
is_to_energy = self._is_energy(to_unit)
|
|
188
|
+
is_from_mass = self._is_mass(from_unit)
|
|
189
|
+
is_to_mass = self._is_mass(to_unit)
|
|
190
|
+
is_from_volume = self._is_volume(from_unit)
|
|
191
|
+
is_to_volume = self._is_volume(to_unit)
|
|
192
|
+
|
|
193
|
+
# Energy conversions
|
|
194
|
+
if is_from_energy or is_to_energy:
|
|
195
|
+
if not commodity:
|
|
196
|
+
raise ValueError("Commodity required for energy conversion")
|
|
197
|
+
comm = self.get_commodity(commodity)
|
|
198
|
+
if not comm.energy_content:
|
|
199
|
+
raise ValueError(f"No energy content defined for {commodity}")
|
|
200
|
+
|
|
201
|
+
ec = comm.energy_content.to('J/m^3')
|
|
202
|
+
|
|
203
|
+
if is_from_energy:
|
|
204
|
+
energy_J = qty.to('J')
|
|
205
|
+
# Energy -> Volume or Mass
|
|
206
|
+
volume_m3 = (energy_J / ec).to('m^3')
|
|
207
|
+
if is_to_volume:
|
|
208
|
+
return volume_m3.to(to_unit).magnitude
|
|
209
|
+
elif is_to_mass:
|
|
210
|
+
density_kg_m3 = comm.density.to('kg/m^3')
|
|
211
|
+
if density_kg_m3.magnitude == 0:
|
|
212
|
+
raise ValueError(f"Density not defined for {commodity}; cannot convert energy to mass")
|
|
213
|
+
mass_kg = (volume_m3 * density_kg_m3).to('kg')
|
|
214
|
+
return mass_kg.to(to_unit).magnitude
|
|
215
|
+
else:
|
|
216
|
+
raise ValueError(f"Cannot convert energy to {to_unit}")
|
|
217
|
+
else:
|
|
218
|
+
# Volume/Mass -> Energy
|
|
219
|
+
if is_from_mass:
|
|
220
|
+
density_kg_m3 = comm.density.to('kg/m^3')
|
|
221
|
+
if density_kg_m3.magnitude == 0:
|
|
222
|
+
raise ValueError(f"Density not defined for {commodity}; cannot convert mass to energy")
|
|
223
|
+
mass_kg = qty.to('kg')
|
|
224
|
+
volume_m3 = (mass_kg / density_kg_m3).to('m^3')
|
|
225
|
+
elif is_from_volume:
|
|
226
|
+
volume_m3 = qty.to('m^3')
|
|
227
|
+
else:
|
|
228
|
+
raise ValueError(f"Cannot convert {from_unit} to energy")
|
|
229
|
+
energy_J = (volume_m3 * ec).to('J')
|
|
230
|
+
return energy_J.to(to_unit).magnitude
|
|
231
|
+
|
|
232
|
+
# Mass <-> Volume conversions require density
|
|
233
|
+
if (is_from_mass and is_to_volume) or (is_from_volume and is_to_mass):
|
|
234
|
+
if not commodity:
|
|
235
|
+
raise ValueError(f"Commodity required for {from_unit} to {to_unit}")
|
|
236
|
+
comm = self.get_commodity(commodity)
|
|
237
|
+
density_kg_L = comm.density.to('kg/L')
|
|
238
|
+
density_kg_m3 = comm.density.to('kg/m^3')
|
|
239
|
+
if density_kg_L.magnitude == 0 or density_kg_m3.magnitude == 0:
|
|
240
|
+
raise ValueError(f"Density not defined for {commodity}; cannot convert between mass and volume")
|
|
241
|
+
if is_from_mass and is_to_volume:
|
|
242
|
+
mass_kg = qty.to('kg')
|
|
243
|
+
volume_L = (mass_kg / density_kg_L).to('L')
|
|
244
|
+
return volume_L.to(to_unit).magnitude
|
|
245
|
+
else:
|
|
246
|
+
volume_L = qty.to('L')
|
|
247
|
+
mass_kg = (volume_L * density_kg_L).to('kg')
|
|
248
|
+
return mass_kg.to(to_unit).magnitude
|
|
249
|
+
|
|
250
|
+
raise ValueError(f"Cannot convert from {from_unit} to {to_unit} - incompatible dimensions")
|
|
251
|
+
|
|
252
|
+
def _convert_series(self, series: pd.Series, from_unit: str, to_unit: str,
|
|
253
|
+
commodity: Optional[str], from_period: Optional[str],
|
|
254
|
+
to_period: Optional[str]) -> pd.Series:
|
|
255
|
+
"""Convert a pandas Series with optional rate handling.
|
|
256
|
+
|
|
257
|
+
- Month conversions use the index's actual days_in_month when available.
|
|
258
|
+
- Other time conversions use standard averages (365.25 days/year, 30.4375 days/month).
|
|
259
|
+
"""
|
|
260
|
+
result = series.copy()
|
|
261
|
+
|
|
262
|
+
# Handle period conversions for rates
|
|
263
|
+
if from_period or to_period:
|
|
264
|
+
if from_period != to_period:
|
|
265
|
+
if hasattr(series.index, 'days_in_month') and (
|
|
266
|
+
(from_period == 'day' and to_period == 'month') or
|
|
267
|
+
(from_period == 'month' and to_period == 'day')
|
|
268
|
+
):
|
|
269
|
+
# Month-aware conversions using calendar days per month
|
|
270
|
+
if from_period == 'day' and to_period == 'month':
|
|
271
|
+
result = result * series.index.days_in_month
|
|
272
|
+
else:
|
|
273
|
+
result = result / series.index.days_in_month
|
|
274
|
+
else:
|
|
275
|
+
# Fallback to scalar factor for other period conversions
|
|
276
|
+
factor = self._rate_factor_scalar(from_period, to_period)
|
|
277
|
+
result = result * factor
|
|
278
|
+
|
|
279
|
+
# Apply unit conversion
|
|
280
|
+
factor_units = self._convert_scalar(1.0, from_unit, to_unit, commodity)
|
|
281
|
+
result = result * factor_units
|
|
282
|
+
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
def _parse_rate_unit(self, unit: str) -> dict:
|
|
286
|
+
"""Parse units like 'bbl/day' or 'kt/month'."""
|
|
287
|
+
if '/' in unit:
|
|
288
|
+
base, period = unit.split('/', 1)
|
|
289
|
+
base = self._normalize_unit(base)
|
|
290
|
+
period = period.strip().lower().rstrip('s') # day(s), month(s), year(s)
|
|
291
|
+
return {'base': base, 'period': period}
|
|
292
|
+
return {'base': self._normalize_unit(unit), 'period': None}
|
|
293
|
+
|
|
294
|
+
def _rate_factor_scalar(self, from_period: Optional[str], to_period: Optional[str]) -> float:
|
|
295
|
+
"""Scalar factor to convert between rate periods for scalars.
|
|
296
|
+
|
|
297
|
+
Uses average calendar lengths when months/years are involved.
|
|
298
|
+
- Average days per year: 365.25
|
|
299
|
+
- Average days per month: 365.25 / 12 = 30.4375
|
|
300
|
+
"""
|
|
301
|
+
if from_period == to_period:
|
|
302
|
+
return 1.0
|
|
303
|
+
if from_period is None and to_period is None:
|
|
304
|
+
return 1.0
|
|
305
|
+
if from_period is None or to_period is None:
|
|
306
|
+
# Ambiguous to add/remove a time dimension for scalar; no-op to preserve behavior
|
|
307
|
+
return 1.0
|
|
308
|
+
|
|
309
|
+
avg_days_per_year = 365.25
|
|
310
|
+
avg_days_per_month = avg_days_per_year / 12.0
|
|
311
|
+
|
|
312
|
+
# Helper to express rates as per-day factors
|
|
313
|
+
def per_day_factor(period: str) -> float:
|
|
314
|
+
if period == 'day':
|
|
315
|
+
return 1.0
|
|
316
|
+
if period == 'year':
|
|
317
|
+
return 1.0 / avg_days_per_year
|
|
318
|
+
if period == 'month':
|
|
319
|
+
return 1.0 / avg_days_per_month
|
|
320
|
+
# Fallback to Pint if it's a known time unit
|
|
321
|
+
try:
|
|
322
|
+
return (1 * (self.ureg(period) ** -1)).to(self.ureg.day ** -1).magnitude
|
|
323
|
+
except Exception:
|
|
324
|
+
raise ValueError(f"Unsupported rate period: {period}")
|
|
325
|
+
|
|
326
|
+
from_per_day = per_day_factor(from_period)
|
|
327
|
+
to_per_day = per_day_factor(to_period)
|
|
328
|
+
# Convert from per-from_period to per-to_period
|
|
329
|
+
return from_per_day / to_per_day
|
|
330
|
+
|
|
331
|
+
def _is_energy(self, unit: str) -> bool:
|
|
332
|
+
try:
|
|
333
|
+
(1 * self.ureg(unit)).to('J')
|
|
334
|
+
return True
|
|
335
|
+
except DimensionalityError:
|
|
336
|
+
return False
|
|
337
|
+
|
|
338
|
+
def _is_mass(self, unit: str) -> bool:
|
|
339
|
+
try:
|
|
340
|
+
(1 * self.ureg(unit)).to('kg')
|
|
341
|
+
return True
|
|
342
|
+
except DimensionalityError:
|
|
343
|
+
return False
|
|
344
|
+
|
|
345
|
+
def _is_volume(self, unit: str) -> bool:
|
|
346
|
+
try:
|
|
347
|
+
(1 * self.ureg(unit)).to('m^3')
|
|
348
|
+
return True
|
|
349
|
+
except DimensionalityError:
|
|
350
|
+
return False
|
|
351
|
+
|
|
352
|
+
def _normalize_unit(self, unit: str) -> str:
|
|
353
|
+
"""Normalize common aliases and fix encoding issues.
|
|
354
|
+
|
|
355
|
+
- Map 'm��'/'m³'/'m**3'/'cubic_meter' -> 'm^3'
|
|
356
|
+
- Map energy aliases 'BTU' -> 'Btu', 'MMBTU' -> 'MMBtu'
|
|
357
|
+
- Trim whitespace
|
|
358
|
+
"""
|
|
359
|
+
if unit is None:
|
|
360
|
+
return unit
|
|
361
|
+
u = unit.strip()
|
|
362
|
+
# Fix cubic meter notations and encoding issues
|
|
363
|
+
replacements = {
|
|
364
|
+
'm��': 'm^3',
|
|
365
|
+
'm³': 'm^3',
|
|
366
|
+
'm**3': 'm^3',
|
|
367
|
+
'cubic_meter': 'm^3',
|
|
368
|
+
'CUBIC_METER': 'm^3',
|
|
369
|
+
}
|
|
370
|
+
for bad, good in replacements.items():
|
|
371
|
+
u = u.replace(bad, good)
|
|
372
|
+
|
|
373
|
+
# Additional robust normalizations using ASCII-only fallbacks
|
|
374
|
+
if u.lower() == 'm3':
|
|
375
|
+
u = 'm^3'
|
|
376
|
+
# Handle rate-style variants like 'm3/day' or 'M3/day'
|
|
377
|
+
u = u.replace('m3/', 'm^3/').replace('M3/', 'm^3/')
|
|
378
|
+
# Energy unit common uppercase forms
|
|
379
|
+
if u == 'BTU':
|
|
380
|
+
u = 'Btu'
|
|
381
|
+
if u == 'MMBTU':
|
|
382
|
+
u = 'MMBtu'
|
|
383
|
+
return u
|
|
384
|
+
|
|
385
|
+
@property
|
|
386
|
+
def available_commodities(self) -> list:
|
|
387
|
+
"""List all available commodities"""
|
|
388
|
+
return list(self.commodities.keys())
|
|
389
|
+
|
|
390
|
+
@property
|
|
391
|
+
def available_units(self) -> list:
|
|
392
|
+
"""List common units for oil & gas"""
|
|
393
|
+
return [
|
|
394
|
+
# Volume
|
|
395
|
+
'bbl', 'barrel', 'L', 'liter', 'm³', 'cubic_meter', 'gal', 'gallon', 'bcm', 'bcf',
|
|
396
|
+
# Mass
|
|
397
|
+
'kg', 'mt', 'metric_ton', 'kt', 'kiloton', 't', 'tonne', 'Mt',
|
|
398
|
+
# Energy
|
|
399
|
+
'J', 'GJ', 'gigajoule', 'MJ', 'megajoule', 'PJ', 'toe', 'Mtoe', 'boe', 'Mboe', 'BTU', 'MMBTU',
|
|
400
|
+
# Rates
|
|
401
|
+
'bbl/day', 'kt/month', 'm³/day', 'mt/year'
|
|
402
|
+
]
|
|
403
|
+
|
|
404
|
+
# Global converter instance for convenience
|
|
405
|
+
converter = CommodityConverter()
|
|
406
|
+
|
|
407
|
+
# Convenience functions for direct use
|
|
408
|
+
def convert(value, from_unit: str, to_unit: str, commodity: Optional[str] = None):
|
|
409
|
+
"""Convert values between units"""
|
|
410
|
+
return converter.convert(value, from_unit, to_unit, commodity)
|
|
411
|
+
|
|
412
|
+
def convfactor(from_unit: str, to_unit: str, commodity: Optional[str] = None) -> float:
|
|
413
|
+
"""Get conversion factor between units"""
|
|
414
|
+
return converter.convert(1.0, from_unit, to_unit, commodity)
|
|
415
|
+
|
|
416
|
+
def list_commodities():
|
|
417
|
+
"""List all available commodities"""
|
|
418
|
+
return converter.available_commodities
|
|
419
|
+
|
|
420
|
+
def list_units():
|
|
421
|
+
"""List common units"""
|
|
422
|
+
# Return normalized forms to avoid encoding issues
|
|
423
|
+
return [converter._normalize_unit(u) for u in converter.available_units]
|
|
424
|
+
|
|
425
|
+
# Example usage
|
|
426
|
+
if __name__ == "__main__":
|
|
427
|
+
print("Modern Commodity Converter Examples\n" + "="*50)
|
|
428
|
+
|
|
429
|
+
# Simple conversions
|
|
430
|
+
print("\n1. Simple unit conversions (no commodity needed):")
|
|
431
|
+
print(f"100 bbl = {convert(100, 'bbl', 'L'):.0f} L")
|
|
432
|
+
print(f"1000 L = {convert(1000, 'L', 'bbl'):.2f} bbl")
|
|
433
|
+
|
|
434
|
+
# Commodity-specific conversions
|
|
435
|
+
print("\n2. Mass-Volume conversions (needs commodity):")
|
|
436
|
+
print(f"100 kt diesel = {convert(100, 'kt', 'bbl', 'diesel'):.0f} bbl")
|
|
437
|
+
print(f"1000 bbl gasoline = {convert(1000, 'bbl', 'mt', 'gasoline'):.2f} mt")
|
|
438
|
+
|
|
439
|
+
# Energy conversions (now implemented across mass/volume/energy)
|
|
440
|
+
print("\n3. Energy conversions:")
|
|
441
|
+
print("(Energy conversions implemented for mass/volume/energy)")
|
|
442
|
+
|
|
443
|
+
# Series with daily rates
|
|
444
|
+
print("\n4. Pandas Series with rate conversions:")
|
|
445
|
+
dates = pd.date_range('2024-01', periods=3, freq='MS')
|
|
446
|
+
series = pd.Series([100, 110, 105], index=dates)
|
|
447
|
+
result = convert(series, 'kt/month', 'bbl/day', 'diesel')
|
|
448
|
+
print(f"January: {result.iloc[0]:.0f} bbl/day")
|
|
449
|
+
|
|
450
|
+
# Available commodities
|
|
451
|
+
print(f"\n5. Available commodities: {', '.join(list_commodities())}")
|
|
452
|
+
|
|
453
|
+
# Error handling
|
|
454
|
+
print("\n6. Error handling:")
|
|
455
|
+
try:
|
|
456
|
+
convert(100, 'kt', 'bbl') # Missing commodity
|
|
457
|
+
except ValueError as e:
|
|
458
|
+
print(f"Error: {e}")
|
|
459
|
+
|
|
460
|
+
print("\n" + "="*50)
|
|
461
|
+
print("Key improvements over original:")
|
|
462
|
+
print("• Type hints and dataclasses for clarity")
|
|
463
|
+
print("• Automatic dimensional analysis")
|
|
464
|
+
print("• Clean separation of concerns")
|
|
465
|
+
print("• Caching for performance")
|
|
466
|
+
print("• Better error messages")
|
|
467
|
+
print("• Extensible commodity definitions")
|
|
468
|
+
print("• Modern Python patterns")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import re
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime, date, timedelta
|
|
5
|
+
|
|
6
|
+
curmon = datetime.now().month
|
|
7
|
+
curyear = datetime.now().year
|
|
8
|
+
curmonyear = datetime(curyear, curmon, 1)
|
|
9
|
+
curmonyear_str = "%s-%s" % (curyear, curmon) # get pandas time filtering
|
|
10
|
+
|
|
11
|
+
last_day_of_prev_month = date.today().replace(day=1) - timedelta(days=1)
|
|
12
|
+
start_day_of_prev_month = date.today().replace(day=1) - timedelta(
|
|
13
|
+
days=last_day_of_prev_month.day
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
prevmon = start_day_of_prev_month.month
|
|
17
|
+
prevmon_str = "%s-%s" % (
|
|
18
|
+
start_day_of_prev_month.year,
|
|
19
|
+
start_day_of_prev_month.month,
|
|
20
|
+
) # get pandas time filtering
|
|
21
|
+
|
|
22
|
+
nextyear = curyear + 1
|
|
23
|
+
prevyear = curyear - 1
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_year(df, use_delta=False):
|
|
27
|
+
"""
|
|
28
|
+
Given a dataframe find the years in the column headings. Return a dict of colname to year
|
|
29
|
+
eg { 'Q1 2016' : 2016, 'Q1 2017' : 2017
|
|
30
|
+
"""
|
|
31
|
+
res = {}
|
|
32
|
+
for colname in df:
|
|
33
|
+
colregex = re.findall("\d\d\d\d", str(colname))
|
|
34
|
+
colyear = None
|
|
35
|
+
if len(colregex) >= 1:
|
|
36
|
+
colyear = int(colregex[0])
|
|
37
|
+
|
|
38
|
+
if colyear:
|
|
39
|
+
res[colname] = colyear
|
|
40
|
+
if colyear and use_delta:
|
|
41
|
+
delta = colyear - curyear
|
|
42
|
+
res[colname] = delta
|
|
43
|
+
else:
|
|
44
|
+
res[colname] = colname
|
|
45
|
+
|
|
46
|
+
return res
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def time_until_end_of_day(dt=None):
|
|
50
|
+
# type: (datetime.datetime) -> datetime.timedelta
|
|
51
|
+
"""
|
|
52
|
+
Get timedelta until end of day on the datetime passed, or current time.
|
|
53
|
+
"""
|
|
54
|
+
if dt is None:
|
|
55
|
+
dt = datetime.now()
|
|
56
|
+
tomorrow = dt + timedelta(days=1)
|
|
57
|
+
return (datetime.combine(tomorrow, time.min) - dt).seconds
|
|
File without changes
|