1010- a general-purpose unit conversion function `convert_units()` that uses Pint
1111 by default (with an optional cf_units backend via an environment variable),
1212 and that has fast paths for the above common conversions.
13-
13+
1414Notes
1515-----
1616- PSU is treated here as a practical “unit” for salinity in workflows,
3535from scipy .optimize import brentq
3636
3737
38-
3938# -----------------------------------------------------------------------------
4039# Constants for conversions (kept from legacy implementation)
4140# -----------------------------------------------------------------------------
5251K6 = 2.5842
5352k = 0.0162
5453
55- s_sea = 35.0 # representative ocean salinity, PSU
56- ec_sea = 53087.0 # EC (μS/cm) associated with s_sea at 25 °C
54+ s_sea = 35.0 # representative ocean salinity, PSU
55+ ec_sea = 53087.0 # EC (μS/cm) associated with s_sea at 25 °C
5756
5857# exact base
5958FT2M = 0.3048 # exact by definition
6059
6160# derive reciprocals/products to avoid mismatch and rounding drift
62- M2FT = 1.0 / FT2M
63- CFS2CMS = FT2M ** 3 # (ft^3/s) → (m^3/s)
61+ M2FT = 1.0 / FT2M
62+ CFS2CMS = FT2M ** 3 # (ft^3/s) → (m^3/s)
6463CMS2CFS = 1.0 / CFS2CMS
6564
6665
7675 "cms" : "m^3 s-1" ,
7776 "m3/s" : "m^3 s-1" ,
7877 "m^3/s" : "m^3 s-1" ,
79-
8078 # temperature (return case-sensitive names Pint expects)
8179 "deg f" : "degF" ,
8280 "degree_fahrenheit" : "degF" ,
8381 "deg c" : "degC" ,
8482 "degree_celsius" : "degC" ,
85-
8683 # conductivity spellings
8784 "us/cm" : "uS cm-1" ,
8885 "μs/cm" : "uS cm-1" ,
9289 "micromhos/cm@25c" : "uS cm-1" ,
9390}
9491
92+
9593def _norm (u : str ) -> str :
9694 """Normalize common shorthands to canonical spellings without
9795 destroying case needed by Pint (e.g., degC/degF)."""
@@ -102,52 +100,66 @@ def _norm(u: str) -> str:
102100 k = s .lower ()
103101 return _ALIASES .get (k , s )
104102
103+
105104def _rewrap_like (values , arr ):
106105 if isinstance (values , pd .DataFrame ):
107106 return pd .DataFrame (arr , index = values .index , columns = values .columns )
108107 if isinstance (values , pd .Series ):
109108 return pd .Series (arr , index = values .index , name = values .name )
110109 return arr
111110
111+
112112# ---- Optional backend flip via env var (no public API) -----------------------
113113def _want_cf_units () -> bool :
114114 return os .environ .get ("VTOOLS_UNITS_BACKEND" , "" ).lower () == "cf_units"
115115
116+
116117@functools .lru_cache (maxsize = 128 )
117118def _get_converter (iu : str , ou : str ):
118119 """Return a callable(arr)->arr using Pint by default; cf_units if env-forced."""
119120 if _want_cf_units ():
120121 try :
121122 from cf_units import Unit
123+
122124 u_in , u_out = Unit (iu ), Unit (ou )
123- def conv (arr ): return u_in .convert (np .asarray (arr ), u_out )
125+
126+ def conv (arr ):
127+ return u_in .convert (np .asarray (arr ), u_out )
128+
124129 return conv
125130 except Exception :
126131 # fall through to Pint if cf_units not available
127132 pass
128133
129134 import pint
135+
130136 ureg = pint .UnitRegistry (autoconvert_offset_to_baseunit = True )
131137
132138 q_in , q_out = 1.0 * ureg (iu ), 1.0 * ureg (ou )
133139 # --- Skip fast scaling for temperature units (affine with offsets) ---
134140 OFFSET_UNITS = {"degC" , "degF" , "degree_Celsius" , "degree_Fahrenheit" , "degR" }
135141 if iu in OFFSET_UNITS or ou in OFFSET_UNITS :
142+
136143 def conv (arr ):
137144 a = np .asarray (arr )
138145 return (a * ureg (iu )).to (ureg (ou )).m
146+
139147 return conv
140148
141149 # --- Otherwise use fast pure-scale path ---
142150 try :
143151 factor = q_in .to (q_out ).magnitude
152+
144153 def conv (arr ):
145154 return np .asarray (arr ) * factor
155+
146156 return conv
147157 except Exception :
158+
148159 def conv (arr ):
149160 a = np .asarray (arr )
150161 return (a * ureg (iu )).to (ureg (ou )).m
162+
151163 return conv
152164
153165
@@ -177,13 +189,12 @@ def convert_units(values, in_unit: str, out_unit: str):
177189 # --- Custom domain-first paths -------------------------------------------
178190 # Temperature
179191 if iu == "degf" and ou == "degc" :
180- arr = (np .asarray (values ) - 32.0 ) * (5.0 / 9.0 )
192+ arr = (np .asarray (values ) - 32.0 ) * (5.0 / 9.0 )
181193 return _rewrap_like (values , arr )
182194 if iu == "degc" and ou == "degf" :
183195 arr = np .asarray (values ) * 1.8 + 32.0
184196 return _rewrap_like (values , arr )
185197
186-
187198 # Length / Flow shorthands (scale only)
188199 if iu == "ft" and ou == "m" :
189200 return _rewrap_like (values , np .asarray (values ) * FT2M )
@@ -196,7 +207,7 @@ def convert_units(values, in_unit: str, out_unit: str):
196207
197208 # EC ↔ PSU at 25C (never hand 'psu' to a generic backend)
198209 if iu in ("ec" , "us/cm" , "uS cm-1" , "micromhos/cm" ) and ou == "psu" :
199- return ec_psu_25c (values , hill_correction = True ) # uses your existing impl
210+ return ec_psu_25c (values , hill_correction = True ) # uses your existing impl
200211 if iu == "psu" and ou in ("ec" , "us/cm" , "uS cm-1" , "micromhos/cm" ):
201212 out = psu_ec_25c (values , refine = True , hill_correction = True )
202213 return out
@@ -208,8 +219,6 @@ def convert_units(values, in_unit: str, out_unit: str):
208219 return _rewrap_like (values , out )
209220
210221
211-
212-
213222# -----------------------------------------------------------------------------
214223# Linear / affine engineering conversions (functional)
215224# -----------------------------------------------------------------------------
@@ -425,7 +434,11 @@ def ec_psu_25c(ec, hill_correction=True):
425434 a_0 = 0.008
426435 f_ = (25.0 - 15.0 ) / (1.0 + k * (25.0 - 15.0 )) # f(T=25)
427436 b_0_f = 0.0005 * f_
428- s = s - a_0 / (1.0 + 1.5 * x + x * x ) - b_0_f / (1.0 + np .sqrt (y ) + y + y * np .sqrt (y ))
437+ s = (
438+ s
439+ - a_0 / (1.0 + 1.5 * x + x * x )
440+ - b_0_f / (1.0 + np .sqrt (y ) + y + y * np .sqrt (y ))
441+ )
429442
430443 if np .isscalar (ec ):
431444 return float (s ) if not np .isnan (s ) else s
@@ -473,19 +486,25 @@ def psu_ec_25c_scalar(psu, refine=True, hill_correction=True):
473486 return np .nan
474487
475488 if hill_correction and not refine :
476- raise ValueError ("Unrefined (refine=False) psu-to-ec correction cannot have hill_correction" )
489+ raise ValueError (
490+ "Unrefined (refine=False) psu-to-ec correction cannot have hill_correction"
491+ )
477492
478493 if refine :
479494 if psu > 34.99969 :
480495 raise ValueError (f"psu is over sea salinity: { psu } " )
481496 ec = brentq (psu_ec_resid , 1.0 , ec_sea , args = (psu , hill_correction ))
482497 else :
483498 sqrtpsu = np .sqrt (psu )
484- ec = (psu / s_sea ) * ec_sea + psu * (psu - s_sea ) * (J1 + J2 * sqrtpsu + J3 * psu + J4 * sqrtpsu * psu )
499+ ec = (psu / s_sea ) * ec_sea + psu * (psu - s_sea ) * (
500+ J1 + J2 * sqrtpsu + J3 * psu + J4 * sqrtpsu * psu
501+ )
485502 return ec
486503
487504
488- psu_ec_25c_vec = np .vectorize (psu_ec_25c_scalar , otypes = "d" , excluded = ["refine" , "hill_correction" ])
505+ psu_ec_25c_vec = np .vectorize (
506+ psu_ec_25c_scalar , otypes = "d" , excluded = ["refine" , "hill_correction" ]
507+ )
489508
490509
491510def psu_ec_25c (psu , refine = True , hill_correction = True ):
0 commit comments