fix(sensors): illumination sliders are logarithmic, like the quantity they set

The lux slider was linear 0-1000, but an LDR's response (and light
perception) is logarithmic: in the night-light example the LED's whole
on/off behaviour lived below ~20 lux — 2% of the slider's travel — and the
remaining 98% did nothing, reading as "the sensor barely works". The
physics were right (the divider model puts the example's 500-count
threshold at ~20 lux, sensible for a night light); the CONTROL was wrong.

The photoresistor and photodiode lux sliders now run in log position
space: value = min + 10^(p·log10(span+1)) - 1, anchored exactly on min
and max (the +1 shift gives the log axis a zero). Mid-travel lands in the
tens of lux, and the night-light toggles near the middle of the track
instead of at its first pixels. Same mapping on the component's own
on-canvas slider. The model, the examples and the reported lux values are
untouched — only where a given lux sits on the track changed.

Slider controls gain an opt-in `scale: 'log'`; everything else stays
linear.
This commit is contained in:
David Montero Crespo 2026-08-18 22:42:23 +02:00
parent 6bd173bba7
commit 4eb8060dcb
4 changed files with 118 additions and 13 deletions

View File

@ -0,0 +1,64 @@
/**
* Log-scale illumination sliders.
*
* An LDR responds logarithmically, so on the old linear 0-1000 lux slider
* the whole useful range of the night-light example sat in the first ~2% of
* travel (its 500-count threshold crosses at ~20 lux). The slider now runs
* in log position space; these pin the mapping and its round-trip.
*/
import { describe, it, expect } from 'vitest';
import {
LOG_SLIDER_STEPS,
logSliderToValue,
logValueToSlider,
SENSOR_CONTROLS,
} from '../simulation/sensorControlConfig';
describe('logSliderToValue', () => {
it('anchors both ends exactly', () => {
expect(logSliderToValue(0, 0, 1000)).toBe(0);
expect(logSliderToValue(LOG_SLIDER_STEPS, 0, 1000)).toBe(1000);
});
it('gives the low decades real travel', () => {
// ~20 lux (the night-light threshold) must sit well inside the track,
// not at 2% like the linear map put it.
const pos20 = logValueToSlider(20, 0, 1000);
expect(pos20 / LOG_SLIDER_STEPS).toBeGreaterThan(0.3);
expect(pos20 / LOG_SLIDER_STEPS).toBeLessThan(0.6);
// Mid-travel lands in the tens of lux, not at 500.
const mid = logSliderToValue(LOG_SLIDER_STEPS / 2, 0, 1000);
expect(mid).toBeGreaterThan(10);
expect(mid).toBeLessThan(100);
});
it('is monotonic and clamps out-of-range positions', () => {
let prev = -1;
for (let p = 0; p <= LOG_SLIDER_STEPS; p += 50) {
const v = logSliderToValue(p, 0, 1000);
expect(v).toBeGreaterThanOrEqual(prev);
prev = v;
}
expect(logSliderToValue(-10, 0, 1000)).toBe(0);
expect(logSliderToValue(LOG_SLIDER_STEPS + 10, 0, 1000)).toBe(1000);
});
it('round-trips within a step', () => {
for (const v of [0, 1, 4, 20, 100, 500, 1000]) {
const back = logSliderToValue(logValueToSlider(v, 0, 1000), 0, 1000);
// log axis: resolution is relative, so allow 1% of the value (min 1)
expect(Math.abs(back - v)).toBeLessThanOrEqual(Math.max(1, v * 0.01));
}
});
});
describe('config wiring', () => {
it('the illumination sliders are the log ones', () => {
for (const id of ['photoresistor-sensor', 'photodiode']) {
const lux = SENSOR_CONTROLS[id].controls.find(
(c) => c.type === 'slider' && c.key === 'lux',
);
expect(lux && 'scale' in lux && lux.scale).toBe('log');
}
});
});

View File

