commodutil 4.3.2__tar.gz → 4.4.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.
Files changed (77) hide show
  1. {commodutil-4.3.2 → commodutil-4.4.0}/PKG-INFO +3 -2
  2. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/__init__.py +2 -0
  3. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/convfactors.py +201 -122
  4. commodutil-4.4.0/commodutil/dates.py +104 -0
  5. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/pandasutil.py +5 -10
  6. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/standards/__init__.py +11 -0
  7. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/standards/commodities.py +10 -0
  8. commodutil-4.4.0/commodutil/standards/price_units.py +99 -0
  9. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/standards/units.py +90 -13
  10. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/stats.py +12 -2
  11. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/transforms.py +1 -1
  12. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil.egg-info/PKG-INFO +3 -2
  13. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil.egg-info/SOURCES.txt +7 -1
  14. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil.egg-info/requires.txt +2 -1
  15. commodutil-4.4.0/commodutil.egg-info/scm_file_list.json +70 -0
  16. commodutil-4.4.0/commodutil.egg-info/scm_version.json +8 -0
  17. {commodutil-4.3.2 → commodutil-4.4.0}/pyproject.toml +2 -1
  18. commodutil-4.4.0/tests/golden/golden_factors.json +8451 -0
  19. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_conv.py +17 -10
  20. commodutil-4.4.0/tests/test_dates.py +62 -0
  21. commodutil-4.4.0/tests/test_golden_factors.py +257 -0
  22. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_pandasutils.py +40 -5
  23. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_price_conv.py +57 -2
  24. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_standards_commodities.py +20 -0
  25. commodutil-4.4.0/tests/test_standards_price_units.py +122 -0
  26. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_standards_units.py +47 -0
  27. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_stats.py +22 -13
  28. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_transforms.py +20 -0
  29. commodutil-4.3.2/commodutil/dates.py +0 -57
  30. commodutil-4.3.2/tests/test_dates.py +0 -34
  31. {commodutil-4.3.2 → commodutil-4.4.0}/.coveragerc +0 -0
  32. {commodutil-4.3.2 → commodutil-4.4.0}/.github/workflows/1_tests.yml +0 -0
  33. {commodutil-4.3.2 → commodutil-4.4.0}/.github/workflows/2_coverage.yml +0 -0
  34. {commodutil-4.3.2 → commodutil-4.4.0}/.github/workflows/3_linting.yml +0 -0
  35. {commodutil-4.3.2 → commodutil-4.4.0}/.github/workflows/4_release.yml +0 -0
  36. {commodutil-4.3.2 → commodutil-4.4.0}/.gitignore +0 -0
  37. {commodutil-4.3.2 → commodutil-4.4.0}/.pypirc +0 -0
  38. {commodutil-4.3.2 → commodutil-4.4.0}/azure-build-pipelines.yml +0 -0
  39. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/arb.py +0 -0
  40. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/__init__.py +0 -0
  41. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/calendar.py +0 -0
  42. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/continuous.py +0 -0
  43. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/fly.py +0 -0
  44. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/quarterly.py +0 -0
  45. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/spreads.py +0 -0
  46. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/structure.py +0 -0
  47. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forward/util.py +0 -0
  48. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/forwards.py +0 -0
  49. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/standards/analysis_types.py +0 -0
  50. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/standards/commodity_groups.py +0 -0
  51. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/standards/currency.py +0 -0
  52. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil/standards/regions.py +0 -0
  53. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil.egg-info/dependency_links.txt +0 -0
  54. {commodutil-4.3.2 → commodutil-4.4.0}/commodutil.egg-info/top_level.txt +0 -0
  55. {commodutil-4.3.2 → commodutil-4.4.0}/requirements-test.txt +0 -0
  56. {commodutil-4.3.2 → commodutil-4.4.0}/requirements.txt +0 -0
  57. {commodutil-4.3.2 → commodutil-4.4.0}/requirements_dev.txt +0 -0
  58. {commodutil-4.3.2 → commodutil-4.4.0}/setup.cfg +0 -0
  59. {commodutil-4.3.2 → commodutil-4.4.0}/tests/__init__.py +0 -0
  60. {commodutil-4.3.2 → commodutil-4.4.0}/tests/conftest.py +0 -0
  61. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/__init__.py +0 -0
  62. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/conftest.py +0 -0
  63. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/test_calendar.py +0 -0
  64. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/test_continuous.py +0 -0
  65. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/test_fly.py +0 -0
  66. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/test_quarterly.py +0 -0
  67. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/test_spreads.py +0 -0
  68. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/test_structure.py +0 -0
  69. {commodutil-4.3.2 → commodutil-4.4.0}/tests/forward/test_util.py +0 -0
  70. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_arb.py +0 -0
  71. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_cl.csv +0 -0
  72. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_forwards.py +0 -0
  73. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_standards_analysis_types.py +0 -0
  74. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_standards_commodity_groups.py +0 -0
  75. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_standards_currency.py +0 -0
  76. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_standards_regions.py +0 -0
  77. {commodutil-4.3.2 → commodutil-4.4.0}/tests/test_weekly.csv +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: commodutil
3
- Version: 4.3.2
3
+ Version: 4.4.0
4
4
  Summary: common commodity/oil analytics utils
5
5
  Author-email: aeorxc <author@example.com>
6
6
  Project-URL: Homepage, https://dev.azure.com/RWEST-MFI-TE/Oil/_git/commodutil
