1"""Universal unit converter actor.
2
3Converts a numeric value from one unit to another, or to every unit in the
4same category. Pure offline computation using exact conversion factors, so
5there is no external API and nothing to break at runtime.
6"""
7
8from __future__ import annotations
9
10from apify import Actor
11
12LENGTH = {
13 "m": ("meter", 1.0, ["meter", "meters", "metre", "metres"]),
14 "km": ("kilometer", 1000.0, ["kilometer", "kilometers", "kilometre", "kilometres"]),
15 "cm": ("centimeter", 0.01, ["centimeter", "centimeters", "centimetre"]),
16 "mm": ("millimeter", 0.001, ["millimeter", "millimeters", "millimetre"]),
17 "um": ("micrometer", 1e-6, ["micrometer", "micron", "microns"]),
18 "nm": ("nanometer", 1e-9, ["nanometer", "nanometre"]),
19 "mi": ("mile", 1609.344, ["mile", "miles"]),
20 "yd": ("yard", 0.9144, ["yard", "yards"]),
21 "ft": ("foot", 0.3048, ["foot", "feet"]),
22 "in": ("inch", 0.0254, ["inch", "inches"]),
23 "nmi": ("nautical mile", 1852.0, ["nauticalmile", "nauticalmiles"]),
24}
25
26MASS = {
27 "kg": ("kilogram", 1.0, ["kilogram", "kilograms", "kilo", "kilos"]),
28 "g": ("gram", 0.001, ["gram", "grams", "gramme"]),
29 "mg": ("milligram", 1e-6, ["milligram", "milligrams"]),
30 "t": ("metric ton", 1000.0, ["tonne", "tonnes", "metricton", "metrictons"]),
31 "lb": ("pound", 0.45359237, ["pound", "pounds", "lbs"]),
32 "oz": ("ounce", 0.028349523125, ["ounce", "ounces"]),
33 "st": ("stone", 6.35029318, ["stone", "stones"]),
34 "ust": ("US ton", 907.18474, ["uston", "shortton", "shorttons"]),
35 "lt": ("long ton", 1016.0469088, ["longton", "longtons", "imperialton"]),
36}
37
38VOLUME = {
39 "l": ("liter", 1.0, ["liter", "liters", "litre", "litres"]),
40 "ml": ("milliliter", 0.001, ["milliliter", "milliliters", "millilitre"]),
41 "m3": ("cubic meter", 1000.0, ["cubicmeter", "cubicmetre", "m^3"]),
42 "cm3": ("cubic centimeter", 0.001, ["cubiccentimeter", "cc", "cm^3"]),
43 "gal": ("US gallon", 3.785411784, ["gallon", "gallons", "usgallon"]),
44 "qt": ("US quart", 0.946352946, ["quart", "quarts"]),
45 "pt": ("US pint", 0.473176473, ["pint", "pints"]),
46 "cup": ("US cup", 0.2365882365, ["cup", "cups"]),
47 "floz": ("US fluid ounce", 0.0295735295625, ["fluidounce", "fluidounces", "usfloz"]),
48 "tbsp": ("tablespoon", 0.01478676478125, ["tablespoon", "tablespoons"]),
49 "tsp": ("teaspoon", 0.00492892159375, ["teaspoon", "teaspoons"]),
50 "galuk": ("imperial gallon", 4.54609, ["imperialgallon", "ukgallon"]),
51}
52
53AREA = {
54 "m2": ("square meter", 1.0, ["squaremeter", "squaremetre", "sqm", "m^2"]),
55 "km2": ("square kilometer", 1e6, ["squarekilometer", "sqkm", "km^2"]),
56 "cm2": ("square centimeter", 1e-4, ["squarecentimeter", "sqcm", "cm^2"]),
57 "ha": ("hectare", 10000.0, ["hectare", "hectares"]),
58 "acre": ("acre", 4046.8564224, ["acre", "acres"]),
59 "mi2": ("square mile", 2589988.110336, ["squaremile", "sqmi", "mi^2"]),
60 "ft2": ("square foot", 0.09290304, ["squarefoot", "squarefeet", "sqft", "ft^2"]),
61 "in2": ("square inch", 0.00064516, ["squareinch", "sqin", "in^2"]),
62 "yd2": ("square yard", 0.83612736, ["squareyard", "sqyd", "yd^2"]),
63}
64
65SPEED = {
66 "mps": ("meter per second", 1.0, ["meterspersecond", "m/s", "ms"]),
67 "kph": ("kilometer per hour", 0.277777777778, ["kmh", "km/h", "kilometersperhour"]),
68 "mph": ("mile per hour", 0.44704, ["milesperhour", "mi/h"]),
69 "fps": ("foot per second", 0.3048, ["feetpersecond", "ft/s"]),
70 "knot": ("knot", 0.514444444444, ["knots", "kn", "kt"]),
71}
72
73TIME = {
74 "s": ("second", 1.0, ["second", "seconds", "sec", "secs"]),
75 "ms": ("millisecond", 0.001, ["millisecond", "milliseconds", "msec"]),
76 "min": ("minute", 60.0, ["minute", "minutes"]),
77 "hr": ("hour", 3600.0, ["hour", "hours", "h"]),
78 "day": ("day", 86400.0, ["day", "days", "d"]),
79 "week": ("week", 604800.0, ["week", "weeks", "wk"]),
80 "month": ("month (30 days)", 2592000.0, ["month", "months"]),
81 "year": ("year (365 days)", 31536000.0, ["year", "years", "yr"]),
82}
83
84DIGITAL = {
85 "byte": ("byte", 1.0, ["byte", "bytes", "b"]),
86 "bit": ("bit", 0.125, ["bit", "bits"]),
87 "kb": ("kilobyte", 1000.0, ["kilobyte", "kilobytes"]),
88 "mb": ("megabyte", 1e6, ["megabyte", "megabytes"]),
89 "gb": ("gigabyte", 1e9, ["gigabyte", "gigabytes"]),
90 "tb": ("terabyte", 1e12, ["terabyte", "terabytes"]),
91 "pb": ("petabyte", 1e15, ["petabyte", "petabytes"]),
92 "kib": ("kibibyte", 1024.0, ["kibibyte", "kibibytes"]),
93 "mib": ("mebibyte", 1048576.0, ["mebibyte", "mebibytes"]),
94 "gib": ("gibibyte", 1073741824.0, ["gibibyte", "gibibytes"]),
95 "tib": ("tebibyte", 1099511627776.0, ["tebibyte", "tebibytes"]),
96}
97
98PRESSURE = {
99 "pa": ("pascal", 1.0, ["pascal", "pascals"]),
100 "kpa": ("kilopascal", 1000.0, ["kilopascal", "kilopascals"]),
101 "bar": ("bar", 100000.0, ["bar", "bars"]),
102 "psi": ("psi", 6894.757293168, ["poundpersquareinch", "poundspersquareinch"]),
103 "atm": ("atmosphere", 101325.0, ["atmosphere", "atmospheres"]),
104 "mmhg": ("millimeter of mercury", 133.322387415, ["mmofmercury"]),
105 "torr": ("torr", 133.3223684211, ["torrs"]),
106}
107
108ENERGY = {
109 "j": ("joule", 1.0, ["joule", "joules"]),
110 "kj": ("kilojoule", 1000.0, ["kilojoule", "kilojoules"]),
111 "cal": ("calorie", 4.184, ["calorie", "calories"]),
112 "kcal": ("kilocalorie", 4184.0, ["kilocalorie", "kilocalories", "foodcalorie"]),
113 "wh": ("watt hour", 3600.0, ["watthour", "watthours"]),
114 "kwh": ("kilowatt hour", 3600000.0, ["kilowatthour", "kilowatthours"]),
115 "btu": ("British thermal unit", 1055.05585262, ["britishthermalunit", "btus"]),
116 "ftlb": ("foot pound", 1.3558179483314, ["footpound", "footpounds"]),
117}
118
119POWER = {
120 "w": ("watt", 1.0, ["watt", "watts"]),
121 "kw": ("kilowatt", 1000.0, ["kilowatt", "kilowatts"]),
122 "mw": ("megawatt", 1e6, ["megawatt", "megawatts"]),
123 "hp": ("horsepower", 745.6998715823, ["horsepower", "hps"]),
124 "ps": ("metric horsepower", 735.49875, ["metrichorsepower", "pferdestarke"]),
125}
126
127ANGLE = {
128 "deg": ("degree", 1.0, ["degree", "degrees"]),
129 "rad": ("radian", 57.2957795130823, ["radian", "radians"]),
130 "grad": ("gradian", 0.9, ["gradian", "gradians", "gon"]),
131 "arcmin": ("arcminute", 0.0166666666667, ["arcminute", "arcminutes"]),
132 "arcsec": ("arcsecond", 0.000277777777778, ["arcsecond", "arcseconds"]),
133 "turn": ("turn", 360.0, ["turn", "turns", "revolution", "revolutions"]),
134}
135
136FREQUENCY = {
137 "hz": ("hertz", 1.0, ["hertz"]),
138 "khz": ("kilohertz", 1000.0, ["kilohertz"]),
139 "mhz": ("megahertz", 1e6, ["megahertz"]),
140 "ghz": ("gigahertz", 1e9, ["gigahertz"]),
141 "rpm": ("revolution per minute", 0.0166666666667, ["revolutionsperminute", "rpms"]),
142}
143
144CATEGORIES = {
145 "length": LENGTH, "mass": MASS, "volume": VOLUME, "area": AREA,
146 "speed": SPEED, "time": TIME, "digital": DIGITAL, "pressure": PRESSURE,
147 "energy": ENERGY, "power": POWER, "angle": ANGLE, "frequency": FREQUENCY,
148}
149
150TEMPERATURE_UNITS = {"c": "Celsius", "f": "Fahrenheit", "k": "Kelvin", "r": "Rankine"}
151TEMPERATURE_ALIASES = {
152 "celsius": "c", "centigrade": "c", "degc": "c",
153 "fahrenheit": "f", "degf": "f",
154 "kelvin": "k", "degk": "k",
155 "rankine": "r", "degr": "r",
156}
157
158
159def _normalize(unit: str) -> str:
160 return unit.strip().lower().replace(" ", "").replace("_", "")
161
162
163_UNIT_INDEX = {}
164for _cat, _units in CATEGORIES.items():
165 for _key, (_name, _factor, _aliases) in _units.items():
166 _UNIT_INDEX[_key] = (_cat, _key)
167 _UNIT_INDEX[_normalize(_name)] = (_cat, _key)
168 for _a in _aliases:
169 _UNIT_INDEX[_normalize(_a)] = (_cat, _key)
170
171_TEMP_INDEX = {}
172for _k, _label in TEMPERATURE_UNITS.items():
173 _TEMP_INDEX[_k] = _k
174 _TEMP_INDEX[_normalize(_label)] = _k
175for _alias, _k in TEMPERATURE_ALIASES.items():
176 _TEMP_INDEX[_normalize(_alias)] = _k
177
178
179def resolve_unit(unit):
180 n = _normalize(unit)
181 if n in _TEMP_INDEX:
182 return ("temperature", _TEMP_INDEX[n])
183 if n in _UNIT_INDEX:
184 return _UNIT_INDEX[n]
185 return None
186
187
188def temp_to_kelvin(value, unit):
189 if unit == "c":
190 return value + 273.15
191 if unit == "f":
192 return (value - 32.0) * 5.0 / 9.0 + 273.15
193 if unit == "k":
194 return value
195 if unit == "r":
196 return value * 5.0 / 9.0
197 raise ValueError(unit)
198
199
200def kelvin_to_temp(kelvin, unit):
201 if unit == "c":
202 return kelvin - 273.15
203 if unit == "f":
204 return (kelvin - 273.15) * 9.0 / 5.0 + 32.0
205 if unit == "k":
206 return kelvin
207 if unit == "r":
208 return kelvin * 9.0 / 5.0
209 raise ValueError(unit)
210
211
212def convert(value, from_cat, from_key, to_cat, to_key):
213 if from_cat == "temperature":
214 return kelvin_to_temp(temp_to_kelvin(value, from_key), to_key)
215 from_factor = CATEGORIES[from_cat][from_key][1]
216 to_factor = CATEGORIES[to_cat][to_key][1]
217 return value * from_factor / to_factor
218
219
220def unit_name(cat, key):
221 if cat == "temperature":
222 return TEMPERATURE_UNITS[key]
223 return CATEGORIES[cat][key][0]
224
225
226def format_number(x, precision):
227 if x != x:
228 return "NaN"
229 rounded = round(x, precision)
230 if rounded == 0:
231 rounded = 0.0
232 s = f"{rounded:.{precision}f}"
233 if "." in s:
234 s = s.rstrip("0").rstrip(".")
235 return s if s else "0"
236
237
238def targets_for(cat, requested_key=None):
239 if requested_key is not None:
240 return [(cat, requested_key)]
241 if cat == "temperature":
242 return [("temperature", k) for k in TEMPERATURE_UNITS]
243 return [(cat, k) for k in CATEGORIES[cat]]
244
245
246async def main():
247 async with Actor:
248 actor_input = await Actor.get_input() or {}
249 conversions = actor_input.get("conversions") or []
250 precision = actor_input.get("precision", 6)
251 try:
252 precision = int(precision)
253 except (TypeError, ValueError):
254 precision = 6
255 precision = max(0, min(precision, 15))
256
257 if not isinstance(conversions, list) or not conversions:
258 Actor.log.warning("No conversions provided, using a demo set.")
259 conversions = [
260 {"value": 100, "from": "km", "to": "mi"},
261 {"value": 72, "from": "F"},
262 {"value": 5, "from": "kg", "to": "lb"},
263 {"value": 1, "from": "GB", "to": "MB"},
264 ]
265
266 rows = []
267 for idx, item in enumerate(conversions):
268 if not isinstance(item, dict):
269 rows.append({"requestIndex": idx, "inputValue": None, "fromUnit": None,
270 "error": "Each conversion must be an object with value and from."})
271 continue
272
273 raw_value = item.get("value")
274 from_unit = item.get("from") or item.get("fromUnit") or ""
275 to_unit = item.get("to") or item.get("toUnit") or ""
276
277 try:
278 value = float(raw_value)
279 except (TypeError, ValueError):
280 rows.append({"requestIndex": idx, "inputValue": raw_value, "fromUnit": from_unit,
281 "error": f"Value '{raw_value}' is not a number."})
282 continue
283
284 from_resolved = resolve_unit(str(from_unit))
285 if from_resolved is None:
286 rows.append({"requestIndex": idx, "inputValue": value, "fromUnit": from_unit,
287 "error": f"Unknown source unit '{from_unit}'."})
288 continue
289 from_cat, from_key = from_resolved
290
291 requested_target_key = None
292 if to_unit:
293 to_resolved = resolve_unit(str(to_unit))
294 if to_resolved is None:
295 rows.append({"requestIndex": idx, "inputValue": value, "fromUnit": from_unit,
296 "category": from_cat, "error": f"Unknown target unit '{to_unit}'."})
297 continue
298 to_cat, to_key = to_resolved
299 if to_cat != from_cat:
300 rows.append({"requestIndex": idx, "inputValue": value, "fromUnit": from_unit,
301 "category": from_cat,
302 "error": f"Cannot convert {from_cat} unit '{from_unit}' to {to_cat} unit '{to_unit}'."})
303 continue
304 requested_target_key = to_key
305
306 for tgt_cat, tgt_key in targets_for(from_cat, requested_target_key):
307 try:
308 result = convert(value, from_cat, from_key, tgt_cat, tgt_key)
309 except Exception as exc:
310 rows.append({"requestIndex": idx, "inputValue": value, "fromUnit": from_key,
311 "toUnit": tgt_key, "error": str(exc)})
312 continue
313 rows.append({
314 "requestIndex": idx,
315 "inputValue": value,
316 "fromUnit": from_key,
317 "fromUnitName": unit_name(from_cat, from_key),
318 "category": from_cat,
319 "toUnit": tgt_key,
320 "toUnitName": unit_name(tgt_cat, tgt_key),
321 "result": round(result, precision),
322 "formatted": f"{format_number(value, precision)} {from_key} = {format_number(result, precision)} {tgt_key}",
323 "error": None,
324 })
325
326 await Actor.push_data(rows)
327 Actor.log.info(f"Wrote {len(rows)} conversion rows to the dataset.")