""" rgb_to_pwm.py Closed-form Python port of the Input Sheet RGB to PWM pipeline. Input : RGB (0..255) per channel, ambient temperature (C). Output : (pwm_no_comp, pwm_comp) -- 16-bit PWM tuples for each channel. Mirrors the spreadsheet chain: Input Sheet B/C/D (RGB) ==> Color Management AP3# (gamut + calibration) ==> Lumin managment BH3# (luminance scaling) ==> Input Sheet I/J/K (pwm_no_comp) ==> Input Sheet L/M/N (pwm_comp, with temperature) All integer math uses FLOOR.MATH / CEILING.MATH semantics (towards -infinity / +infinity). """ from dataclasses import dataclass from typing import Tuple, List import math # Color config constants L2 = 255 L3 = 65536 L4 = 256 L5 = 65536 L8 = 65536 L11 = 32768 L12 = 64879 K4 = 8 K10 = 16 # Lumin managment constants B1 = 256 D1 = 32768 B15 = 8 C15 = 256 # Input Sheet constants V32 = 15 V33 = 3840 # Calibration vectors CALIB_LM_SCALED: Tuple[int, int, int] = (2396, 5534, 1740) # ColorConfig!B24:D24 = Lumin!A6# TARGET_LM_SCALED: Tuple[int, int, int] = (2396, 4433, 1620) # InputSheet!V12:X12 = Lumin!A3# A11_RATIO_Q15: Tuple[int, int, int] = tuple( math.floor(TARGET_LM_SCALED[i] * D1 / CALIB_LM_SCALED[i]) for i in range(3) ) # Color Management calibration matrix (ColorConfig!B9:D11) COLOR_MATRIX: List[List[int]] = [ [3437, 1404, 1198], [1581, 4206, 628], [ 110, 851, 5908], ] # Calibration determinant (Color Management!B44/C44/D44) B44 = 1504796096380 C44 = B44 >> 16 # 22961366 D44 = math.floor(math.log2(C44) + 1) - 16 # 9 # Target XYZ matrix columns (Input Sheet V/W/X 26:28) TARGET_X = (5665, 2396, 0) TARGET_Y = (1094, 5534, 996) TARGET_Z = (9618, 1740, 51922) # Temperature compensation table (T, kR, kG, kB) -- 96 rows, T from -40..150 step 2 TEMP_TABLE: List[Tuple[float, float, float, float]] = [ (-40, 1.391998, 1.099121, 0.940033), (-38, 1.379791, 1.096924, 0.942169), (-36, 1.367584, 1.094727, 0.944305), (-34, 1.355377, 1.092529, 0.946442), (-32, 1.34317, 1.090332, 0.948578), (-30, 1.330963, 1.088135, 0.950714), (-28, 1.318756, 1.085938, 0.95285), (-26, 1.306549, 1.08374, 0.954987), (-24, 1.294342, 1.081543, 0.957123), (-22, 1.282135, 1.079346, 0.959259), (-20, 1.269928, 1.077148, 0.961395), (-18, 1.257721, 1.074951, 0.963531), (-16, 1.245514, 1.072754, 0.965668), (-14, 1.233307, 1.070557, 0.967804), (-12, 1.2211, 1.068359, 0.96994), (-10, 1.208893, 1.066162, 0.972076), (-8, 1.196686, 1.062317, 0.974213), (-6, 1.184479, 1.058716, 0.976349), (-4, 1.172272, 1.055115, 0.978485), (-2, 1.160065, 1.051514, 0.980621), (0, 1.147858, 1.047913, 0.982758), (2, 1.135651, 1.044312, 0.984894), (4, 1.123444, 1.04071, 0.98703), (6, 1.111237, 1.037109, 0.989166), (8, 1.09903, 1.033508, 0.991302), (10, 1.086823, 1.029907, 0.993439), (12, 1.074615, 1.026306, 0.995575), (14, 1.062408, 1.022705, 0.997711), (16, 1.050201, 1.019104, 0.999847), (18, 1.037994, 1.015503, 1.001984), (20, 1.025787, 1.011902, 1.00412), (22, 1.01358, 1.008301, 1.006256), (24, 1.001373, 1.0047, 1.008392), (26, 0.989166, 1.001099, 1.010529), (28, 0.976959, 0.997498, 1.012665), (30, 0.965851, 0.992767, 1.014801), (32, 0.952484, 0.988617, 1.016937), (34, 0.939117, 0.984467, 1.019073), (36, 0.925751, 0.980316, 1.02121), (38, 0.912384, 0.976166, 1.023346), (40, 0.899017, 0.972015, 1.025482), (42, 0.885651, 0.967865, 1.027618), (44, 0.872284, 0.963715, 1.029755), (46, 0.858917, 0.959564, 1.031891), (48, 0.845551, 0.955414, 1.034027), (50, 0.832184, 0.951263, 1.036163), (52, 0.818817, 0.947113, 1.0383), (54, 0.80545, 0.942963, 1.040436), (56, 0.792084, 0.938812, 1.042572), (58, 0.778717, 0.934662, 1.044708), (60, 0.76535, 0.930511, 1.046844), (62, 0.751984, 0.926361, 1.048981), (64, 0.738617, 0.922211, 1.051117), (66, 0.72525, 0.91806, 1.053253), (68, 0.711884, 0.91391, 1.055389), (70, 0.698517, 0.90976, 1.057526), (72, 0.68515, 0.905609, 1.059662), (74, 0.671783, 0.901459, 1.061798), (76, 0.658417, 0.897308, 1.063934), (78, 0.64505, 0.893158, 1.066071), (80, 0.631683, 0.889008, 1.068207), (82, 0.618317, 0.884857, 1.070343), (84, 0.60495, 0.880707, 1.072479), (86, 0.591583, 0.876556, 1.074615), (88, 0.578217, 0.872406, 1.076752), (90, 0.56485, 0.868256, 1.078888), (92, 0.551483, 0.864105, 1.081024), (94, 0.538116, 0.859955, 1.08316), (96, 0.52475, 0.855804, 1.085297), (98, 0.511383, 0.851654, 1.087433), (100, 0.498016, 0.847504, 1.089569), (102, 0.48465, 0.843353, 1.087433), (104, 0.468872, 0.839203, 1.087128), (106, 0.457336, 0.835052, 1.086823), (108, 0.445801, 0.830902, 1.086517), (110, 0.434265, 0.826752, 1.086212), (112, 0.422729, 0.822601, 1.085907), (114, 0.411194, 0.818451, 1.085602), (116, 0.399658, 0.817047, 1.085297), (118, 0.388123, 0.812225, 1.084991), (120, 0.376587, 0.807404, 1.084686), (122, 0.365051, 0.802582, 1.084381), (124, 0.355804, 0.79776, 1.084076), (126, 0.346466, 0.792938, 1.083771), (128, 0.337128, 0.788116, 1.083466), (130, 0.327789, 0.783295, 1.081909), (132, 0.318451, 0.778473, 1.07782), (134, 0.309113, 0.773651, 1.07373), (136, 0.299774, 0.768829, 1.069641), (138, 0.290436, 0.764008, 1.065552), (140, 0.287231, 0.759186, 1.061462), (142, 0.280396, 0.754364, 1.057373), (144, 0.27356, 0.749542, 1.053284), (146, 0.266724, 0.74472, 1.049194), (148, 0.259888, 0.739899, 1.045105), (150, 0.253052, 0.735077, 1.041016), ] def _round_half_away(x: float) -> int: """Excel ROUND() rounds half away from zero.""" if x >= 0: return int(math.floor(x + 0.5)) return -int(math.floor(-x + 0.5)) def _mdet3(m: List[List[int]]) -> int: a, b, c = m[0]; d, e, f = m[1]; g, h, i = m[2] return a*(e*i - f*h) - b*(d*i - f*g) + c*(d*h - e*g) def color_management_pwm(r: int, g: int, b: int) -> Tuple[int, int, int]: """RGB (0..255) -> Color Management AP3# (Q15 PWM_LED) via Cramer rule.""" rgb = (r, g, b) F = tuple( math.floor(sum(COLOR_MATRIX[i][j] * rgb[j] for j in range(3)) / L2) for i in range(3) ) bits = max(F[i].bit_length() if F[i] > 0 else 0 for i in range(3)) N = bits - K10 if bits > 16 else 0 O = tuple(F[i] >> N for i in range(3)) R_num = _mdet3([[O[0], TARGET_Y[0], TARGET_Z[0]], [O[1], TARGET_Y[1], TARGET_Z[1]], [O[2], TARGET_Y[2], TARGET_Z[2]]]) S_num = _mdet3([[TARGET_X[0], O[0], TARGET_Z[0]], [TARGET_X[1], O[1], TARGET_Z[1]], [TARGET_X[2], O[2], TARGET_Z[2]]]) T_num = _mdet3([[TARGET_X[0], TARGET_Y[0], O[0]], [TARGET_X[1], TARGET_Y[1], O[1]], [TARGET_X[2], TARGET_Y[2], O[2]]]) R_det = math.floor(R_num) S_det = math.floor(S_num) T_det = math.floor(T_num) V_shift = K4 + K4 # 16 def shr(x, s): if x >= 0: return x >> s return -((-x) >> s) W = (shr(R_det, V_shift), shr(S_det, V_shift), shr(T_det, V_shift)) denom = C44 >> D44 pwm = tuple( math.floor((W[i] >> D44) / denom * L11) if denom != 0 else 0 for i in range(3) ) return pwm def lumin_management(pwm_led: Tuple[int, int, int]) -> Tuple[int, int, int]: """Color Management AP3# -> Input Sheet I/J/K (BH3#).""" L = pwm_led if all(L[i] <= 0 for i in range(3)): return (0, 0, 0) AB = [] for i in range(3): if L[i] <= 0: AB.append(10**18) else: AB.append(math.floor((A11_RATIO_Q15[i] / L[i]) * D1)) AE = min(AB) AF = tuple(math.floor((L[i] * AE + D1) / D1) for i in range(3)) AL = tuple(math.floor((AF[i] * CALIB_LM_SCALED[i] + D1) / D1) for i in range(3)) AO = sum(AL) if AO <= V33: AP = 1 << B15 else: AP = math.floor((V33 << B15) / AO) AX = tuple(math.ceil(AF[i] * AP / C15) for i in range(3)) BH = tuple((AX[i] * L12) >> 15 for i in range(3)) return BH def compute_pwm_no_comp(r: int, g: int, b: int) -> Tuple[int, int, int]: """RGB -> Input Sheet I/J/K (PWM without temp compensation).""" return lumin_management(color_management_pwm(r, g, b)) def _interp_temp(temp_c: float) -> Tuple[float, float, float]: table = TEMP_TABLE if temp_c <= table[0][0]: return (table[0][1], table[0][2], table[0][3]) if temp_c >= table[-1][0]: return (table[-1][1], table[-1][2], table[-1][3]) for i in range(len(table) - 1): t0 = table[i][0]; t1 = table[i+1][0] if t0 <= temp_c <= t1: f = (temp_c - t0) / (t1 - t0) return ( table[i][1] + f * (table[i+1][1] - table[i][1]), table[i][2] + f * (table[i+1][2] - table[i][2]), table[i][3] + f * (table[i+1][3] - table[i][3]), ) return (table[-1][1], table[-1][2], table[-1][3]) def temp_comp_q15(temp_c: float) -> Tuple[int, int, int]: """Q7/R7/S7 = ROUND(k * L11).""" k = _interp_temp(temp_c) return ( _round_half_away(k[0] * L11), _round_half_away(k[1] * L11), _round_half_away(k[2] * L11), ) def apply_temp_compensation(pwm_no_comp: Tuple[int, int, int], temp_c: float = 25.0) -> Tuple[int, int, int]: """Mirrors Input Sheet L3/M3/N3:", m = MIN_i ( q_i * L12 / I_i if I_i > 0 else INF ) if m < 2^15: out_i = MIN(65535, ROUND(m * I_i / q_i)) else: out_i = MIN(65535, ROUND(I_i * 2^15 / q_i)) """ p = pwm_no_comp q = temp_comp_q15(temp_c) INF = 4294967295 ratios = [(q[i] * L12 / p[i]) if p[i] > 0 else INF for i in range(3)] m = min(ratios) out = [] for i in range(3): if q[i] <= 0: out.append(p[i]); continue if m < L11: out.append(min(65535, _round_half_away(m * p[i] / q[i]))) else: out.append(min(65535, _round_half_away(p[i] * L11 / q[i]))) return tuple(out) @dataclass class PwmResult: pwm_no_comp: Tuple[int, int, int] # Input Sheet I/J/K pwm_comp: Tuple[int, int, int] # Input Sheet L/M/N def compute_pwm(r: int, g: int, b: int, temp_c: float = 25.0) -> PwmResult: """RGB (0..255) and ambient temperature (C) -> 16-bit PWM (with and without temp comp).""" no_comp = compute_pwm_no_comp(r, g, b) comp = apply_temp_compensation(no_comp, temp_c) return PwmResult(pwm_no_comp=no_comp, pwm_comp=comp) EXPECTED = [ ((0, 180, 80), (5989, 41414, 2920), (6017, 41294, 2893)), ((0, 255, 0), (6240, 42226, 160), (6270, 42104, 159)), ((0, 0, 255), (2425, 29812, 44360), (2437, 29726, 43944)), ] def self_check(verbose: bool = False) -> bool: ok = True for rgb, exp_nc, exp_c in EXPECTED: res = compute_pwm(*rgb, temp_c=25.0) nc_ok = res.pwm_no_comp == exp_nc c_ok = res.pwm_comp == exp_c ok = ok and nc_ok and c_ok if verbose or not (nc_ok and c_ok): print(f"RGB={rgb}") print(f" no_comp: got={res.pwm_no_comp} exp={exp_nc} {nc_ok}") print(f" comp: got={res.pwm_comp} exp={exp_c} {c_ok}") return ok if __name__ == "__main__": print("self_check:", "PASS" if self_check(verbose=True) else "FAIL")