@@ -9,9 +9,10 @@ Classifier: Programming Language :: Python :: 3
9
9
  Classifier: License :: OSI Approved :: MIT License
10
10
  Classifier: Operating System :: OS Independent
11
11
  Requires-Python: >=3.9
12
- Requires-Dist: pandas
12
+ Requires-Dist: pandas>=2.1
13
13
  Requires-Dist: dask[delayed]
14
14
  Requires-Dist: pint
15
+ Requires-Dist: python-dateutil
15
16
  Provides-Extra: test
16
17
  Requires-Dist: pytest; extra == "test"
17
18
  Requires-Dist: pytest-runner; extra == "test"
@@ -38,7 +38,9 @@ _LAZY_EXPORTS = {
38
38
  "normalize_currency_token": "commodutil.standards.currency",
39
39
  "split_currency_unit": "commodutil.standards.currency",
40
40
  # standards.units (stdlib-only -- cheap, no pint)
41
+ "canonical_price_unit_token": "commodutil.standards.units",
41
42
  "canonical_quantity_unit": "commodutil.standards.units",
43
+ "canonical_unit_token": "commodutil.standards.units",
42
44
  "quantity_unit_from_price_unit": "commodutil.standards.units",
43
45
  # dates
44
46
  "curmon": "commodutil.dates",
@@ -16,7 +16,17 @@ from commodutil.standards.units import to_pint_token as _to_pint_token
16
16
 
17
17
  logger = logging.getLogger(__name__)
18
18
 
19
- # Initialize pint with custom definitions
19
+ # Initialize pint with custom definitions.
20
+ #
21
+ # NOTE: kept case-sensitive on purpose. `pint.UnitRegistry(case_sensitive=False)`
22
+ # looks tempting (it would drop the hand-registered casing aliases below), but it
23
+ # is UNSAFE for this registry: pint's case-folded lookup makes our domain units
24
+ # collide with SI units that differ only by case -- `kt`/`mt`/`gal` vs `kT`/`mT`
25
+ # (kilo/milli-tesla, T = tesla) and `Gal` (galileo, cm/s^2), while lowercase
26
+ # `mw`/`mwh` resolve to milli-watt instead of mega-. Collision winners are chosen
27
+ # from a set and are NON-DETERMINISTIC (PYTHONHASHSEED-dependent), so `kt`->`km3`
28
+ # etc. intermittently raise "incompatible dimensions". We therefore register the
29
+ # exact spellings we need explicitly and leave the registry case-sensitive.
20
30
  ureg = pint.UnitRegistry()
21
31
 
22
32
  # Define oil & gas specific units.
@@ -74,62 +84,113 @@ class Commodity:
74
84
  mass<->volume conversion is undefined and must raise).
75
85
  Previously this used `0.0 kg/L` as a sentinel; magnitude==0 checks
76
86
  were scattered through the codebase. `None` makes the intent explicit.
