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:
parent
6bd173bba7
commit
4eb8060dcb
|
|
@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
Loading…
Reference in New Issue