1"""Electronics Calculator - Apify actor.
2
3A suite of offline electronics calculations: Ohm's law and the power wheel,
4resistor colour-code decode and encode, LED series resistor, voltage divider,
5series and parallel component networks, RC and RL time constants, capacitive
6and inductive reactance with LC resonance, and energy running cost. Pure Python
7standard library, no API key, no network, so it never breaks.
8"""
9
10
11from __future__ import annotations
12
13import math
14import re
15
16
17
18
19
20_SI = {
21 "p": 1e-12, "n": 1e-9, "u": 1e-6, "µ": 1e-6, "m": 1e-3,
22 "k": 1e3, "K": 1e3, "M": 1e6, "G": 1e9, "T": 1e12,
23}
24
25
26
27
28_UNIT_SUFFIXES = sorted((
29 "ohms", "ohm", "Ω", "farads", "farad", "henries", "henry",
30 "hertz", "hz", "volts", "volt", "amps", "amp", "amperes", "ampere",
31 "watts", "watt", "seconds", "second",
32 "f", "h", "v", "a", "w", "s",
33), key=len, reverse=True)
34
35
36def parse_value(x, name="value"):
37 """Parse a number that may carry an SI prefix or RKM notation.
38
39 Accepts plain numbers (4700), SI prefixes (4.7k, 100n, 0.1u), RKM codes
40 (4k7, 2R2, 470R, 1M5), scientific notation (4.7e3) and an optional trailing
41 unit word (4.7 kohm, 100 nF). Returns a float in base units.
42 """
43 if isinstance(x, bool):
44 raise ValueError(f"{name} must be a number")
45 if isinstance(x, (int, float)):
46 return float(x)
47 if x is None:
48 raise ValueError(f"{name} is required")
49 s = str(x).strip().replace(" ", "")
50 if s == "":
51 raise ValueError(f"{name} is required")
52
53 low = s.lower()
54 for suf in _UNIT_SUFFIXES:
55 sl = suf.lower()
56 if low.endswith(sl) and len(s) > len(sl):
57 s = s[: len(s) - len(sl)]
58 break
59
60
61 m = re.fullmatch(r"(\d*)([pnuµmkKMGTRr])(\d*)", s)
62 if m and (m.group(1) or m.group(3)):
63 left = m.group(1) or "0"
64 letter = m.group(2)
65 right = m.group(3)
66 num = float(left + ("." + right if right else ""))
67 if letter in ("R", "r"):
68 mult = 1.0
69 else:
70 mult = _SI.get(letter, _SI.get(letter.lower(), 1.0))
71 return num * mult
72
73
74 m2 = re.fullmatch(r"([+-]?[0-9.]+(?:[eE][-+]?\d+)?)([pnuµmkKMGT]?)", s)
75 if m2:
76 num = float(m2.group(1))
77 pre = m2.group(2)
78 return num * (_SI.get(pre, 1.0) if pre else 1.0)
79
80 return float(s)
81
82
83def _trim(v, digits=6):
84 """Round to a few significant figures and drop trailing zeros."""
85 if v == 0:
86 return 0.0
87 if v == math.inf or v == -math.inf:
88 return v
89 return float(f"{v:.{digits}g}")
90
91
92def format_si(v, unit):
93 """Render a number with an SI prefix, e.g. 4700 -> '4.7 kΩ'."""
94 if v is None:
95 return None
96 if v == 0:
97 return f"0 {unit}"
98 neg = "-" if v < 0 else ""
99 a = abs(v)
100 steps = [
101 (1e12, "T"), (1e9, "G"), (1e6, "M"), (1e3, "k"), (1.0, ""),
102 (1e-3, "m"), (1e-6, "µ"), (1e-9, "n"), (1e-12, "p"),
103 ]
104 for factor, prefix in steps:
105 if a >= factor:
106 return f"{neg}{_trim(a / factor):g} {prefix}{unit}"
107 return f"{neg}{_trim(a / 1e-12):g} p{unit}"
108
109
110
111
112
113
114E12 = [1.0, 1.2, 1.5, 1.8, 2.2, 2.7, 3.3, 3.9, 4.7, 5.6, 6.8, 8.2]
115E24 = [1.0, 1.1, 1.2, 1.3, 1.5, 1.6, 1.8, 2.0, 2.2, 2.4, 2.7, 3.0,
116 3.3, 3.6, 3.9, 4.3, 4.7, 5.1, 5.6, 6.2, 6.8, 7.5, 8.2, 9.1]
117
118
119def nearest_e_series(value, series=E24, direction="up"):
120 """Nearest standard component value in a decade series."""
121 if value <= 0:
122 return value
123 decade = math.floor(math.log10(value))
124 base = 10 ** decade
125 candidates = [round(s * base, 12) for s in series] + [round(series[0] * base * 10, 12)]
126 if direction == "up":
127 for c in candidates:
128 if c >= value - 1e-12:
129 return c
130 return candidates[-1]
131 if direction == "down":
132 below = [c for c in candidates if c <= value + 1e-12]
133 return below[-1] if below else candidates[0]
134 return min(candidates, key=lambda c: abs(c - value))
135
136
137def _power_rating(watts):
138 """Next standard resistor power rating above the dissipation (2x headroom)."""
139 ratings = [0.0625, 0.125, 0.25, 0.5, 1.0, 2.0, 3.0, 5.0, 10.0, 25.0, 50.0]
140 target = watts * 2.0
141 for r in ratings:
142 if r >= target:
143 return r
144 return ratings[-1]
145
146
147
148
149
150
151_DIGIT_COLORS = ["black", "brown", "red", "orange", "yellow",
152 "green", "blue", "violet", "grey", "white"]
153_DIGIT = {c: i for i, c in enumerate(_DIGIT_COLORS)}
154_DIGIT["gray"] = 8
155
156_MULT = {
157 "black": 1, "brown": 10, "red": 100, "orange": 1e3, "yellow": 1e4,
158 "green": 1e5, "blue": 1e6, "violet": 1e7, "grey": 1e8, "gray": 1e8,
159 "white": 1e9, "gold": 0.1, "silver": 0.01,
160}
161_MULT_EXP = {-2: "silver", -1: "gold", 0: "black", 1: "brown", 2: "red",
162 3: "orange", 4: "yellow", 5: "green", 6: "blue", 7: "violet",
163 8: "grey", 9: "white"}
164
165_TOL = {"brown": 1.0, "red": 2.0, "green": 0.5, "blue": 0.25, "violet": 0.1,
166 "grey": 0.05, "gray": 0.05, "gold": 5.0, "silver": 10.0}
167_TEMPCO = {"black": 250, "brown": 100, "red": 50, "orange": 15, "yellow": 25,
168 "green": 20, "blue": 10, "violet": 5, "grey": 1}
169
170
171
172
173
174
175def ohms_law(c):
176 """Solve the Ohm's law / power wheel from any two of V, I, R, P."""
177 v = c.get("voltage")
178 i = c.get("current")
179 r = c.get("resistance")
180 p = c.get("power")
181 known = {}
182 for key, val in (("V", v), ("I", i), ("R", r), ("P", p)):
183 if val is not None and val != "":
184 known[key] = parse_value(val, key)
185 if len(known) < 2:
186 raise ValueError("provide any two of voltage, current, resistance, power")
187
188 V = known.get("V")
189 I = known.get("I")
190 R = known.get("R")
191 P = known.get("P")
192
193 if V is not None and I is not None:
194 R = V / I if I else math.inf
195 P = V * I
196 elif V is not None and R is not None:
197 if R == 0:
198 raise ValueError("resistance of 0 gives infinite current")
199 I = V / R
200 P = V * V / R
201 elif V is not None and P is not None:
202 if V == 0:
203 raise ValueError("voltage of 0 is invalid with power")
204 I = P / V
205 R = V * V / P if P else math.inf
206 elif I is not None and R is not None:
207 V = I * R
208 P = I * I * R
209 elif I is not None and P is not None:
210 if I == 0:
211 raise ValueError("current of 0 is invalid with power")
212 V = P / I
213 R = P / (I * I)
214 elif R is not None and P is not None:
215 if R < 0 or P < 0:
216 raise ValueError("resistance and power must be positive")
217 V = math.sqrt(P * R)
218 I = math.sqrt(P / R) if R else math.inf
219 else:
220 raise ValueError("unsupported combination; give any two of V, I, R, P")
221
222 return {
223 "voltage": _trim(V), "current": _trim(I),
224 "resistance": _trim(R), "power": _trim(P),
225 "voltageFormatted": format_si(V, "V"),
226 "currentFormatted": format_si(I, "A"),
227 "resistanceFormatted": format_si(R, "Ω"),
228 "powerFormatted": format_si(P, "W"),
229 "primaryFormatted": format_si(P, "W"),
230 "inputs": ", ".join(f"{k}={known[k]:g}" for k in known),
231 "result": (f"V={format_si(V, 'V')}, I={format_si(I, 'A')}, "
232 f"R={format_si(R, 'Ω')}, P={format_si(P, 'W')}"),
233 }
234
235
236def resistor_color_code(c):
237 """Decode colour bands to a value, or encode a value to colour bands."""
238 bands = c.get("bands")
239 if bands:
240 return _decode_resistor(bands)
241 if c.get("resistance") not in (None, ""):
242 return _encode_resistor(c)
243 raise ValueError("give either bands (to decode) or resistance (to encode)")
244
245
246def _decode_resistor(bands):
247 if isinstance(bands, str):
248 bands = re.split(r"[,\s]+", bands.strip())
249 bands = [b.strip().lower() for b in bands if str(b).strip()]
250 n = len(bands)
251 if n == 3:
252 digit_bands, mult_b, tol_b, temp_b = bands[:2], bands[2], None, None
253 elif n == 4:
254 digit_bands, mult_b, tol_b, temp_b = bands[:2], bands[2], bands[3], None
255 elif n == 5:
256 digit_bands, mult_b, tol_b, temp_b = bands[:3], bands[3], bands[4], None
257 elif n == 6:
258 digit_bands, mult_b, tol_b, temp_b = bands[:3], bands[3], bands[4], bands[5]
259 else:
260 raise ValueError("bands must be a list of 3, 4, 5 or 6 colours")
261
262 digits = ""
263 for b in digit_bands:
264 if b not in _DIGIT:
265 raise ValueError(f"'{b}' is not a valid digit-band colour")
266 digits += str(_DIGIT[b])
267 if mult_b not in _MULT:
268 raise ValueError(f"'{mult_b}' is not a valid multiplier colour")
269 value = int(digits) * _MULT[mult_b]
270 tol = _TOL.get(tol_b, 20.0) if tol_b else 20.0
271 tempco = _TEMPCO.get(temp_b) if temp_b else None
272
273 lo = value * (1 - tol / 100.0)
274 hi = value * (1 + tol / 100.0)
275 return {
276 "resistance": _trim(value, 6),
277 "resistanceFormatted": format_si(value, "Ω"),
278 "tolerancePercent": tol,
279 "minResistance": _trim(lo, 6),
280 "maxResistance": _trim(hi, 6),
281 "minResistanceFormatted": format_si(lo, "Ω"),
282 "maxResistanceFormatted": format_si(hi, "Ω"),
283 "temperatureCoefficient": tempco,
284 "bands": bands,
285 "primaryFormatted": f"{format_si(value, 'Ω')} ±{tol:g}%",
286 "inputs": " ".join(bands),
287 "result": (f"{format_si(value, 'Ω')} ±{tol:g}% "
288 f"({format_si(lo, 'Ω')} to {format_si(hi, 'Ω')})"),
289 }
290
291
292def _encode_resistor(c):
293 value = parse_value(c.get("resistance"), "resistance")
294 if value <= 0:
295 raise ValueError("resistance must be positive")
296 band_count = int(c.get("bandCount", 4) or 4)
297 if band_count not in (3, 4, 5, 6):
298 raise ValueError("bandCount must be 3, 4, 5 or 6")
299 sig = 2 if band_count in (3, 4) else 3
300
301 exp = math.floor(math.log10(value)) - (sig - 1)
302 digits_int = round(value / (10 ** exp))
303 if digits_int >= 10 ** sig:
304 digits_int //= 10
305 exp += 1
306 if digits_int < 10 ** (sig - 1):
307 digits_int *= 10
308 exp -= 1
309
310 if exp not in _MULT_EXP:
311 raise ValueError("resistance is outside the standard colour-code range")
312
313 digit_str = str(digits_int).rjust(sig, "0")
314 bands = [_DIGIT_COLORS[int(d)] for d in digit_str]
315 bands.append(_MULT_EXP[exp])
316 tol_color = str(c.get("toleranceColor", "gold")).strip().lower()
317 tol = _TOL.get(tol_color, 5.0)
318 if band_count >= 4:
319 bands.append(tol_color)
320 if band_count == 6:
321 bands.append("brown")
322
323 encoded_value = int(digit_str) * (10 ** exp)
324 return {
325 "resistance": _trim(encoded_value, 6),
326 "resistanceFormatted": format_si(encoded_value, "Ω"),
327 "tolerancePercent": tol,
328 "bands": bands,
329 "bandCount": band_count,
330 "primaryFormatted": " - ".join(bands),
331 "inputs": f"{format_si(value, 'Ω')}, {band_count} bands",
332 "result": f"{format_si(encoded_value, 'Ω')}: " + " - ".join(bands),
333 }
334
335
336def led_resistor(c):
337 """Series resistor for driving one or more LEDs."""
338 vs = parse_value(c.get("supplyVoltage"), "supplyVoltage")
339 vf = parse_value(c.get("ledForwardVoltage", 2.0), "ledForwardVoltage")
340 i_ma = parse_value(c.get("ledCurrentMa", 20.0), "ledCurrentMa")
341 n = int(c.get("numberOfLeds", 1) or 1)
342 if n < 1:
343 raise ValueError("numberOfLeds must be at least 1")
344 i = i_ma / 1000.0
345 if i <= 0:
346 raise ValueError("ledCurrentMa must be positive")
347 total_vf = vf * n
348 v_r = vs - total_vf
349 if v_r <= 0:
350 raise ValueError(
351 f"supply {vs:g} V is too low for {n} LED(s) needing {total_vf:g} V")
352 r = v_r / i
353 r_std = nearest_e_series(r, E24, "up")
354 actual_i = v_r / r_std if r_std else 0.0
355 p_r = v_r * actual_i
356 return {
357 "resistance": _trim(r),
358 "resistanceFormatted": format_si(r, "Ω"),
359 "nearestStandard": _trim(r_std),
360 "nearestStandardFormatted": format_si(r_std, "Ω"),
361 "resistorPower": _trim(p_r),
362 "resistorPowerFormatted": format_si(p_r, "W"),
363 "recommendedPowerRating": _power_rating(p_r),
364 "actualCurrentMa": _trim(actual_i * 1000.0),
365 "totalForwardVoltage": _trim(total_vf),
366 "primaryFormatted": format_si(r_std, "Ω"),
367 "inputs": f"Vs={vs:g}V, Vf={vf:g}V, I={i_ma:g}mA, n={n}",
368 "result": (f"use {format_si(r_std, 'Ω')} (calc {format_si(r, 'Ω')}), "
369 f"dissipating {format_si(p_r, 'W')}"),
370 }
371
372
373def voltage_divider(c):
374 """Output voltage of a two-resistor divider, optionally loaded."""
375 vin = parse_value(c.get("vin"), "vin")
376 r1 = parse_value(c.get("r1"), "r1")
377 r2 = parse_value(c.get("r2"), "r2")
378 if r1 < 0 or r2 < 0:
379 raise ValueError("resistances must be positive")
380 if r1 + r2 == 0:
381 raise ValueError("r1 and r2 cannot both be zero")
382 vout = vin * r2 / (r1 + r2)
383 current = vin / (r1 + r2)
384 p1 = current * current * r1
385 p2 = current * current * r2
386 out = {
387 "vout": _trim(vout),
388 "voutFormatted": format_si(vout, "V"),
389 "current": _trim(current),
390 "currentFormatted": format_si(current, "A"),
391 "r1Power": _trim(p1),
392 "r2Power": _trim(p2),
393 "primaryFormatted": format_si(vout, "V"),
394 "inputs": f"Vin={vin:g}V, R1={format_si(r1, 'Ω')}, R2={format_si(r2, 'Ω')}",
395 "result": f"Vout={format_si(vout, 'V')}, I={format_si(current, 'A')}",
396 }
397 load = c.get("loadResistance")
398 if load not in (None, ""):
399 rl = parse_value(load, "loadResistance")
400 if rl > 0:
401 r2eff = (r2 * rl) / (r2 + rl)
402 vout_l = vin * r2eff / (r1 + r2eff)
403 out["voutWithLoad"] = _trim(vout_l)
404 out["voutWithLoadFormatted"] = format_si(vout_l, "V")
405 out["result"] += f", loaded Vout={format_si(vout_l, 'V')}"
406 return out
407
408
409def component_network(c):
410 """Total value of resistors, capacitors or inductors in series or parallel."""
411 kind = str(c.get("componentType", "resistor")).strip().lower()
412 conn = str(c.get("connection", "series")).strip().lower()
413 raw = c.get("values") or []
414 if isinstance(raw, str):
415 raw = re.split(r"[,\s]+", raw.strip())
416 vals = [parse_value(v, "value") for v in raw if str(v).strip() != ""]
417 if not vals:
418 raise ValueError("values must contain at least one component")
419 if kind.startswith("res"):
420 unit, series_adds = "Ω", True
421 elif kind.startswith("cap"):
422 unit, series_adds = "F", False
423 elif kind.startswith("ind"):
424 unit, series_adds = "H", True
425 else:
426 raise ValueError("componentType must be resistor, capacitor or inductor")
427
428 if conn not in ("series", "parallel"):
429 raise ValueError("connection must be series or parallel")
430
431 adds_directly = (conn == "series") == series_adds
432 if adds_directly:
433 total = sum(vals)
434 else:
435 if any(v == 0 for v in vals):
436 raise ValueError("a component value of 0 makes the reciprocal undefined")
437 total = 1.0 / sum(1.0 / v for v in vals)
438
439 return {
440 "totalValue": _trim(total, 6),
441 "totalFormatted": format_si(total, unit),
442 "componentType": kind,
443 "connection": conn,
444 "componentCount": len(vals),
445 "primaryFormatted": format_si(total, unit),
446 "inputs": f"{conn} {kind}s: " + ", ".join(format_si(v, unit) for v in vals),
447 "result": f"{format_si(total, unit)} total",
448 }
449
450
451def rc_filter(c):
452 """Time constant and cutoff frequency of an RC or RL circuit."""
453 r = parse_value(c.get("resistance"), "resistance")
454 cap = c.get("capacitance")
455 ind = c.get("inductance")
456 if r <= 0:
457 raise ValueError("resistance must be positive")
458 if cap not in (None, ""):
459 cval = parse_value(cap, "capacitance")
460 if cval <= 0:
461 raise ValueError("capacitance must be positive")
462 tau = r * cval
463 fc = 1.0 / (2 * math.pi * r * cval)
464 kind = "RC"
465 elif ind not in (None, ""):
466 lval = parse_value(ind, "inductance")
467 if lval <= 0:
468 raise ValueError("inductance must be positive")
469 tau = lval / r
470 fc = r / (2 * math.pi * lval)
471 kind = "RL"
472 else:
473 raise ValueError("give a capacitance (RC) or an inductance (RL)")
474
475 return {
476 "timeConstant": _trim(tau, 6),
477 "timeConstantFormatted": format_si(tau, "s"),
478 "cutoffFrequency": _trim(fc, 6),
479 "cutoffFrequencyFormatted": format_si(fc, "Hz"),
480 "fiveTau": _trim(5 * tau, 6),
481 "fiveTauFormatted": format_si(5 * tau, "s"),
482 "filterType": kind,
483 "primaryFormatted": format_si(fc, "Hz"),
484 "inputs": f"{kind}: R={format_si(r, 'Ω')}, "
485 + (format_si(parse_value(cap), 'F') if kind == 'RC'
486 else format_si(parse_value(ind), 'H')),
487 "result": f"τ={format_si(tau, 's')}, fc={format_si(fc, 'Hz')}",
488 }
489
490
491def reactance(c):
492 """Capacitive and inductive reactance, plus LC resonant frequency."""
493 freq = c.get("frequency")
494 cap = c.get("capacitance")
495 ind = c.get("inductance")
496 out = {}
497 parts = []
498 xc = xl = None
499 f = None
500 if freq not in (None, ""):
501 f = parse_value(freq, "frequency")
502 if f <= 0:
503 raise ValueError("frequency must be positive")
504 if cap not in (None, ""):
505 cval = parse_value(cap, "capacitance")
506 if f is not None:
507 xc = 1.0 / (2 * math.pi * f * cval)
508 out["capacitiveReactance"] = _trim(xc)
509 out["capacitiveReactanceFormatted"] = format_si(xc, "Ω")
510 parts.append(f"Xc={format_si(xc, 'Ω')}")
511 if ind not in (None, ""):
512 lval = parse_value(ind, "inductance")
513 if f is not None:
514 xl = 2 * math.pi * f * lval
515 out["inductiveReactance"] = _trim(xl)
516 out["inductiveReactanceFormatted"] = format_si(xl, "Ω")
517 parts.append(f"Xl={format_si(xl, 'Ω')}")
518 if cap not in (None, "") and ind not in (None, ""):
519 cval = parse_value(cap, "capacitance")
520 lval = parse_value(ind, "inductance")
521 if cval <= 0 or lval <= 0:
522 raise ValueError("inductance and capacitance must be positive")
523 f0 = 1.0 / (2 * math.pi * math.sqrt(lval * cval))
524 out["resonantFrequency"] = _trim(f0)
525 out["resonantFrequencyFormatted"] = format_si(f0, "Hz")
526 parts.append(f"f0={format_si(f0, 'Hz')}")
527
528 if not parts:
529 raise ValueError(
530 "give a frequency with a capacitance and/or inductance, "
531 "or both L and C for the resonant frequency")
532
533 primary = out.get("resonantFrequencyFormatted") or \
534 out.get("capacitiveReactanceFormatted") or \
535 out.get("inductiveReactanceFormatted")
536 out["primaryFormatted"] = primary
537 out["inputs"] = ", ".join(
538 x for x in [
539 f"f={format_si(f, 'Hz')}" if f is not None else "",
540 f"C={format_si(parse_value(cap), 'F')}" if cap not in (None, "") else "",
541 f"L={format_si(parse_value(ind), 'H')}" if ind not in (None, "") else "",
542 ] if x)
543 out["result"] = ", ".join(parts)
544 return out
545
546
547def energy_cost(c):
548 """Energy use and running cost from power and time."""
549 if c.get("power") not in (None, ""):
550 watts = parse_value(c.get("power"), "power")
551 elif c.get("voltage") not in (None, "") and c.get("current") not in (None, ""):
552 watts = parse_value(c.get("voltage"), "voltage") * \
553 parse_value(c.get("current"), "current")
554 else:
555 raise ValueError("give power, or both voltage and current")
556 if watts < 0:
557 raise ValueError("power must be positive")
558
559 if c.get("hours") not in (None, ""):
560 hours = parse_value(c.get("hours"), "hours")
561 else:
562 hpd = parse_value(c.get("hoursPerDay", 24), "hoursPerDay")
563 days = parse_value(c.get("days", 1), "days")
564 hours = hpd * days
565 if hours < 0:
566 raise ValueError("time must be positive")
567
568 kwh = watts / 1000.0 * hours
569 joules = watts * hours * 3600.0
570 rate = c.get("energyRate")
571 currency = str(c.get("currency", "USD")).strip() or "USD"
572 out = {
573 "power": _trim(watts),
574 "powerFormatted": format_si(watts, "W"),
575 "totalHours": _trim(hours, 6),
576 "energyWh": _trim(watts / 1000.0 * hours * 1000.0, 6),
577 "energyKWh": _trim(kwh, 6),
578 "energyJoules": _trim(joules, 6),
579 "energyFormatted": format_si(joules, "J"),
580 "primaryFormatted": f"{_trim(kwh, 4):g} kWh",
581 "inputs": f"{format_si(watts, 'W')} for {_trim(hours, 4):g} h",
582 "result": f"{_trim(kwh, 4):g} kWh over {_trim(hours, 4):g} h",
583 }
584 if rate not in (None, ""):
585 rate = parse_value(rate, "energyRate")
586 cost = kwh * rate
587 out["cost"] = round(cost, 4)
588 out["currency"] = currency
589 out["primaryFormatted"] = f"{cost:.2f} {currency}"
590 out["result"] += f", {cost:.2f} {currency} at {rate:g}/kWh"
591 return out
592
593
594DISPATCH = {
595 "ohms-law": ohms_law,
596 "resistor-color-code": resistor_color_code,
597 "led-resistor": led_resistor,
598 "voltage-divider": voltage_divider,
599 "component-network": component_network,
600 "rc-filter": rc_filter,
601 "reactance": reactance,
602 "energy-cost": energy_cost,
603}
604
605
606def run_calculation(c):
607 """Run one calculation dict and return a normalised result row."""
608 ctype = str(c.get("type", "")).strip().lower()
609 row = {
610 "calculationType": ctype,
611 "label": c.get("label", ""),
612 "inputs": "",
613 "result": "",
614 "primaryFormatted": "",
615 "ok": True,
616 "error": None,
617 }
618 func = DISPATCH.get(ctype)
619 if func is None:
620 row["ok"] = False
621 row["error"] = (f"unknown type '{ctype}'. Use one of: "
622 + ", ".join(sorted(DISPATCH)))
623 return row
624 try:
625 row.update(func(c))
626 except Exception as exc:
627 row["ok"] = False
628 row["error"] = str(exc)
629 return row
630
631
632
633
634
635
636from apify import Actor
637
638DEFAULT_CALCULATIONS = [
639 {"type": "ohms-law", "voltage": 12, "current": 0.5,
640 "label": "12 V at 0.5 A"},
641 {"type": "resistor-color-code", "bands": ["yellow", "violet", "red", "gold"],
642 "label": "Decode a 4-band resistor"},
643 {"type": "resistor-color-code", "resistance": "4.7k", "bandCount": 5,
644 "label": "Encode 4.7 kΩ to 5 bands"},
645 {"type": "led-resistor", "supplyVoltage": 5, "ledForwardVoltage": 2.0,
646 "ledCurrentMa": 20, "numberOfLeds": 1, "label": "Series resistor for a red LED"},
647 {"type": "voltage-divider", "vin": 9, "r1": "10k", "r2": "4.7k",
648 "label": "9 V divider"},
649 {"type": "component-network", "componentType": "resistor",
650 "connection": "parallel", "values": ["10k", "10k"],
651 "label": "Two 10 kΩ in parallel"},
652 {"type": "rc-filter", "resistance": "10k", "capacitance": "100n",
653 "label": "RC low-pass cutoff"},
654 {"type": "reactance", "frequency": "1k", "inductance": "10m",
655 "capacitance": "100n", "label": "Reactance and LC resonance"},
656 {"type": "energy-cost", "power": 60, "hours": 720, "energyRate": 0.15,
657 "currency": "USD", "label": "60 W load for a month"},
658]
659
660
661async def main() -> None:
662 async with Actor:
663 actor_input = await Actor.get_input() or {}
664 calculations = actor_input.get("calculations")
665 if not calculations:
666 calculations = DEFAULT_CALCULATIONS
667 Actor.log.info("No calculations supplied, running the built-in examples.")
668
669 if not isinstance(calculations, list):
670 raise ValueError("Input 'calculations' must be a list of objects.")
671
672 results = []
673 for item in calculations:
674 if not isinstance(item, dict):
675 results.append({
676 "calculationType": "", "label": "", "inputs": "",
677 "result": "", "primaryFormatted": "",
678 "ok": False, "error": "each calculation must be an object",
679 })
680 continue
681 results.append(run_calculation(item))
682
683 ok = sum(1 for r in results if r.get("ok"))
684 Actor.log.info(f"Computed {len(results)} row(s), {ok} ok, "
685 f"{len(results) - ok} with errors.")
686 await Actor.push_data(results)