87
+
88
+ `energy_content` may be a pint Quantity of EITHER dimensionality:
89
+ - `[energy]/[volume]` (e.g. GJ/m^3) — the default for liquids; registers a
90
+ `[volume] <-> [energy]` transformation (and chains to mass via density).
91
+ - `[energy]/[mass]` (e.g. GJ/t) — for density-less solids like coal;
92
+ registers a DIRECT `[mass] <-> [energy]` transformation so `mt <-> MMBtu`
93
+ works with `density=None`, while `mt <-> bbl` still correctly raises.
94
+ A bare float is coerced to the volume-basis default (GJ/m^3) for back-compat.
77
95
  """
78
96
 
79
97
  name: str
80
98
  density: Optional[pint.Quantity] = None # kg/L or API gravity; None = not a liquid
81
- energy_content: Optional[pint.Quantity] = None # GJ/m^3 or similar
99
+ # [energy]/[volume] (GJ/m^3, liquids) OR [energy]/[mass] (GJ/t, e.g. coal)
100
+ energy_content: Optional[pint.Quantity] = None
82
101
 
83
102
  def __post_init__(self):
84
103
  # Ensure quantities have correct dimensions
85
104
  if self.density is not None and not isinstance(self.density, pint.Quantity):
86
105
  self.density = self.density * ureg.kg / ureg.liter
87
- if self.energy_content and not isinstance(self.energy_content, pint.Quantity):
106
+ # Bare float -> volume-basis default (GJ/m^3). Callers wanting a mass
107
+ # basis pass an explicit Quantity (e.g. `26.377 * ureg.GJ / ureg.mt`),
108
+ # which is left untouched here and dispatched on in _commodity_context.
109
+ if self.energy_content is not None and not isinstance(
110
+ self.energy_content, pint.Quantity
111
+ ):
88
112
  self.energy_content = self.energy_content * ureg.GJ / ureg.m**3
89
113
 
90
114
 
115
+ # -----------------------------------------------------------------------------
116
+ # Calorific basis policy: GROSS (higher heating value, HHV).
117
+ #
118
+ # `energy_content` for every liquid commodity below is stated on a GROSS/HHV
119
+ # basis, anchored on the U.S. EIA Monthly Energy Review Appendix A, Table A1
120
+ # ("Approximate Heat Content of Petroleum and Biofuels", explicitly gross —
121
+ # "Gross heat content rates are applied in all Btu calculations for the Monthly
122
+ # Energy Review") and, for the NGL species, GPA/EIA figures (EIA A1 which cites
123
+ # NIST enthalpy-of-combustion + API liquid densities). NGL/natural-gas hubs
124
+ # (Henry Hub, TTF, NBP, JKM) all quote HHV, so a cross-fuel $/MMBtu comparison
125
+ # is only internally consistent if every commodity is on the same gross basis.
126
+ #
127
+ # The stored value is GJ/m^3 (energy per unit VOLUME), set directly from the
128
+ # EIA gross figure in MMBtu/bbl via the fixed, density-independent conversion
129
+ # GJ/m^3 = MMBtu/bbl * 1.055056 GJ/MMBtu / 0.158987294928 m^3/bbl
130
+ # so that convfactor('bbl','MMBtu', <commodity>) reproduces the EIA number
131
+ # exactly. Densities are NOT part of this conversion; they remain the
132
+ # BP/Energy-Institute values unchanged (the resulting GJ/t is therefore a
133
+ # derived reporting figure = energy_content / density). Each entry's comment
134
+ # records the source EIA MMBtu/bbl figure and the implied gross GJ/t.
135
+ #
136
+ # Historical note: several products (diesel/naphtha/jet/gasoline/fuel_oil) and
137
+ # the biofuels previously carried BP-style NET (NCV) energy contents, which
138
+ # understated $/MMBtu by ~1-12% vs gross. Standardising on gross was the
139
+ # owner-approved decision (conversion-architecture-plan.md, decision 2b).
140
+ #
141
+ # NOT covered by this policy (see per-entry comments / flagged for desk review):
142
+ # crude (kept on its documented BP gross basis), the LPG/LNG blends (`lpg`,
143
+ # `natgas`), gaseous `natural_gas`, and `product_basket` — all blend- or
144
+ # construction-dependent rather than single-species EIA rows.
145
+ # -----------------------------------------------------------------------------
91
146
  # Define commodities with their properties and correct industry factors
92
147
  COMMODITIES = {
93
148
  # Crude oil (BP approximate conversion factors)
94
149
  # 1 mt ≈ 7.33 bbl and ≈ 1.165 kL => density ≈ 0.85809 kg/L
95
- "crude": Commodity("crude", 0.85809151 * ureg.kg / ureg.L, None),
150
+ "crude": Commodity(
151
+ "crude", 0.85809151 * ureg.kg / ureg.L, 39.043 * ureg.GJ / ureg.m**3
152
+ ), # HHV; BP/EI ~45.5 GJ/t gross (implies 5.883 MMBtu/bbl). EIA Table A2 crude ~5.69-5.80 MMBtu/bbl (US, lighter) -> commodutil is ~1-3% higher; kept on documented BP world-crude gross basis, not changed
96
153
  # Light ends - tuned to match kbbl/kt figures exactly
97
154
  "gasoline": Commodity(
98
- "gasoline", 0.755079324 * ureg.kg / ureg.L, 33.7898 * ureg.GJ / ureg.m**3
99
- ), # BP: 44.75 GJ/t
155
+ "gasoline", 0.755079324 * ureg.kg / ureg.L, 34.653728 * ureg.GJ / ureg.m**3
156
+ ), # HHV; EIA motor gasoline blending components (2007+, GREET) 5.222 MMBtu/bbl -> 45.89 GJ/t gross (was BP NCV 44.75; RBOB/blendstock basis, not E10-finished ~5.05)
100
157
  "naphtha": Commodity(
101
- "naphtha", 0.706720311 * ureg.kg / ureg.L, None
102
- ), # 8.90 kbbl/kt
158
+ "naphtha", 0.706720311 * ureg.kg / ureg.L, 34.826266 * ureg.GJ / ureg.m**3
159
+ ), # HHV; EIA petrochemical naphtha <401F 5.248 MMBtu/bbl -> 49.28 GJ/t gross (was NCV 44.90)
103
160
  "ethanol": Commodity(
104
- "ethanol", 0.755079324 * ureg.kg / ureg.L, 21 * ureg.GJ / ureg.m**3
105
- ), # 8.33 kbbl/kt
161
+ "ethanol", 0.755079324 * ureg.kg / ureg.L, 23.485167 * ureg.GJ / ureg.m**3
162
+ ), # HHV; EIA fuel ethanol (undenatured) 3.539 MMBtu/bbl -> 31.10 GJ/t gross (was net ~27.81); NOTE density 0.7551 looks copied from gasoline (ethanol ~0.789) - flagged, left unchanged
106
163
  # Middle distillates
107
164
  "diesel": Commodity(
108
- "diesel", 0.844269902 * ureg.kg / ureg.L, 36.624428 * ureg.GJ / ureg.m**3
109
- ), # BP: 43.38 GJ/t
165
+ "diesel", 0.844269902 * ureg.kg / ureg.L, 38.290312 * ureg.GJ / ureg.m**3
166
+ ), # HHV; EIA distillate (15 ppm & under) 5.770 MMBtu/bbl -> 45.35 GJ/t gross (was BP NCV 43.38)
110
167
  "jet": Commodity(
111
- "jet", 0.798199336 * ureg.kg / ureg.L, 35.056915 * ureg.GJ / ureg.m**3
112
- ), # BP: 43.92 GJ/t
168
+ "jet", 0.798199336 * ureg.kg / ureg.L, 37.626702 * ureg.GJ / ureg.m**3
169
+ ), # HHV; EIA jet fuel kerosene-type 5.670 MMBtu/bbl -> 47.14 GJ/t gross (was BP NCV 43.92)
113
170
  "fame": Commodity(
114
- "fame", 0.892001564 * ureg.kg / ureg.L, 33 * ureg.GJ / ureg.m**3
115
- ), # 7.051345 kbbl/kt
171
+ "fame", 0.892001564 * ureg.kg / ureg.L, 35.562874 * ureg.GJ / ureg.m**3
172
+ ), # HHV; EIA biodiesel 5.359 MMBtu/bbl -> 39.87 GJ/t gross (was net ~37.00)
116
173
  "hvo": Commodity(
117
- "hvo", 0.781731391 * ureg.kg / ureg.L, 34 * ureg.GJ / ureg.m**3
118
- ), # 8.046 kbbl/kt
174
+ "hvo", 0.781731391 * ureg.kg / ureg.L, 36.458748 * ureg.GJ / ureg.m**3
175
+ ), # HHV; EIA renewable diesel fuel 5.494 MMBtu/bbl -> 46.64 GJ/t gross (was net ~43.49)
119
176
  # Heavy products
120
177
  "vgo": Commodity("vgo", 0.911566778 * ureg.kg / ureg.L, None), # 6.90 kbbl/kt
121
178
  "fuel_oil": Commodity(
122
- "fuel_oil", 0.990521381 * ureg.kg / ureg.L, 41.175974 * ureg.GJ / ureg.m**3
123
- ), # BP: 41.57 GJ/t
179
+ "fuel_oil", 0.990521381 * ureg.kg / ureg.L, 41.721177 * ureg.GJ / ureg.m**3
180
+ ), # HHV; EIA residual fuel oil 6.287 MMBtu/bbl -> 42.12 GJ/t gross (was BP NCV 41.57)
124
181
  # LPG and Natural gas (liquefied)
125
182
  "lpg": Commodity(
126
183
  "lpg", 0.541 * ureg.kg / ureg.L, 24.96715 * ureg.GJ / ureg.m**3
127
- ), # BP: LPG 46.15 GJ/t
184
+ ), # BP: LPG 46.15 GJ/t (implies 3.762 MMBtu/bbl). FLAGGED: generic propane/butane blend; likely NET-basis, but the gross value is composition-dependent (undocumented mix) -> not changed. Desk: specify a C3/C4 split, or price via the discrete NGL species instead.
128
185
  "natgas": Commodity(
129
186
  "natgas", 0.542225066 * ureg.kg / ureg.L, 26.137 * ureg.GJ / ureg.m**3
130
- ),
187
+ ), # liquefied NG / LNG (alias 'lng'); implies 48.20 GJ/t, 3.939 MMBtu/bbl. FLAGGED: density 0.542 is high for LNG (~0.43-0.47) and basis is unclear -> not changed pending desk clarification of what this represents vs 'natural_gas'.
131
188
  # Natural gas (gaseous, pipeline): BP approx 36 PJ per bcm => 0.036 GJ/m**3
132
189
  # density=None: not a liquid, so mass<->volume conversion is undefined.
190
+ # FLAGGED (HHV policy): 0.036 GJ/m^3 (=36 MJ/m^3) is a rounded BP approx sitting
191
+ # between net (~34.6) and US-pipeline gross (~38.3 MJ/m^3, ~1028 Btu/scf). Not
192
+ # clearly net, and it has broad blast radius (bcm<->energy in many callers +
193
+ # explicit tests), so left unchanged; desk to confirm gross ~0.0383 if wanted.
133
194
  "natural_gas": Commodity(
134
195
  "natural_gas", density=None, energy_content=0.036 * ureg.GJ / ureg.m**3
135
196
  ),
@@ -138,23 +199,36 @@ COMMODITIES = {
138
199
  # $/gal <-> $/MMBtu for the MB OPIS futures (AC0, B0, AD0, A8I)).
139
200
  "ethane": Commodity(
140
201
  "ethane", 0.373 * ureg.kg / ureg.L, 18.4262 * ureg.GJ / ureg.m**3
141
- ), # BP: 49.4 GJ/t; 1 gal ~ 0.0661 MMBtu (HHV)
202
+ ), # HHV; implies 2.777 MMBtu/bbl vs EIA ethane 2.783 (+0.2%, within tol) -> left as-is
142
203
  "propane": Commodity(
143
204
  "propane", 0.507 * ureg.kg / ureg.L, 25.5375 * ureg.GJ / ureg.m**3
144
- ), # ~91,690 BTU/gal HHV; ~50.35 GJ/t
205
+ ), # HHV; implies 3.848 MMBtu/bbl vs EIA propane 3.841 (-0.2%, within tol) -> left as-is
145
206
  "butane": Commodity(
146
- "butane", 0.574 * ureg.kg / ureg.L, 28.4400 * ureg.GJ / ureg.m**3
147
- ), # n-butane; ~103,000 BTU/gal HHV liquid; ~49.5 GJ/t
207
+ "butane", 0.574 * ureg.kg / ureg.L, 28.887 * ureg.GJ / ureg.m**3
208
+ ), # HHV; EIA MER App A n-butane 4.353 MMBtu/bbl gross (was 28.44 -> 4.286 MMBtu/bbl, -1.57% off EIA; aligned 2026-07 per EIA-gross basis decision)
148
209
  "isobutane": Commodity(
149
210
  "isobutane", 0.557 * ureg.kg / ureg.L, 27.7700 * ureg.GJ / ureg.m**3
150
- ), # iso-C4; ~99,000 BTU/gal HHV liquid
211
+ ), # HHV; implies 4.185 MMBtu/bbl vs EIA isobutane 4.183 (-0.0%, within tol) -> left as-is
151
212
  "natural_gasoline": Commodity(
152
- "natural_gasoline", 0.6675 * ureg.kg / ureg.L, 31.4 * ureg.GJ / ureg.m**3
153
- ), # pentanes-plus; ~118,000 BTU/gal HHV
213
+ "natural_gasoline", 0.6675 * ureg.kg / ureg.L, 30.778 * ureg.GJ / ureg.m**3
214
+ ), # HHV; EIA MER App A pentanes-plus 4.638 MMBtu/bbl gross (was 31.4 -> 4.732 MMBtu/bbl, +1.98% off EIA; aligned 2026-07 per EIA-gross basis decision)
154
215
  # BP product basket (optional reference)
155
216
  "product_basket": Commodity(
156
217
  "product_basket", 0.781 * ureg.kg / ureg.L, 33.642356 * ureg.GJ / ureg.m**3
157
- ),
218
+ ), # implies 43.08 GJ/t, 5.070 MMBtu/bbl. FLAGGED: weighted product basket, construction (component weights) undocumented -> not changed; should be recomputed from the now-gross component products with documented weights rather than guessed.
219
+ # Solid fuels: MASS-basis energy_content (GJ/t) with density=None. mt<->energy
220
+ # (the only meaningful jump, $/t <-> $/MMBtu) works; mt<->bbl stays illegal.
221
+ "coal": Commodity(
222
+ "coal",
223
+ density=None,
224
+ energy_content=26.377 * ureg.GJ / ureg.mt,
225
+ ), # API2 CIF ARA thermal coal. GROSS/HHV basis: traded spec is 6,000 kcal/kg
226
+ # NAR (net); the gross equivalent is ~6,300 kcal/kg GAR (Argus/CoalIndo NAR/GAR
227
+ # markers show a ~300 kcal/kg NAR->GAR gap for high-rank export coal, e.g.
228
+ # 6,200 NAR = 6,500 GAR). 6,300 kcal/kg x 4.1868 kJ/kcal (IT calorie) = 26.377
229
+ # GJ/t = 25.00 MMBtu/t. Replaces the legacy /27.76 tce divisor in
230
+ # oilpricingcharts energy_mmbtu (generic tonne-of-coal-equivalent, 7,000 kcal/kg
231
+ # GCV = 29.29 GJ/t) which understated coal $/MMBtu by ~11%.
158
232
  }
159
233
 
160
234
  # Aliases for compatibility
@@ -270,97 +344,115 @@ class CommodityConverter:
270
344
 
271
345
  return result
272
346
 
347
+ @lru_cache(maxsize=128)
348
+ def _commodity_context(self, name: str) -> pint.Context:
349
+ """Build (and cache) a pint Context expressing a commodity's dimensional
350
+ transformations.
351
+
352
+ A commodity's physical properties define which cross-dimension jumps are
353
+ legal:
354
+ - ``density`` (kg/m^3) enables ``[mass] <-> [volume]``.
355
+ - ``energy_content`` on a VOLUME basis (J/m^3) enables
356
+ ``[volume] <-> [energy]`` (and chains to ``[mass] <-> [energy]`` via
357
+ density when present).
358
+ - ``energy_content`` on a MASS basis (J/kg) enables ``[mass] <-> [energy]``
359
+ DIRECTLY — for density-less solids (coal) whose only meaningful jump
360
+ is $/t <-> $/MMBtu. No volume transform is registered, so a
361
+ mass<->volume jump still finds no path and raises.
362
+ pint composes the registered transformations into a graph and finds the
363
+ conversion path itself — no manual branching on the path is needed.
364
+
365
+ Properties that are ``None`` register no transformation, so pint simply
366
+ finds no path and raises ``DimensionalityError`` (translated to a domain
367
+ ``ValueError`` by the caller). This is how density=None commodities
368
+ (natural_gas, naphtha, vgo) reject any mass<->volume(<->energy) jump.
369
+ """
370
+ comm = self.get_commodity(name)
371
+ ctx = pint.Context(comm.name)
372
+ if comm.density is not None:
373
+ d = comm.density.to("kg/m^3")
374
+ ctx.add_transformation("[mass]", "[volume]", lambda ureg, x, d=d: x / d)
375
+ ctx.add_transformation("[volume]", "[mass]", lambda ureg, x, d=d: x * d)
376
+ if comm.energy_content is not None:
377
+ ec = comm.energy_content
378
+ if ec.check("[energy]/[volume]"):
379
+ e = ec.to("J/m^3")
380
+ ctx.add_transformation(
381
+ "[volume]", "[energy]", lambda ureg, x, e=e: x * e
382
+ )
383
+ ctx.add_transformation(
384
+ "[energy]", "[volume]", lambda ureg, x, e=e: x / e
385
+ )
386
+ elif ec.check("[energy]/[mass]"):
387
+ em = ec.to("J/kg")
388
+ ctx.add_transformation(
389
+ "[mass]", "[energy]", lambda ureg, x, em=em: x * em
390
+ )
391
+ ctx.add_transformation(
392
+ "[energy]", "[mass]", lambda ureg, x, em=em: x / em
393
+ )
394
+ else:
395
+ raise ValueError(
396
+ f"{comm.name}: energy_content must be [energy]/[volume] or "
397
+ f"[energy]/[mass], got dimensionality {dict(ec.dimensionality)}"
398
+ )
399
+ return ctx
400
+
273
401
  def _convert_scalar(
274
402
  self, value: float, from_unit: str, to_unit: str, commodity: Optional[str]
275
403
  ) -> float:
276
- """Convert a scalar value across mass/volume/energy using commodity context when needed."""
404
+ """Convert a scalar value across mass/volume/energy using a commodity's
405
+ pint Context when a cross-dimension jump is needed."""
277
406
  from_unit = _to_pint_token(from_unit)
278
407
  to_unit = _to_pint_token(to_unit)
279
408
  qty = value * self.ureg(from_unit)
280
409
 
281
- # Try direct conversion first
410
+ # Same-dimension conversion (e.g. kt->mt, bbl->L): no commodity needed.
282
411
  try:
283
412
  return qty.to(to_unit).magnitude
284
413
  except DimensionalityError:
285
414
  pass
286
415
 
287
- # Determine unit types
416
+ # Cross-dimensional: needs commodity physical properties. Decide the
417
+ # right error up front when no commodity was supplied (mirrors the
418
+ # historical messages the callers/tests grep for).
288
419
  is_from_energy = self._is_energy(from_unit)
289
420
  is_to_energy = self._is_energy(to_unit)
290
- is_from_mass = self._is_mass(from_unit)
291
- is_to_mass = self._is_mass(to_unit)
292
- is_from_volume = self._is_volume(from_unit)
293
- is_to_volume = self._is_volume(to_unit)
294
-
295
- # Energy conversions
296
- if is_from_energy or is_to_energy:
297
- if not commodity:
298
- raise ValueError("Commodity required for energy conversion")
299
- comm = self.get_commodity(commodity)
300
- if not comm.energy_content:
301
- raise ValueError(f"No energy content defined for {commodity}")
302
-
303
- ec = comm.energy_content.to("J/m^3")
304
-
305
- if is_from_energy:
306
- energy_J = qty.to("J")
307
- # Energy -> Volume or Mass
308
- volume_m3 = (energy_J / ec).to("m^3")
309
- if is_to_volume:
310
- return volume_m3.to(to_unit).magnitude
311
- elif is_to_mass:
312
- if comm.density is None:
313
- raise ValueError(
314
- f"Mass<->volume conversion not supported for {commodity!r} "
315
- f"(no density defined); cannot convert energy to mass"
316
- )
317
- density_kg_m3 = comm.density.to("kg/m^3")
318
- mass_kg = (volume_m3 * density_kg_m3).to("kg")
319
- return mass_kg.to(to_unit).magnitude
320
- else:
321
- raise ValueError(f"Cannot convert energy to {to_unit}")
322
- else:
323
- # Volume/Mass -> Energy
324
- if is_from_mass:
325
- if comm.density is None:
326
- raise ValueError(
327
- f"Mass<->volume conversion not supported for {commodity!r} "
328
- f"(no density defined); cannot convert mass to energy"
329
- )
330
- density_kg_m3 = comm.density.to("kg/m^3")
331
- mass_kg = qty.to("kg")
332
- volume_m3 = (mass_kg / density_kg_m3).to("m^3")
333
- elif is_from_volume:
334
- volume_m3 = qty.to("m^3")
335
- else:
336
- raise ValueError(f"Cannot convert {from_unit} to energy")
337
- energy_J = (volume_m3 * ec).to("J")
338
- return energy_J.to(to_unit).magnitude
421
+ involves_energy = is_from_energy or is_to_energy
339
422
 
340
- # Mass <-> Volume conversions require density
341
- if (is_from_mass and is_to_volume) or (is_from_volume and is_to_mass):
342
- if not commodity:
423
+ if not commodity:
424
+ if involves_energy:
425
+ raise ValueError("Commodity required for energy conversion")
426
+ is_from_mass = self._is_mass(from_unit)
427
+ is_to_mass = self._is_mass(to_unit)
428
+ is_from_volume = self._is_volume(from_unit)
429
+ is_to_volume = self._is_volume(to_unit)
430
+ if (is_from_mass and is_to_volume) or (is_from_volume and is_to_mass):
343
431
  raise ValueError(f"Commodity required for {from_unit} to {to_unit}")
344
- comm = self.get_commodity(commodity)
345
- if comm.density is None:
432
+ raise ValueError(
433
+ f"Cannot convert from {from_unit} to {to_unit} - incompatible dimensions"
434
+ )
435
+
436
+ # Let the commodity's context find the conversion path (including
437
+ # mass<->energy chaining). A missing property leaves no path -> pint
438
+ # raises DimensionalityError, which we translate to the domain error.
439
+ comm = self.get_commodity(commodity)
440
+ ctx = self._commodity_context(commodity)
441
+ try:
442
+ return qty.to(to_unit, ctx).magnitude
443
+ except DimensionalityError as exc:
444
+ if involves_energy and comm.energy_content is None:
445
+ raise ValueError(f"No energy content defined for {commodity}") from exc
446
+ if comm.density is None and (
447
+ self._is_mass(from_unit) or self._is_mass(to_unit)
448
+ ):
346
449
  raise ValueError(
347
450
  f"Mass<->volume conversion not supported for {commodity!r} "
348
451
  f"(no density defined)"
349
- )
350
- density_kg_L = comm.density.to("kg/L")
351
- density_kg_m3 = comm.density.to("kg/m^3")
352
- if is_from_mass and is_to_volume:
353
- mass_kg = qty.to("kg")
354
- volume_L = (mass_kg / density_kg_L).to("L")
355
- return volume_L.to(to_unit).magnitude
356
- else:
357
- volume_L = qty.to("L")
358
- mass_kg = (volume_L * density_kg_L).to("kg")
359
- return mass_kg.to(to_unit).magnitude
360
-
361
- raise ValueError(
362
- f"Cannot convert from {from_unit} to {to_unit} - incompatible dimensions"
363
- )
452
+ ) from exc
453
+ raise ValueError(
454
+ f"Cannot convert from {from_unit} to {to_unit} - incompatible dimensions"
455
+ ) from exc
364
456
 
365
457
  def _convert_series(
366
458
  self,
@@ -450,25 +542,13 @@ class CommodityConverter:
450
542
  return from_per_day / to_per_day
451
543
 
452
544
  def _is_energy(self, unit: str) -> bool:
453
- try:
454
- (1 * self.ureg(unit)).to("J")
455
- return True
456
- except DimensionalityError:
457
- return False
545
+ return self.ureg(unit).check("[energy]")
458
546
 
459
547
  def _is_mass(self, unit: str) -> bool:
460
- try:
461
- (1 * self.ureg(unit)).to("kg")
462
- return True
463
- except DimensionalityError:
464
- return False
548
+ return self.ureg(unit).check("[mass]")
465
549
 
466
550
  def _is_volume(self, unit: str) -> bool:
467
- try:
468
- (1 * self.ureg(unit)).to("m^3")
469
- return True
470
- except DimensionalityError:
471
- return False
551
+ return self.ureg(unit).check("[length]**3")
472
552
 
473
553
  @property
474
554
  def available_commodities(self) -> list:
@@ -629,12 +709,11 @@ def convert_price(
629
709
  f"convert_price currently only supports USD/* as target; got '{to_unit}'"
630
710
  )
631
711
 
632
- # Unit-leg conversion (no FX yet — uses commodity factors)
712
+ # Unit-leg conversion (no FX yet — uses commodity factors).
713
+ # convfactor returns a nonzero float or raises, so no None/zero guard is
714
+ # needed here.
633
715
  factor = convfactor(from_bare_unit, to_bare_unit, commodity)
634
- if factor is None or factor == 0:
635
- unit_converted = value
636
- else:
637
- unit_converted = value / factor
716
+ unit_converted = value / factor
638
717
 
639
718
  # Same-base fractional case: USc -> USD, GBp -> GBP, EUc -> EUR, JPy -> JPY.
640
719
  # This is a pure /100 scale (or *100 in the reverse direction) — no FX
@@ -0,0 +1,104 @@
1
+ import datetime
2
+ import re
3
+ from datetime import datetime, date, time, timedelta
4
+
5
+ from dateutil.relativedelta import relativedelta
6
+
7
+
8
+ def _curmon():
9
+ return datetime.now().month
10
+
11
+
12
+ def _curyear():
13
+ return datetime.now().year
14
+
15
+
16
+ def _curmonyear():
17
+ return datetime(_curyear(), _curmon(), 1)
18
+
19
+
20
+ def _curmonyear_str():
21
+ return "%s-%s" % (_curyear(), _curmon()) # get pandas time filtering
22
+
23
+
24
+ def _last_day_of_prev_month():
25
+ return date.today().replace(day=1) - relativedelta(days=1)
26
+
27
+
28
+ def _start_day_of_prev_month():
29
+ return date.today().replace(day=1) - relativedelta(months=1)
30
+
31
+
32
+ def _prevmon():
33
+ return _start_day_of_prev_month().month
34
+
35
+
36
+ def _prevmon_str():
37
+ sdpm = _start_day_of_prev_month()
38
+ return "%s-%s" % (sdpm.year, sdpm.month) # get pandas time filtering
39
+
40
+
41
+ def _nextyear():
42
+ return _curyear() + 1
43
+
44
+
45
+ def _prevyear():
46
+ return _curyear() - 1
47
+
48
+
49
+ # Compute "current date" values live on each attribute access (PEP 562) so that a
50
+ # long-running process (Prefect worker, Dash app) that imports this module once still
51
+ # sees the correct month/year after a boundary, instead of a stale import-time snapshot.
52
+ _DYNAMIC = {
53
+ "curmon": _curmon,
54
+ "curyear": _curyear,
55
+ "curmonyear": _curmonyear,
56
+ "curmonyear_str": _curmonyear_str,
57
+ "last_day_of_prev_month": _last_day_of_prev_month,
58
+ "start_day_of_prev_month": _start_day_of_prev_month,
59
+ "prevmon": _prevmon,
60
+ "prevmon_str": _prevmon_str,
61
+ "nextyear": _nextyear,
62
+ "prevyear": _prevyear,
63
+ }
64
+
65
+
66
+ def __getattr__(name):
67
+ try:
68
+ return _DYNAMIC[name]()
69
+ except KeyError:
70
+ raise AttributeError(name)
71
+
72
+
73
+ def find_year(df, use_delta=False):
74
+ """
75
+ Given a dataframe find the years in the column headings. Return a dict of colname to year
76
+ eg { 'Q1 2016' : 2016, 'Q1 2017' : 2017
77
+ """
78
+ res = {}
79
+ for colname in df:
80
+ colregex = re.findall("\d\d\d\d", str(colname))
81
+ colyear = None
82
+ if len(colregex) >= 1:
83
+ colyear = int(colregex[0])
84
+
85
+ if colyear:
86
+ res[colname] = colyear
87
+ if colyear and use_delta:
88
+ delta = colyear - _curyear()
89
+ res[colname] = delta
90
+ else:
91
+ res[colname] = colname
92
+
93
+ return res
94
+
95
+
96
+ def time_until_end_of_day(dt=None):
97
+ # type: (datetime.datetime) -> datetime.timedelta
98
+ """
99
+ Get timedelta until end of day on the datetime passed, or current time.
100
+ """
101
+ if dt is None:
102
+ dt = datetime.now()
103
+ tomorrow = dt + timedelta(days=1)
104
+ return (datetime.combine(tomorrow, time.min) - dt).seconds
@@ -1,7 +1,6 @@
1
1
  import re
2
2
  from functools import reduce
3
3
 
4
- import numpy as np
5
4
  import pandas as pd
6
5
 
7
6
 
@@ -38,15 +37,11 @@ def fillna_downbet(df):
38
37
  """
39
38
  Fill weekends/holidays in timeseries but don't extend values beyond the last non-NaN entry.
40
39
  """
41
- df = df.copy()
42
- for col in df.columns:
43
- # Drop all NaNs at once, vectorized and fast
44
- non_nans = df[col].dropna()
45
- if len(non_nans) > 1:
46
- start, end = non_nans.index[0], non_nans.index[-1]
47
- # Perform ffill only on the portion that has data
48
- df.loc[start:end, col] = df.loc[start:end, col].ffill()
49
- return df
40
+ # ffill(limit_area="inside") only fills gaps between existing valid values,
41
+ # never before the first or after the last non-NaN — equivalent to the old
42
+ # per-column ffill over [first_valid:last_valid]. Returns a new frame, so the
43
+ # input is not mutated. Requires pandas>=2.1.
44
+ return df.ffill(limit_area="inside")
50
45
 
51
46
 
52
47
  def sql_insert_statement_from_dataframe(df, table_name, print_statemnt=False):
@@ -33,6 +33,10 @@ from commodutil.standards.currency import (
33
33
  split_currency_unit,
34
34
  to_symbol,
35
35
  )
36
+ from commodutil.standards.price_units import (
37
+ resolve_price_unit,
38
+ resolve_price_unit_from_attrs,
39
+ )
36
40
  from commodutil.standards.regions import (
37
41
  CRUDE_GRADE_REGIONS,
38
42
  REGION_PATTERNS,
@@ -44,7 +48,9 @@ from commodutil.standards.regions import (
44
48
  )
45
49
  from commodutil.standards.units import (
46
50
  UNIT_MAP,
51
+ canonical_price_unit_token,
47
52
  canonical_quantity_unit,
53
+ canonical_unit_token,
48
54
  default_unit_for_commodity,
49
55
  quantity_unit_from_price_unit,
50
56
  to_pint_token,
@@ -75,6 +81,9 @@ __all__ = [
75
81
  "required_fx_pair",
76
82
  "split_currency_unit",
77
83
  "to_symbol",
84
+ # price_units
85
+ "resolve_price_unit",
86
+ "resolve_price_unit_from_attrs",
78
87
  # regions
79
88
  "CRUDE_GRADE_REGIONS",
80
89
  "REGION_PATTERNS",
@@ -85,7 +94,9 @@ __all__ = [
85
94
  "normalize_region",
86
95
  # units
87
96
  "UNIT_MAP",
97
+ "canonical_price_unit_token",
88
98
  "canonical_quantity_unit",
99
+ "canonical_unit_token",
89
100
  "default_unit_for_commodity",
90
101
  "quantity_unit_from_price_unit",
91
102
  "to_pint_token",
@@ -61,6 +61,13 @@ COMMODITY_KEYWORDS = [
61
61
  ("Propane", "NGL", ["propane"]),
62
62
  ("NGL", "NGL", ["ngl"]),
63
63
  ("FFA", "Freight", ["freight", "ffa"]),
64
+ # Coal (API2 gross basis, added to COMMODITIES 2026-07). Group "Coal" is not
65
+ # in commodity_groups.COMMODITY_GROUPS (closed set mirroring the legacy
66
+ # MetadataDB2 CHECK constraint) — that tuple is deliberately NOT extended.
67
+ # Gold's commodity_group column is free-form and already carries values
68
+ # outside the closed set (e.g. 'Emissions'); no live consumer validates
69
+ # groups (2026-07 census).
70
+ ("Coal", "Coal", ["api2", "api4", "coal"]),
64
71
  ]
65
72
 
66
73
  COMMODITY_CONVERSION_MAP = {
@@ -86,6 +93,9 @@ COMMODITY_CONVERSION_MAP = {
86
93
  "Propane": "propane",
87
94
  "NGL": "lpg",
88
95
  "Ethane": "ethane",
96
+ # Coal (API2 gross basis) added to COMMODITIES 2026-07; keywords entry above
97
+ # enables display-name inference ("API2 Rotterdam Coal Futures" -> Coal).
98
+ "Coal": "coal",
89
99
  }
90
100
 
91
101