@ -8,11 +8,7 @@
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
getSensorControl,
type SensorControl,
type SliderControl,
} from '../../simulation/sensorControlConfig';
import { getSensorControl, type SensorControl, type SliderControl, LOG_SLIDER_STEPS, logSliderToValue, logValueToSlider } from '../../simulation/sensorControlConfig';
import { dispatchSensorUpdate, getLastSensorValues } from '../../simulation/SensorUpdateRegistry';
import './SensorControlPanel.css';
@ -115,6 +111,10 @@ export const SensorControlPanel: React.FC<SensorControlPanelProps> = ({
const val = (values[sc.key] as number) ?? sc.defaultValue;
const displayVal = sc.formatValue ? sc.formatValue(val) : String(val);
const isAxisKey = AXIS_KEYS.has(sc.key);
// Log-scale sliders (illumination): the range input runs in POSITION
// space and the value is derived, so the interesting low decades get
// real travel instead of the first few pixels.
const isLog = sc.scale === 'log';
return (
<div key={sc.key} className="sensor-control-row">
@ -124,11 +124,18 @@ export const SensorControlPanel: React.FC<SensorControlPanelProps> = ({
<input
type="range"
className="sensor-slider"
min={sc.min}
max={sc.max}
step={sc.step}
value={val}
onChange={(e) => handleSlider(sc.key, e.target.value)}
min={isLog ? 0 : sc.min}
max={isLog ? LOG_SLIDER_STEPS : sc.max}
step={isLog ? 1 : sc.step}
value={isLog ? logValueToSlider(val, sc.min, sc.max) : val}
onChange={(e) =>
handleSlider(
sc.key,
isLog
? String(logSliderToValue(parseFloat(e.target.value), sc.min, sc.max))
: e.target.value,
)
}
/>
<span className="sensor-value-display">
{displayVal}

View File

@ -3,6 +3,7 @@ import type { AnySimulator } from './PartSimulationRegistry';
import { RP2040Simulator } from '../RP2040Simulator';
import { getADC, setAdcVoltage, emitPropertyChange } from './partUtils';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
import { LOG_SLIDER_STEPS, logSliderToValue } from '../sensorControlConfig';
// ─── Helpers ────────────────────────────────────────────────────────────────
@ -167,9 +168,15 @@ PartSimulationRegistry.register('photoresistor-sensor', {
const volts = (val / 1023.0) * 5.0;
setAdcVoltage(avrSimulator, pinAO, volts);
}
// Mirror to store — maps the slider 0-1023 back to lux 0-1000
// so the SPICE photoresistor handler re-computes its R_ldr.
emitPropertyChange(componentId, 'lux', Math.round((val / 1023) * 1000));
// Mirror to store — the slider position maps to lux LOGARITHMICALLY
// (same curve as the Sensors popover): an LDR's whole useful range
// lives in the low decades, and the linear map crammed it into the
// first few percent of travel.
emitPropertyChange(
componentId,
'lux',
logSliderToValue((val / 1023) * LOG_SLIDER_STEPS, 0, 1000),
);
}
};
element.addEventListener('input', onInput);

View File

@ -18,6 +18,11 @@ export interface SliderControl {
defaultValue: number;
/** Optional custom formatter — e.g. to show "24.0°C" instead of "24" */
formatValue?: (v: number) => string;
/** 'log': the slider POSITION is logarithmic in the value. For quantities
* perceived and sensed logarithmically (illumination on an LDR), a linear
* slider crams all the behaviour into its first few percent — the
* night-light example toggled at 2% of travel. Requires min >= 0. */
scale?: 'log';
}
export interface ButtonControl {
@ -39,6 +44,26 @@ export interface SensorControlDef {
const oneDecimal = (v: number) => v.toFixed(1);
const twoDecimal = (v: number) => v.toFixed(2);
/** Resolution of the position axis for log-scale sliders. */
export const LOG_SLIDER_STEPS = 1000;
/** Log-scale slider: position 0..LOG_SLIDER_STEPS -> value min..max.
* value = min + 10^(p/STEPS * log10(span+1)) - 1, so position 0 lands
* EXACTLY on min (a log axis has no true zero; the +1 shift gives it one)
* and full travel lands exactly on max. */
export function logSliderToValue(pos: number, min: number, max: number): number {
const p = Math.min(Math.max(pos, 0), LOG_SLIDER_STEPS) / LOG_SLIDER_STEPS;
const span = max - min;
return Math.round(min + Math.pow(10, p * Math.log10(span + 1)) - 1);
}
/** Inverse of logSliderToValue — where an existing value sits on the axis. */
export function logValueToSlider(value: number, min: number, max: number): number {
const span = max - min;
const v = Math.min(Math.max(value, min), max) - min;
return Math.round((Math.log10(v + 1) / Math.log10(span + 1)) * LOG_SLIDER_STEPS);
}
// ─── Sensor Control Definitions ──────────────────────────────────────────────
export const SENSOR_CONTROLS: Record<string, SensorControlDef> = {
@ -291,6 +316,7 @@ export const SENSOR_CONTROLS: Record<string, SensorControlDef> = {
step: 1,
unit: 'lux',
defaultValue: 500,
scale: 'log',
},
],
defaultValues: { lux: 500 },
@ -309,6 +335,7 @@ export const SENSOR_CONTROLS: Record<string, SensorControlDef> = {
step: 1,
unit: 'lux',
defaultValue: 500,
scale: 'log',
},
],
defaultValues: { lux: 500 },