Build: The Densitometer Electronics and Firmware
Purpose
Section titled “Purpose”To build the half of the densitometer that turns light into a number you can write down, and to discover — with a meter and a terminal rather than by being told — that the limit on the whole instrument is not the detector. The photodiode is linear over more than nine orders of magnitude. Everything after it is not, and this session finds out by how much.
Three things come out of it. A working chain: detector, 16-bit converter, Pico, display, three buttons, all on a breadboard, all at 3.3 V. Firmware in full, published and runnable, that takes a dark reading and an air zero, averages, computes a density, applies a two-point calibration and logs every reading as comma-separated values. And a measured noise figure in counts at air and at 3.0 D, which is the first honest statement anyone in this course has been able to make about how finely a density can be read.
The optical head is assumed built and commissioned. You should already own a stray-light fraction and the density ceiling it sets, because that ceiling is the number this page’s converter has to be worth buying against.
Learning objectives
Section titled “Learning objectives”By the end of this session you should be able to:
- wire an integrated photodiode-amplifier for single-supply operation from its own pin table, and say what each of the five connections is for;
- explain why a 7.5 mV pedestal that sounds negligible caps the instrument at 2.26 D if it is not subtracted, and compute that number for your own zero;
- start a single conversion on a 16-bit converter by writing one register, and know when it has finished by reading one bit rather than by waiting a guessed interval;
- choose the two full-scale ranges from the density band each has to cover, and compute the wedge steps where they overlap;
- measure the ratio between two gain ranges rather than trusting the datasheet’s figure, and express the residual as a density;
- apply a two-point calibration in the form a commercial instrument uses, and say which of its two points drifts;
- read a firmware listing well enough to change the four constants that are meant to change and to leave the rest alone;
- state the three things averaging cannot fix, and identify which of your own error terms they are.
Prerequisites
Section titled “Prerequisites”The densitometer optical head, built, blackened and commissioned, with tests T1 to T5 recorded. This page reads the head; it does not build one.
Photodiodes, ADCs and measuring light over three decades, in full, and especially the density-per-count equation and the stray-light ceiling. Both are used here and neither is re-derived.
Low-voltage electronics for the darkroom is assumed entirely — Ohm’s law, decoupling, the low-side switch, the datasheet habit — and none of it is repeated here.
The sensitometer’s LED and timer module, because the lamp driver, the MOSFET, the gate resistors and the firmware idiom all come from it unchanged. The densitometer’s lamp is that circuit at a twentieth of the current.
Safety classification
Section titled “Safety classification”Level A, with one step declared at Level B.
The baseline is Level A because the rubric’s four criteria are met by nearly all of the work: no substance more than irritant in home quantities, nothing heated above 50 °C, no mains-voltage construction or modification, and no waste that cannot be collected ordinarily. The session is wiring a breadboard, typing at a terminal and reading a wedge, all from a certified USB supply at 5 V — the voltage HSE’s own advice arrives at, which is to limit the supply to the lowest that will do the job, because 230 V AC can kill.
The Level B step: soldering, if you do any. Under the mixed-level rule a single step may be declared above the session’s baseline provided the page names the step and states its controls where the reader meets them, and that is what happens here. Soldering meets a Level B criterion for two reasons Part XIV established: an iron whose tip runs at about 350 °C, and rosin-based solder flux fume, which HSE’s EH40 lists at 0.05 mg/m³ over eight hours and 0.15 mg/m³ over fifteen minutes with the annotation Sen — capable of causing occupational asthma. The controls for that step, and only that step, are in Hazards and Ventilation below.
And it is a step you can decline. Every component on the list is through-hole and every module comes with pin headers, so the whole instrument works on a solderless breadboard, and a breadboard taped down inside a box measures exactly as well as a soldered board until something is knocked. Where a raised step can be avoided entirely the rubric prefers that it is, and here it can. If you never pick up an iron, this session is Level A throughout.
What is not a hazard here, and why. No photographic chemistry appears on this page at all, so there is no splash risk, no glove-selection problem, no eye-irrigation requirement and no liquid waste stream — worth saying rather than leaving to inference, because the reflex after twelve chemical parts is to import controls that have nothing to hold on to here. Nor is there a shock hazard in the ordinary sense: five volts across dry skin drives a current far below the threshold of sensation. What five volts can do is deliver a great deal of current into a short circuit, which is a heating question rather than a shock one, and it is why the supply is a certified charger or a power bank.
The lamp is not a Level B emitter here either, and the arithmetic was done on the optical head page: the detector needs a few microwatts, so the LED runs at a few milliamps behind an opal diffuser inside a closed head. Substitute a high-power emitter and you have moved your own session to Level B, with Part XIV’s emitter controls.
Hazards
Section titled “Hazards”Burns, from the iron, if you solder. The tip runs at about 350 °C and a joint stays at solder temperature for seconds after the iron leaves it. Iron back in the stand every single time, work held in a vice or helping hands rather than in fingers, and nothing picked up to inspect until it has been put down for a count of ten. The NHS instruction for a burn is cool running water for 20 minutes, as soon as possible; remove clothing and jewellery near the area but nothing stuck to it; cover the cooled injury by laying cling film over it rather than wrapping.
Rosin flux fume, if you solder. The workplace exposure limit above is a workplace limit and your bench is not a workplace, but the substance is the same and it is a respiratory sensitiser, which means the dose that matters is cumulative and the reaction, once acquired, does not go away. Keep your head out of the plume — the plume rises, so the answer is to sit back rather than to lean in — and work at an open window. HSE’s guidance on extraction is worth knowing before you buy anything to fix this: the controls that come before extraction come first, and paragraph 93 of HSG258 records that some bench-mounted fan-and-filter units sold for solder fume are ineffective, and that a supplier should be asked to show the proposed system provides adequate control.
Eye injury from flying wire. Trimming component legs fires offcuts across the room at eye height. Spectacles on, and hold the offcut as you cut.
A short across the supply. A stray strand across the 5 V rail heats a lead faster than you can react. Build with the supply unplugged and check continuity between the rails before power is applied, every time.
Static, at the input. Both are high-impedance semiconductor parts. Touch metal before handling either, and hold them by the body rather than the pins.
The wedge, again. The hazard is to it rather than from it. By the ends, into its sleeve, no solvent.
Required PPE
Section titled “Required PPE”Safety spectacles with side protection, for every leg trimmed and every joint made. This is the one piece of equipment on the page and it is not optional for those two operations.
A soldering stand and a vice or helping hands, protective equipment rather than conveniences: the commonest burn on a bench of this kind comes from holding a hot board.
Nothing else is specified, and the reason is stated rather than left blank. Gloves are not among the controls: there is no chemical to keep off the skin, and a glove between your fingers and a hot iron dulls the feedback that keeps fingers away from the tip. Eye protection is the control that applies, and it applies to the operations named above rather than to the whole session. Wash your hands after handling solder and before eating.
Ventilation
Section titled “Ventilation”Ventilation is not among the controls for the electronics work itself, because nothing on the breadboard route produces a vapour — a Pico, a converter module, a detector and some wire produce nothing at all, and there is nothing to extract.
It is a control for the declared soldering step, and there it is the one that matters. Work at an open window with the plume going away from you rather than past your face, and do not buy a bench fan with a carbon pad and consider the problem solved: HSE names that class of device as often ineffective. If you solder often enough for this to be a real exposure, the honest answer is a properly extracted arm chosen against the supplier’s own evidence — and the cheaper one is to solder less, which the breadboard route lets you do.
Two routes. The integrated route is the course’s reference build and is what the firmware assumes. The discrete route replaces one part with three and buys you the feedback resistor back.
| Part | Quantity | What it does | Substitutes and notes |
|---|---|---|---|
| OPT101 photodiode with integrated transimpedance amplifier, 8-pin DIP | 1 | The detector and its amplifier on one die | The reference part. Photodiode, op-amp and a laser-trimmed 1 MΩ feedback resistor in one package, no analogue layout to get wrong, and a single supply from 2.7 V. Its active area is 2.29 × 2.29 mm |
| ADS1115 breakout module, 16-bit, I²C | 1 | The reading element | Four single-ended channels, a full-scale range programmable from ±6.144 V to ±0.256 V, 16 bits with no missing codes. The reason this page exists: the Pico’s own converter cannot read 3.0 D |
| Raspberry Pi Pico, with headers | 1 | Runs the firmware, drives the lamp, writes the log | A Pico W works and its wireless is unused. Any board with I²C and MicroPython runs the same firmware with the pin numbers changed |
| SSD1306 OLED module, 0.96 in, 128 × 64, I²C | 1 | Shows the density at the bench | Optional. It shares the bus with the converter. The firmware runs headless if it is absent and prints everything the screen shows |
| Momentary push buttons | 3 | Read, zero, calibrate | Any normally-open switch to 0 V; the internal pull-ups do the rest. Three buttons rather than one and a menu, because a menu in a dim room is a mistake waiting |
| Resistor, 330 Ω | 1 | Gate series resistor for the lamp MOSFET | From Part XIV, unchanged |
| Resistor, 100 kΩ | 1 | Gate pull-down; holds the lamp off while the Pico is in reset | From Part XIV. Not optional |
| Logic-level N-channel MOSFET | 1 | Switches the lamp | From Part XIV. At a few milliamps almost anything serves; it is listed because the circuit is unchanged |
| Constant-current LED driver or a series resistor | 1 | Sets the lamp current and holds it | From Part XIV. At these currents the resistor route is more defensible than it was there: the power dissipated is milliwatts |
| Ceramic capacitor, 100 nF | 3 | Decoupling: one at the detector, one at the converter, one at the lamp driver | TI’s own application note asks for decoupling close to the OPT101’s pins where the supply is not of low impedance, and a breadboard supply is not |
| Electrolytic capacitor, 10 µF | 1 | Holds the 3.3 V rail up | Fit it at the converter |
| Screened cable, two-core plus screen, about 300 mm | 1 | Detector to board | Microphone cable, or the lead from a dead pair of headphones. The part people leave out and then spend an evening on |
| Solderless breadboard, 830 points | 1 | The build, and a legitimate final form | Everything here is through-hole for exactly this reason |
| Hook-up wire, 22 AWG solid | a few metres | Wiring | Short: every stray centimetre at the detector is an aerial |
| USB supply or power bank, 1 A or better, and a lead | 1 | Power, at the only voltage this course builds at | A certified supply or a power bank. Never a bare lithium cell, never a bench supply of unknown provenance, never anything mains that has been opened |
If you take the discrete route
Section titled “If you take the discrete route”| Part | Quantity | What it does | Notes |
|---|---|---|---|
| BPW34 silicon PIN photodiode | 1 | The detector | 7.02 mm² of sensitive area against the OPT101’s 5.2 mm², a half angle of 60°, and a dark current of 2 nA typical at a reverse voltage of 10 V — which is a figure for a mode this course does not use |
| Rail-to-rail op-amp, single supply, low input bias current | 1 | The amplifier | The course names no part. The requirements are stated in build stage 3 and the choice is yours to justify from a datasheet |
| Feedback resistor, 1 MΩ to 10 MΩ, metal film | 1 | Sets the transimpedance | Chosen on the bench. Its tolerance goes straight into your scale, so 1 per cent rather than 5 |
| Feedback capacitor, 1 to 10 pF | 1 | Stops the amplifier oscillating | Not optional with a megohm in the loop |
| Tool | For | Notes |
|---|---|---|
| Digital multimeter | Everything, in the order continuity, voltage, current | The object that turns a guess into a measurement |
| Computer with a serial terminal | Loading and running the firmware | Thonny needs no configuration; mpremote also works |
| Temperature-controlled soldering iron and lead-free solder | Only if you take the soldered route | The declared Level B step. About 350 °C, back in the stand every time |
| Side cutters, strippers, tweezers | Preparation and trimming | Spectacles on |
| The optical head, commissioned | Every measurement here | Stray-light fraction and ceiling on its label |
| The 21-step wedge | The overlap measurement and the first density | Serial number into the build record |
| Spreadsheet | The logs | Anything that opens CSV |
Estimated cost
Section titled “Estimated cost”Cost band ££. Three named parts — the Pico, the converter module and the detector — and a handful of passives that together cost less than any one of them. The optional display is the only line you can delete without changing a measurement.
This page quotes no prices, because it cannot verify a current one and because the laboratory planner carries no electronics line at all. The planner records what it could not price rather than guessing, and the electronics for Parts XIV to XVII are among the gaps it names. What belongs on the page instead is the specification, which outlasts any quotation: a converter of at least 16 bits with a programmable gain reaching ±0.256 V, and a detector whose datasheet gives a responsivity and an offset.
The one purchase that can change the total is the same as everywhere in this part — the calibrated wedge, which the planner also could not price, and which is the instrument’s ruler rather than one of its parts.
Estimated consumables cost
Section titled “Estimated consumables cost”Almost nothing in this session is consumed. Every part is capital: it goes into an instrument meant to be recalibrated rather than replaced, and the two most expensive components sit in sockets or headers so that they survive a rebuild. What one run actually uses up is a few grams of alloy and a little solvent, and only on the soldered route.
| Consumed | This session | Sourced price | Cost this session |
|---|---|---|---|
| Lead-free solder, 0.7 mm | about 3 g, or none on the breadboard route | None. The planner carries no electronics line | — |
| Heat-shrink sleeving | about 150 mm, for the screened cable’s ends | None. The planner carries no electronics line | — |
| Isopropanol, for flux residue | a few millilitres, or none | None. The planner prices isopropanol only in the darkroom quantities of Part II | — |
| Stripboard | one 50 × 80 mm piece, or none | None. The planner carries no electronics line | — |
| Hook-up wire | about 1 m | None. The planner carries no electronics line | — |
No row here carries a dated price, so no total is given and none should be inferred. Nothing on the list is free. A second run — a spare module, or a rebuild after a mistake — consumes the same five lines and nothing else, because every component is reusable.
Equipment stays out of the table deliberately: the multimeter, the iron, the wedge and the head are not consumed by a session, and a page that quietly counted them would stop measuring the thing the consumables calculator needs.
Eight stages, about three hours. Stage 0 is not optional and stage 5 is the one that decides whether your densities are continuous across the range.
Stage 0 — The six numbers, and the one nobody can give you (20 minutes)
Section titled “Stage 0 — The six numbers, and the one nobody can give you (20 minutes)”Open the datasheets for the two parts you actually bought and write these in the build record before a single wire goes in.
Detector. Responsivity, and at what wavelength. Output offset with no light. Offset drift with temperature. Output ceiling relative to the supply. For an OPT101 those are 0.45 V/µW at 650 nm through its internal 1 MΩ resistor, an offset of 5 to 10 mV with 7.5 mV typical, a drift of ±10 µV/°C, and an output that cannot rise above the supply minus 1.15 to 1.3 V.
Converter. Resolution, the full-scale ranges available, the input-referred noise on each, and the matching between ranges. For an ADS1115 those are 16 bits with no missing codes, ranges from ±6.144 V to ±0.256 V, an input-referred noise of exactly one least-significant bit on every range — 62.5 µV on ±2.048 V and 7.81 µV on ±0.256 V — and a gain match between any two ranges of 0.02 per cent typical and 0.1 per cent maximum.
Stage 1 — Power, and why the whole instrument runs at 3.3 V (20 minutes)
Section titled “Stage 1 — Power, and why the whole instrument runs at 3.3 V (20 minutes)”Everything analogue on this board runs from the Pico’s own 3.3 V output, not from the 5 V USB rail, and the reason is a chain of three datasheet lines that pin the design down completely.
The converter must not see an analogue input above its own supply plus 0.3 V. The Pico’s GPIO are fixed at 3.3 V and carry a reverse diode to that rail, so a 5 V I²C line is a fault rather than a compatibility question. And an OPT101 on a 5 V supply can swing to 5 − 1.15 = 3.85 V, which is over the limit for a converter running at 3.3 V.
Put all three on 3.3 V and the numbers stop fighting. The detector’s ceiling becomes about 3.3 − 1.3 = 2.0 V; the converter’s most useful range is ±2.048 V; and those two are within a couple of per cent of each other, which is as close to a designed coincidence as a parts bin gets. The Pico’s 3.3 V pin will supply this comfortably — its datasheet asks only that the external load stay below 300 mA, and the whole analogue side draws under a milliamp.
The lamp stays on the 5 V rail with its own driver and its own decoupling, exactly as Part XIV built it, and it meets the rest of the circuit at one ground point and nowhere else.
The complete densitometer module: detector, converter, Pico, display, buttons and lamp
- Lamp branch, on 5 V — constant-current element, green LED, low-side MOSFET — the Part XIV circuit at a twentieth of the current
- Gate network — 330 Ω in series from GPIO15, 100 kΩ to ground so the lamp is off whenever the Pico is not driving it
- Pico — firmware, three buttons on GPIO14, 13 and 12, I²C on GPIO5 (SCL) and GPIO4 (SDA) — the default assignment for I2C(0) on this port
- ADS1115 at 0x48 — ADDR to ground gives target address 1001000b. Supplied from 3V3, so no input may exceed 3.6 V
- SSD1306 display — optional, same bus, its own address. The firmware runs headless without it
- OPT101 in the head — pin 1 to 3V3, pins 3 and 8 to ground, pin 4 to pin 5 for the internal 1 MΩ, pin 2 unconnected, 100 nF at the pins
- Screened cable — inner to AIN0, screen to ground at the board end only. Grounded at both ends it is a loop, not a screen
- Monitor photodiode — in the lamp chamber, to AIN1, so every reading is logged with the lamp beside it
- The single ground point — lamp current and signal current share exactly one junction; a second one is a millivolt of error in a millivolt signal
Stage 2 — The converter on the bus, and the first raw counts (25 minutes)
Section titled “Stage 2 — The converter on the bus, and the first raw counts (25 minutes)”Wire the module’s VDD to 3.3 V, GND to ground, SCL to GPIO5, SDA to GPIO4 and ADDR to ground, which selects the target address 1001000b — 0x48. Put the 100 nF and the 10 µF across its supply pins.
Then, at the REPL, before any detector exists:
from machine import Pin, I2Ci2c = I2C(0) # the default assignment: scl=Pin(5), sda=Pin(4)print([hex(a) for a in i2c.scan()])You want ['0x48'], or ['0x3c', '0x48'] once the display is on. Nothing found means power, ground,
the two bus lines or the pull-up resistors, in that order; a module with pull-ups fitted is the usual
case and a bare chip without them is the usual fault.
Now read one conversion by hand, so that the driver in the firmware is something you have already done rather than something you are trusting:
i2c.writeto(0x48, b'\x01\xc5\x83') # config: OS=1, MUX=100b (AIN0/GND), # PGA=010b (±2.048 V), MODE=1 (single-shot), # DR=100b (128 SPS), COMP_QUE=11b (off)i2c.writeto(0x48, b'\x00') # point at the conversion registerraw = i2c.readfrom(0x48, 2)print((raw[0] << 8) | raw[1])With AIN0 floating that number means nothing, and that is the point: it should still be a number, and it should change when you touch the input. If it is stuck at 0 or at 32767 the bus is talking and the converter is not converting, which is a different fault from silence.
Stage 3 — The detector, the dark reading, and the pedestal that caps the instrument (25 minutes)
Section titled “Stage 3 — The detector, the dark reading, and the pedestal that caps the instrument (25 minutes)”The OPT101 needs five connections and no components. Pin 1 to 3.3 V with the 100 nF right at the pin. Pin 3, the most negative supply, and pin 8, the photodiode’s anode, both to ground. Pin 4 joined to pin 5, which is what brings the internal 1 MΩ feedback resistor into the loop. Pin 2 left unconnected. Pin 5 is the output and goes down the screened cable to AIN0.
Cover it completely, run one conversion on the ±2.048 V range, and look at what you get. It will not be zero. The datasheet says why: the output is the photocurrent times the feedback resistor plus a pedestal of about 7.5 mV, deliberately introduced so that a single-supply amplifier stays in its linear region when there is no light at all.
FSR is the full-scale range in volts and 32 768 is the positive half of a 16-bit two’s-complement result. On ±2.048 V that is 62.5 µV per count; on ±0.256 V it is 7.8125 µV.
So the pedestal is 7.5 mV ÷ 62.5 µV = 120 counts on the coarse range, and 960 counts on the fine one. Now do the arithmetic that matters.
Take the dark reading now, on both ranges, sixty-four conversions each, and write the mean and the spread in the build record. Then switch the lamp on with nothing on the stage and adjust the LED current until the coarse-range reading sits near 22 000 counts. That current is now part of the instrument and goes on the label beside the aperture and the gap.
Stage 4 — Analogue hygiene, which is most of what separates a good chain from a bad one (20 minutes)
Section titled “Stage 4 — Analogue hygiene, which is most of what separates a good chain from a bad one (20 minutes)”The output of the detector is a high-impedance node carrying, at the top of the range, about a millivolt. Four disciplines, in the order they matter.
Short leads at the detector, and a screen for the run. The detector lives in the head and the converter lives on the board, so there is a cable between them and it is an aerial unless it is screened. Use two-core screened cable, inner for the output and the other core for the local ground, and connect the screen to ground at the board end only. Grounded at both ends it is not a screen but a loop, and it will pick up whatever magnetic field your bench has.
Decoupling at every device, not at the supply. A 100 nF ceramic across the supply pins of the detector, another at the converter, another at the lamp driver. TI’s own application notes ask for decoupling close to the OPT101’s pins where the supply is not of low impedance, and a breadboard fed through a metre of USB cable is the definition of a supply that is not.
One ground point. The lamp current is milliamps switching on and off; the signal is microvolts. If they share a length of breadboard rail, the lamp’s current develops a voltage across that rail’s resistance and it adds to your signal. Bring the lamp’s ground return and the analogue ground to the same single junction and nowhere else.
Keep the supply out of the measurement. A USB supply is a switching supply and its output is not quiet. Most of what it does is common to dark and signal and cancels in the subtraction; what does not cancel is anything that modulates the lamp, because that is multiplicative. This is what the monitor photodiode is for, and why the firmware logs it beside every reading.
Stage 5 — Two ranges, and measuring the ratio between them (25 minutes)
Section titled “Stage 5 — Two ranges, and measuring the ratio between them (25 minutes)”One range cannot cover three decades. Gain switching is how the instrument covers them anyway. The ±2.048 V range gives 21 904 counts at air and 22 counts at 3.0 D, which is a density resolution of 0.020 per count — a seventh of a wedge step, from a converter that cost pocket money and ought to do better. The fix is the programmable gain: read the dense end on ±0.256 V, where the same light gives 175 counts and the resolution is 0.0025 per count.
Where to change over is arithmetic, not preference. The fine range saturates at 0.256 V, so it can only be used below about 0.25 V, which from a 1.369 V zero is above 0.74 D. The coarse range stops being worth having when its counts fall below a few hundred, which is around 1.8 D. Anywhere between those two, both ranges work — and that overlap is not an inconvenience, it is the measurement that stitches the scale together.
| Density | Volts at the detector | Counts on ±2.048 V | Counts on ±0.256 V | ΔD per count, best range |
|---|---|---|---|---|
| 0.0 (air) | 1.369 | 21 904 | over range | 0.00002 |
| 0.5 | 0.4329 | 6 927 | over range | 0.00006 |
| 1.0 | 0.1369 | 2 190 | 17 523 | 0.000025 |
| 1.5 | 0.04329 | 693 | 5 541 | 0.00008 |
| 2.0 | 0.01369 | 219 | 1 752 | 0.00025 |
| 2.5 | 0.004329 | 69 | 554 | 0.0008 |
| 3.0 | 0.001369 | 22 | 175 | 0.0025 |
| 3.5 | 0.0004329 | 7 | 55 | 0.0079 |
On a T2115 with its nominal 0.15 increment from 0.05, the overlap band 0.74 to 1.30 D is steps 6 to 9. Those four steps are where you measure the range ratio.
The datasheet says the two ranges match to 0.02 per cent typical and 0.1 per cent maximum, which is
log₁₀(1.001) = 0.0004 D at worst — forty times finer than a good commercial instrument’s stated
repeatability. So why measure it? Because the datasheet’s figure is about the converter and your
instrument is not only a converter. A wrongly entered full-scale constant, a module with a different
part fitted, a supply sagging when the sensitive range is selected: all three show up here and none of
them shows up anywhere else. range_ratio() in the firmware reads one step alternately on both ranges
ten times and prints the trim and the density step it corresponds to.
Two ranges, and the band where both are true
- ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D
- ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D
Show the numbers behind this plot
| Series | Density | Counts on the range in use |
|---|---|---|
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 0.00 | 21904.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 0.25 | 12318.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 0.50 | 6927.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 0.75 | 3896.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 1.00 | 2190.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 1.25 | 1232.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 1.50 | 693.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 1.75 | 390.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 2.00 | 219.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 2.50 | 69.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 3.00 | 22.00 |
| ±2.048 V range: 21 904 counts at air, unusable above about 1.8 D | 3.50 | 7.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 0.74 | 32000.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 1.00 | 17523.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 1.25 | 9856.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 1.50 | 5541.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 1.75 | 3117.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 2.00 | 1752.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 2.50 | 554.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 3.00 | 175.00 |
| ±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D | 3.50 | 55.00 |
The alternative, and why the course does not take it. You could extend the range by turning the LED up for the dense steps instead of changing the converter’s gain. It works, and it is worse, for a reason the sensitometer already taught: an LED’s output and its dominant wavelength both move with junction temperature, so a lamp at two currents is two slightly different lamps with two different spectra. A gain change is a pure number in a digital register; a current change is a thermal transient. Measure the ratio of two gains once and it stays measured.
Stage 6 — The firmware (30 minutes)
Section titled “Stage 6 — The firmware (30 minutes)”Save the listing below to the board as densitometer.py. Then:
import densitometer as dd.selftest()selftest scans the bus, reports what it found, takes a dark reading on both ranges, switches the lamp
on, checks that the air reading is on scale, and prints the density ceiling your unsubtracted dark would
have imposed. Do not go further until it passes.
The firmware in full
Section titled “The firmware in full”This is the whole of it, published under the course’s content licence and tested on MicroPython v1.24.1
for the RP2 port. Six constants near the top are meant to be changed — SAMPLES, WARM_UP_S,
RANGE_TRIM, SLOPE, DMAX and the pin numbers — and the rest is not. The values in the listing are
provisional starting points rather than measurements, and the
calibration experiment replaces four of
them with your own.
The converter driver is written out rather than imported. That is a deliberate choice with two
reasons. Every register write below can be found in the datasheet’s own register map, so nothing in the
instrument is magic; and no third-party licence enters a course that publishes its firmware under a
share-alike licence. The one component the course does not publish is the display driver, which the
firmware imports inside a try and does without when it is absent.
"""densitometer.py - Pure Silver, Part XV: densitometer electronics and firmware.
Target Raspberry Pi Pico (RP2040), MicroPython v1.24.1 for RPI_PICO.Install copy to the board as densitometer.py, then at the REPL: import densitometer as d d.selftest()Licence CC BY-SA 4.0, as the rest of this course's content.
Everything printed is comma-separated, with '#' beginning any line that is acomment, so a captured terminal session is already a data file.
The converter driver is written out below rather than imported. That isdeliberate: every register write on this page can be found in the ADS1115datasheet's register map, and no third-party licence enters the instrument."""
import machineimport mathimport micropythonimport sysimport time
micropython.alloc_emergency_exception_buf(100)
VERSION = "1.0"
# --- Wiring. Rewiring the board means changing these six numbers only. ------PIN_LAMP = 15 # -> 330 R -> MOSFET gate; 100k gate-to-source pull-downPIN_READ = 14 # -> button -> 0 V; internal pull-up, so pressed reads 0PIN_ZERO = 13 # -> button -> 0 V; held for HOLD_MS to take a new zeroPIN_CAL = 12 # -> button -> 0 VPIN_SCL = 5 # I2C(0) default on this portPIN_SDA = 4 # I2C(0) default on this port
ADS_ADDR = 0x48 # ADDR pin to GND: target address 1001000bOLED_ADDR = 0x3C # read it off your own module; selftest() prints the scan
MUX_SAMPLE = 4 # 100b: AINP = AIN0, AINN = GNDMUX_MONITOR = 5 # 101b: AINP = AIN1, AINN = GND
PGA_COARSE = 2 # 010b: full scale +/- 2.048 V, used from air to about 1.0 DPGA_FINE = 5 # 101b: full scale +/- 0.256 V, used above about 1.0 DDR = 4 # 100b: 128 samples per second, the datasheet's default
# --- Settings the calibration experiment replaces with measured ones. -------SAMPLES = 64 # conversions averaged into one readingTRIM = 8 # highest and lowest TRIM readings discarded before the meanWARM_UP_S = 900 # continuous run before the first reading of a sessionHOLD_MS = 1000 # the zero button must be held this longDEBOUNCE_MS = 250 # further edges on any button ignored for this longSWITCH_COUNTS = 3000 # below this on the coarse range, re-read on the fine oneSATURATED = 32500 # at or above this, the reading is off scaleFLOOR_MARGIN = 0.10 # a density this close to DMAX is reported as at the floor
# Measured on your own instrument and written back here. RANGE_TRIM corrects# the ratio between the two full-scale ranges; 1.0 means "not yet measured".RANGE_TRIM = [1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0, 1.0]SLOPE = 1.0 # two-point calibration: measured density / raw densityDMAX = 3.0 # the stray-light ceiling measured on the optical head page
COLUMNS = ("n,kind,ticks_ms,fsr_v,counts,counts_sd,dark,volts," "monitor,density,flag")
lamp = machine.Pin(PIN_LAMP, machine.Pin.OUT, value=0)btn_read = machine.Pin(PIN_READ, machine.Pin.IN, machine.Pin.PULL_UP)btn_zero = machine.Pin(PIN_ZERO, machine.Pin.IN, machine.Pin.PULL_UP)btn_cal = machine.Pin(PIN_CAL, machine.Pin.IN, machine.Pin.PULL_UP)i2c = machine.I2C(0, scl=machine.Pin(PIN_SCL), sda=machine.Pin(PIN_SDA), freq=100_000)try: # "LED" on current firmware, 25 on old heartbeat = machine.Pin("LED", machine.Pin.OUT, value=0)except (TypeError, ValueError): heartbeat = machine.Pin(25, machine.Pin.OUT, value=0)
_count = 0_pending = None_edge_at = 0_zero_v = None # air reading, volts_dark_v = [None] * 8 # dark reading per PGA code, volts_oled = None
class ADS1115: """Just enough of the converter to read one channel on one range.
Four registers reached through an address pointer, of which this needs two: pointer 00b is the conversion result and 01b is the configuration. A single-shot conversion is started by writing the config word with OS set and MODE set, and it is finished when OS reads back as 1. """
FSR = (6.144, 4.096, 2.048, 1.024, 0.512, 0.256, 0.256, 0.256) RATE = (8, 16, 32, 64, 128, 250, 475, 860)
def __init__(self, bus, addr=ADS_ADDR): self.bus = bus self.addr = addr self._out = bytearray(3)
def _write_config(self, value): self._out[0] = 0x01 self._out[1] = (value >> 8) & 0xFF self._out[2] = value & 0xFF self.bus.writeto(self.addr, self._out)
def _read16(self, pointer): self.bus.writeto(self.addr, bytes((pointer,))) raw = self.bus.readfrom(self.addr, 2) return (raw[0] << 8) | raw[1]
def convert(self, mux, pga, rate=DR): """One single-shot conversion. Returns signed counts, -32768 to 32767.""" cfg = ((1 << 15) | ((mux & 7) << 12) | ((pga & 7) << 9) | (1 << 8) | ((rate & 7) << 5) | 0x0003) self._write_config(cfg) deadline = time.ticks_add(time.ticks_ms(), 5 + 3000 // ADS1115.RATE[rate]) while not self._read16(0x01) & 0x8000: if time.ticks_diff(deadline, time.ticks_ms()) < 0: raise OSError("ADS1115: conversion did not finish") raw = self._read16(0x00) return raw - 65536 if raw > 32767 else raw
ads = ADS1115(i2c)
def mean(xs): return sum(xs) / len(xs)
def sd(xs): """Sample standard deviation. Returns 0.0 for fewer than two values.""" n = len(xs) if n < 2: return 0.0 m = mean(xs) return math.sqrt(sum((x - m) * (x - m) for x in xs) / (n - 1))
def volts(counts_value, pga): """Counts to volts, with the measured correction for this range applied.""" return counts_value * (ADS1115.FSR[pga] / 32768.0) * RANGE_TRIM[pga]
def off(): """Everything dark. Call this if anything goes wrong.""" lamp.value(0) heartbeat.value(0)
def _on_edge(pin): """Soft-IRQ handler for all three buttons. Debounced in software.""" global _pending, _edge_at now = time.ticks_ms() if time.ticks_diff(now, _edge_at) < DEBOUNCE_MS: return _edge_at = now _pending = pin
for _b in (btn_read, btn_zero, btn_cal): _b.irq(trigger=machine.Pin.IRQ_FALLING, handler=_on_edge)
def counts(mux, pga, n=SAMPLES): """Trimmed mean and spread of n conversions, in counts.
The extremes are discarded before the mean because a single mains-borne spike on a high-impedance node is not a sample of the light; it is an event. Averaging it in spreads it over every reading, and dropping it does not. """ xs = sorted(ads.convert(mux, pga) for _ in range(n)) keep = xs[TRIM:len(xs) - TRIM] if len(xs) > 2 * TRIM + 1 else xs return mean(keep), sd(keep), len(keep)
def take_dark(): """Lamp off, both ranges. Every reading is corrected against these.""" lamp.value(0) time.sleep_ms(200) for pga in (PGA_COARSE, PGA_FINE): m, s, _ = counts(MUX_SAMPLE, pga) _dark_v[pga] = volts(m, pga) print("# dark, FSR {:.3f} V: {:.1f} counts +/- {:.1f} = {:.6f} V".format( ADS1115.FSR[pga], m, s, _dark_v[pga])) return _dark_v[PGA_COARSE], _dark_v[PGA_FINE]
def raw(mux=MUX_SAMPLE): """One auto-ranged reading. Returns (volts, pga, counts, sd, flag).
The coarse range is tried first. When it returns too few counts to be worth having, the same light is re-read on the fine range, which is eight times more sensitive and eight times sooner saturated. """ pga = PGA_COARSE m, s, _ = counts(mux, pga) flag = "" if m >= SATURATED: flag = "SAT" elif m < SWITCH_COUNTS: pga = PGA_FINE m, s, _ = counts(mux, pga) if m >= SATURATED: pga = PGA_COARSE m, s, _ = counts(mux, pga) v = volts(m, pga) if _dark_v[pga] is not None: v -= _dark_v[pga] return v, pga, m, s, flag
def zero(): """Lamp on, nothing on the stage. Take the dark reading, then the air.""" global _zero_v take_dark() lamp.value(1) time.sleep_ms(500) v, pga, m, s, flag = raw() _zero_v = v print("# zero: {:.1f} counts +/- {:.1f} on FSR {:.3f} V = {:.6f} V".format( m, s, ADS1115.FSR[pga], v)) if flag == "SAT": print("# FAIL: saturated on air. Reduce the LED current.") elif m < 8000: print("# WARN: air is low on the coarse range; raise the LED current.") return _zero_v
def read(mux=MUX_SAMPLE): """One density reading. Returns (density, volts, pga, counts, sd, flag).""" if _zero_v is None: raise ValueError("zero() has not been taken this session") v, pga, m, s, flag = raw(mux) if v <= 0.0: return None, v, pga, m, s, "DARK" d = SLOPE * math.log10(_zero_v / v) if d < -0.005: flag = flag or "NEG" elif d > DMAX - FLOOR_MARGIN: flag = flag or "FLOOR" return d, v, pga, m, s, flag
def _log(kind, record): global _count d, v, pga, m, s, flag = record _count += 1 mon = volts(ads.convert(MUX_MONITOR, PGA_COARSE), PGA_COARSE) print("{},{},{},{:.3f},{:.1f},{:.2f},{:.6f},{:.6f},{:.4f},{},{}".format( _count, kind, time.ticks_ms(), ADS1115.FSR[pga], m, s, _dark_v[pga] if _dark_v[pga] is not None else 0.0, v, mon, "{:.4f}".format(d) if d is not None else "", flag)) return d
def calibrate(d_ref): """Two-point calibration: the zero is one point, this step is the other.
Put the calibrated step on the stage, pass its certificate density, and the slope that makes the instrument agree with it is computed and printed. Write the printed SLOPE back into this file; nothing is stored on the board. """ global SLOPE SLOPE = 1.0 d, v, pga, m, s, flag = read() if d is None or d <= 0.0: print("# FAIL: the calibration step reads no signal.") return None SLOPE = d_ref / d print("# raw density {:.4f}, reference {:.4f}, SLOPE = {:.5f}".format( d, d_ref, SLOPE)) if abs(SLOPE - 1.0) > 0.10: print("# WARN: a correction above 10 per cent is a fault, not a slope.") return SLOPE
def range_ratio(n=10): """Measure the ratio between the two ranges on one patch in the overlap.
Put a step that reads between about 0.8 and 1.3 D on the stage: it gives enough counts on the fine range to be precise and enough on the coarse range to be real. The printed trim goes into RANGE_TRIM[PGA_FINE]. """ coarse = [] fine = [] for _ in range(n): c, _s, _k = counts(MUX_SAMPLE, PGA_COARSE) f, _s, _k = counts(MUX_SAMPLE, PGA_FINE) if f >= SATURATED: print("# FAIL: fine range saturated. Use a denser step.") return None coarse.append(c * ADS1115.FSR[PGA_COARSE] / 32768.0 - (_dark_v[PGA_COARSE] or 0.0)) fine.append(f * ADS1115.FSR[PGA_FINE] / 32768.0 - (_dark_v[PGA_FINE] or 0.0)) trim = mean(coarse) / mean(fine) print("# coarse {:.6f} V, fine {:.6f} V, RANGE_TRIM[{}] = {:.5f}".format( mean(coarse), mean(fine), PGA_FINE, trim)) print("# that is a step of {:.5f} D between the ranges".format( abs(math.log10(trim)))) return trim
def repeat(n=10, lift=False): """n readings of one patch, with the statistics at the end.
With lift False the sample is not touched and the spread is the electronics. With lift True the firmware waits for the read button between readings, so you lift the arm and replace the sample, and the spread is the mechanics. """ global _pending print("# repeat: {} readings, lift = {}".format(n, lift)) print(COLUMNS) ds = [] for i in range(n): if lift: print("# replace the sample and press READ") _pending = None while _pending is not btn_read: time.sleep_ms(20) _pending = None d = _log("repeat", read()) if d is not None: ds.append(d) if len(ds) > 1: print("# density mean {:.4f} sd {:.4f} spread {:.4f} over {} readings" .format(mean(ds), sd(ds), max(ds) - min(ds), len(ds))) return ds
def drift(minutes=30, interval_s=120): """Read one patch every interval_s from switch-on. The warm-up measurement.
The zero is taken once, at the start, and never retaken: retaking it would remove exactly the drift this is measuring. """ print("# drift: {} min, one reading every {} s, zero taken once".format( minutes, interval_s)) lamp.value(1) zero() print("elapsed_s," + COLUMNS) start = time.ticks_ms() try: while True: elapsed = time.ticks_diff(time.ticks_ms(), start) // 1000 print("{},".format(elapsed), end="") _log("drift", read()) if elapsed >= minutes * 60: break time.sleep(interval_s) finally: off() print("# drift complete")
def series(steps=21): """One reading per wedge step, prompted by the read button. The linearity run.""" global _pending print("# series: {} steps, press READ at each".format(steps)) print("step," + COLUMNS) out = [] for n in range(1, steps + 1): print("# step {} on the stage, then press READ".format(n)) _pending = None while _pending is not btn_read: time.sleep_ms(20) _pending = None print("{},".format(n), end="") out.append(_log("series", read())) return out
def _show(text_lines): """Put up to four short lines on the display, if there is one.""" if _oled is None: return _oled.fill(0) for row, line in enumerate(text_lines[:4]): _oled.text(line, 0, row * 12) _oled.show()
def _attach_display(): """Optional. The course publishes no display driver and ships none.
ssd1306 is a separate module you install on the board yourself. If the import fails the instrument runs headless, and loses nothing but convenience: every value the screen shows is on the serial line too. """ global _oled try: import ssd1306 _oled = ssd1306.SSD1306_I2C(128, 64, i2c, addr=OLED_ADDR) _show(["Pure Silver", "densitometer", "v" + VERSION]) except (ImportError, OSError, ValueError) as exc: _oled = None print("# no display ({}); running headless".format(exc))
def header(): print("# Pure Silver densitometer firmware v{}".format(VERSION)) print("# {}".format(sys.implementation)) print("# SLOPE {:.5f}, RANGE_TRIM[fine] {:.5f}, DMAX {:.2f}".format( SLOPE, RANGE_TRIM[PGA_FINE], DMAX)) print("# {} conversions averaged, {} discarded at each end".format( SAMPLES, TRIM))
def selftest(): """Prove the wiring before anything is measured. Nothing runs for long.""" header() off() found = i2c.scan() print("# I2C devices: {}".format([hex(a) for a in found])) if ADS_ADDR not in found: print("# FAIL: no converter at {}. Check SDA, SCL, power and ADDR." .format(hex(ADS_ADDR))) return False _attach_display() print("# buttons read {} {} {} (1 = released)".format( btn_read.value(), btn_zero.value(), btn_cal.value())) dark_coarse, dark_fine = take_dark() lamp.value(1) time.sleep_ms(500) lit, s, _ = counts(MUX_SAMPLE, PGA_COARSE) off() print("# air {:.1f} counts +/- {:.1f} on the coarse range".format(lit, s)) ok = True if lit >= SATURATED: print("# FAIL: saturated on air. Reduce the LED current.") ok = False elif lit < 8000: print("# FAIL: air is under a quarter of full scale.") print("# Raise the LED current, or look for a blocked light path.") ok = False ped = dark_coarse / (volts(lit, PGA_COARSE) or 1.0) print("# dark is {:.3f} per cent of air; unsubtracted it would cap the".format( 100.0 * ped)) print("# instrument at {:.2f} D".format(-math.log10(ped) if ped > 0 else 0.0)) if ok: print("# PASS: converter found, lamp switches, detector on scale.") return ok
def session(warm_s=WARM_UP_S): """Warm up, take a zero, then one reading per button press. Runs until stopped.
READ takes and logs one reading. ZERO held for a second takes a new dark and a new air zero. CAL re-reads the calibration step and reports the check against SLOPE. """ global _pending header() _attach_display() lamp.value(1) if warm_s: print("# warming up for {} s".format(warm_s)) _show(["warming up", "{} s".format(warm_s)]) time.sleep(warm_s) zero() print(COLUMNS) _pending = None try: while True: pin = _pending if pin is None: time.sleep_ms(20) continue _pending = None if pin is btn_read: heartbeat.value(1) record = read() _log("read", record) d, v, pga, m, s, flag = record _show(["D {:.3f}".format(d) if d is not None else "D ---", "{:.0f} counts".format(m), "FSR {:.3f} V".format(ADS1115.FSR[pga]), flag or "ok"]) heartbeat.value(0) elif pin is btn_zero: held = 0 while btn_zero.value() == 0 and held < HOLD_MS: time.sleep_ms(20) held += 20 if held >= HOLD_MS: _show(["re-zeroing"]) zero() else: print("# zero not taken: hold the button for {} ms".format( HOLD_MS)) elif pin is btn_cal: d = _log("cal-check", read()) if d is not None: _show(["cal check", "D {:.3f}".format(d), "against your", "certificate"]) finally: off()Five things in that listing repay a second reading.
convert polls rather than sleeps. It writes the config word, then reads the config register back
until bit 15 says the conversion is done, with a deadline computed from the data rate so a dead bus
raises an error instead of hanging. Change the data rate and the code is still correct, which a
sleep_ms(10) would not be.
counts takes a trimmed mean. It sorts the sixty-four conversions and discards the highest eight
and the lowest eight before averaging. A mains-borne spike on a high-impedance node is not a sample of
the light; it is an event, and averaging it in spreads it across every reading while dropping it does
not. What survives the trim is what averaging was designed for.
Everything is carried in volts, not counts. volts() converts with the range’s own full-scale
constant and the measured RANGE_TRIM, so a reading taken on one range and a zero taken on the other
are directly comparable and the density arithmetic never has to know which range it is on. The stitching
happens in one line, once.
take_dark reads both ranges every time. It costs about a second and it removes the one term that
would otherwise cap the instrument at 2.26 D. zero() calls it before every air reading; the lamp is
off for that second and the small cooling it causes is inside the noise of a lamp that has been running
for fifteen minutes.
drift deliberately does not re-zero. Every other routine re-zeroes freely, because re-zeroing
removes multiplicative error. drift is measuring exactly that error, so re-zeroing inside it would
report a beautifully stable instrument and tell you nothing.
Stage 7 — The display and the three buttons (15 minutes)
Section titled “Stage 7 — The display and the three buttons (15 minutes)”Three buttons, one job each, from GPIO14, 13 and 12 to ground; the internal pull-ups do the rest, so a released button reads 1 and a pressed one 0.
READ takes one reading and logs it. ZERO must be held for a second before it does anything, which is the whole of the user-interface design: an accidental brush against the zero button in a dim room would silently rescale every subsequent reading, and a deliberate one-second hold cannot happen by accident. CAL re-reads the calibration step and prints what it gets, so that the check a commercial instrument asks you to make weekly is one button away.
The display shows four lines: the density, the raw counts, the full-scale range in use, and a flag. The
flags are the honest half of the interface. SAT means the converter is at saturation, the top of its range, and the
number is not a measurement. FLOOR means the density has come within 0.10 of the stray-light ceiling
you measured on the head, so the instrument is reporting its own floor rather than your negative. NEG
means the density came out below zero, which is a fault and not a sample — most often a zero taken
through film base. DARK means the signal after dark subtraction was not positive at all.
The display is optional and the firmware treats it that way. _attach_display imports ssd1306
inside a try and runs headless if it is absent. The course publishes no display driver and has not
verified the licence terms of any third-party one from a source that meets its
sourcing standard, so it is named as a dependency you install rather than as code the course
ships. Nothing is lost by going without: the serial line carries everything the screen does, and can be
pasted into a notebook.
Stage 8 — The log, and getting it into a spreadsheet (10 minutes)
Section titled “Stage 8 — The log, and getting it into a spreadsheet (10 minutes)”Every routine prints one header line beginning with # and then comma-separated rows. Capture the
terminal to a file, delete nothing, and open it in a spreadsheet with # set as the comment character —
or leave the comments in, because they carry the dark readings, the zero and the calibration slope that
the rows were taken under.
| Column | What it is | Why it is in the log rather than derived later |
|---|---|---|
n |
Reading number this session | So a row can be pointed at in the notebook |
kind |
read, repeat, series, drift, cal-check |
The routine that produced it, so runs do not get mixed |
ticks_ms |
Milliseconds since the board started | Differences are valid; absolute values are not |
fsr_v |
The full-scale range in use | Which range a reading came from is part of the reading |
counts, counts_sd |
Trimmed mean and spread of the conversions | The spread is the noise measurement. Discard it and the uncertainty work later has no Type A term |
dark |
The dark reading subtracted, in volts | So a reading can be un-corrected if the dark turns out to have been wrong |
volts |
Signal after dark subtraction | The quantity the density is actually computed from |
monitor |
The monitor photodiode, at the same moment | The lamp, logged beside every reading, so drift can be separated from everything else afterwards |
density |
The computed density | With the slope in the header, so it can be recomputed |
flag |
Blank, SAT, FLOOR, NEG or DARK |
A flagged row is not a measurement and must not be silently averaged in |
The course’s own curve tool does not exist yet, and this page will not pretend otherwise: there is no published schema for it and no page that loads a file. What is published is this column set, which is what the sensitometry work in the rest of the course will read, and the rule that goes with it — keep the raw counts. A density can always be recomputed from counts, a zero and a slope. Counts cannot be recovered from a density.
Testing and calibration
Section titled “Testing and calibration”Six tests. The first five commission the chain; the sixth is the one that produces a number nobody in this course has been able to state before.
Commissioning the signal chain, in the order that isolates faults
- E1 — The bus answersi2c.scan() returns the converter, and a hand-written config word produces a changing number. Proves wiring and protocol before anything optical is involved.
- E2 — The dark reading, on both rangesSixty-four conversions, lamp off, head closed. Record mean and spread on each range and compute the density ceiling this term alone would impose.
- E3 — Air on scaleLamp on, nothing on the stage, LED current set for about 22,000 counts on the coarse range. Write the current on the label.
- E4 — The hand testTwenty readings, then twenty more with a hand near the cable. If they differ, stop and fix the screening before going on.
- E5 — The range ratioOne step in the overlap band, read alternately on both ranges ten times. Compute the trim and express it as a density.
- E6 — Noise at air and at 3.0 DThe measurement this page exists to produce: the spread in counts at each end of the range, converted to density, and compared with what the arithmetic predicted.
E6 — The noise figure, and what it is allowed to claim
Section titled “E6 — The noise figure, and what it is allowed to claim”Take a hundred readings of air without touching anything, and a hundred of your densest usable step. Record the standard deviation of each in counts, then convert to density with the equation from the last lesson:
σcounts is the spread of the readings, N is the mean count they were taken at, and σD is the corresponding density spread. Note that N is in the denominator: the same spread in counts is worth a hundred times more density at 2.0 D than at air.
What should you expect? On the fine range the converter’s own input-referred noise is 7.81 µV, and at 3.0 D the signal is 1.369 mV, so a single conversion carries about 0.0025 D of converter noise. Averaging forty-eight surviving conversions should divide that by about seven, to 0.0004 D. If your measured spread is near that figure, the converter is the limit and there is nothing left to gain there. If it is five or ten times larger, something else is in the way, and the hand test, the screen and the single ground point are where to look — in that order.
What is happening physically at each stage
Section titled “What is happening physically at each stage”It is worth walking the chain once as physics rather than as parts, because every fault in the rest of this part is a fault at one of these six places.
Photons to charge. A photon absorbed in the depletion region of the silicon produces an electron-hole pair, and the junction’s field separates them before they recombine. The current is proportional to the arrival rate of photons, which is why the detector is linear over more than nine orders and why nothing else in the instrument is.
Charge to current, at nearly zero volts. The amplifier holds its inverting input at the same potential as its non-inverting one, so the photodiode works into an effective load of the feedback resistance divided by the open-loop gain — Hamamatsu’s point, and several orders of magnitude smaller than the resistor itself. The diode therefore never develops a voltage across itself, which is what keeps it in the linear short-circuit régime instead of the logarithmic open-circuit one.
Current to voltage. One resistor, one multiplication. A picoamp through a megohm is a microvolt; at the top of the range about 1.4 microamps through the same megohm is 1.4 volts.
Voltage to counts. A successive-approximation converter compares the input against a reference in sixteen halvings. The reference is internal and low-drift, which is exactly what the Pico’s converter lacks and why it is not doing this job.
Counts to a ratio. Two numbers divided. Everything common to both — the lamp’s brightness, the detector’s responsivity, the resistor’s exact value, the reference voltage — cancels. This is why a densitometer can be built from parts with 5 per cent tolerances and still read to three decimal places, and it is the single most important idea in the instrument.
A ratio to a logarithm. One function call, and the reason the numbers behave: multiplying transmittances becomes adding densities, which is what makes a wedge of equal steps a straight line and a stray-light floor a bend.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Most likely cause | What to do |
|---|---|---|
i2c.scan() returns an empty list |
Power, ground, SDA and SCL swapped, or no pull-up resistors on the bus | Check 3.3 V at the module’s own pins with the meter. Most breakout modules carry pull-ups; a bare chip does not, and needs about 4.7 kΩ to 3.3 V on each line |
| The scan finds the display but not the converter | ADDR floating. The pin is sampled continuously and a floating input is undefined | Tie ADDR to ground for 0x48. Never leave it unconnected |
OSError: conversion did not finish |
The device answers writes but never sets the OS bit — usually a counterfeit or wrongly marked part, or a supply below 2.0 V | Meter the supply first. Then read the config register back after writing it: if it does not read what you wrote, the part is not an ADS1115 |
| Densities come out negative on clear film | The zero was taken on air and the sample carries film base, or the zero was taken before the lamp had warmed | Decide which zero you mean and be consistent. Air zero plus a base of 0.10 gives every reading 0.10 too high, not negative; a falling lamp gives negatives. Re-zero and see which one moves |
| Every density is a little high, and the amount grows through the session | The zero is stale and the lamp is drifting down | Re-zero. If the error vanishes it was multiplicative, which is the whole diagnostic method of the break/fix page |
| Readings jump when your hand approaches the cable | Capacitive coupling into a high-impedance node | Stage 4, in order: shorten, screen, decouple, one ground point. Do not proceed until the hand test is clean |
Saturated at air, flag SAT |
LED current too high, or the coarse range was not selected | Reduce the current until air sits near 22 000 counts. Air is not supposed to use the whole range; the headroom is for a warming lamp |
| Densities near the top of the range all read about the same | You have reached the stray-light ceiling and the instrument is reporting its own floor | Nothing is broken. Re-run the head page’s T2, and either reduce the floor or exclude those steps and say so |
| The fine range and the coarse range disagree by more than 0.01 D on the same step | RANGE_TRIM has not been measured, or the supply sags when the sensitive range is selected |
Run range_ratio(). If the trim is more than a per cent from 1.000, meter the 3.3 V rail while it runs |
| Counts wander slowly with nothing changing | Thermal: the OPT101’s offset drifts at ±10 µV/°C and the head warms | Retake the dark. If the wander survives dark subtraction, it is the lamp, and the monitor column will show it |
| Everything works on the bench and fails in the head | The cable, every time — a cracked joint, an unscreened run, or a screen grounded at both ends | Wiggle the cable while reading. A reading that responds to a wiggle is a connection, not a measurement |
Questions
Section titled “Questions”- Your air reading is 18 400 counts on the ±2.048 V range and your dark reading is 131 counts. Compute the density ceiling the dark reading alone would impose if it were not subtracted, and state in one sentence why this term, unlike stray light, can be removed completely.
- You have set the LED so that air reads 30 000 counts, reasoning that more signal is better. Give two independent reasons this is worse than 22 000, one about the lamp and one about the converter.
- Working from the config register’s field table, write the sixteen-bit word that starts a single conversion on AIN1 against ground, on the ±0.512 V range, at 8 samples per second, with the comparator disabled. Give it in hexadecimal.
- Your
range_ratio()reports a trim of 1.0093. Express that as a density step, say whether it is large or small against a commercial instrument’s stated repeatability, and give one cause that would make it large enough to worry about. - A reader proposes dropping the monitor photodiode, on the grounds that the dark subtraction already removes the lamp’s contribution. Explain why dark subtraction cannot touch a lamp that is drifting, and name the one arithmetic operation that can.
- At 2.0 D your hundred readings have a standard deviation of 4.1 counts about a mean of 1 752 on the fine range. Convert that to a density, compare it with the 0.0004 D the converter’s own noise predicts after averaging, and give two candidate explanations for the difference with a test for each.
Further experiments
Section titled “Further experiments”Put the Pico’s own converter beside the ADS1115. Wire the detector output to GPIO26 as well and read the same wedge on both in one session, then plot both against the wedge’s nominal steps. The two agree to about 1.5 D and then the Pico’s trace falls apart into a staircase, because there are four counts left to make a density out of. It is the last lesson’s arithmetic as a picture, on your own bench, in twenty minutes.
Measure the averaging law instead of believing it. Take the spread of one reading with SAMPLES set
to 1, then 4, 16, 64 and 256, on the same step, and plot spread against the square root of the number
averaged. It should be a straight line through the origin — until it is not, and where it stops falling
is where a systematic term takes over from the random one. That knee is worth more than any number in
this page, because it tells you when to stop spending time on averaging.
Find out what the OPT101’s output stage does near zero. The datasheet records that when the output comes within about 50 mV of the negative supply the output stage powers down, the bandwidth falls, and the noise drops from about 300 µV RMS to about 100. Your dark reading sits at 7.5 mV, which is inside that region, and your signal at 3.0 D is 1.4 mV, which is also inside it. Measure the spread at a series of light levels straddling 50 mV and see whether you can find the transition. If you can, you have discovered something about your instrument that its designers documented and nobody in this course had measured.
Build the discrete route as a second channel. A BPW34 and an op-amp reading the same light through the same aperture, converted by two channels of the same ADS1115. Every difference between the traces is something the integrated part decided for you.
Check your understanding
Sources for this page
13 cited · checked 2026-09-05
- 01OPT101 monolithic photodiode and single-supply transimpedance amplifier, data sheet SBBS002Texas Instruments Incorporated§ Section 5, pin functions - VS on pin 1, the op-amp inverting input on pin 2 left unconnected for the internal feedback path, the most negative supply on pin 3 tied to common for single-supply working, the 1 megohm feedback network on pin 4 joined to the output on pin 5, and the photodiode anode on pin 8 tied to ground. Section 6.1, features - a single supply from 2.7 to 36 V. Section 6.5, electrical characteristics at 25 C, taken at RL = 10 kilohms - responsivity 0.45 A/W and 0.45 V per microwatt at 650 nm with a temperature coefficient of 100 ppm/C and a unit-to-unit variation of plus or minus 5 per cent; output offset voltage 5 to 10 mV with 7.5 mV typical, drifting plus or minus 10 microvolts per degree C; nonlinearity plus or minus 0.01 per cent of full scale, specified at a full-scale output of 24 V; internal feedback resistor 1 megohm trimmed to plus or minus 0.5 per cent; bandwidth 14 kHz; output voltage high limited to the supply minus 1.3 to 1.15 V. Section 6.6, photodiode characteristics - active area 2.29 by 2.29 mm. Section 8.1, overview - the output is the photocurrent times the feedback resistor plus a pedestal of approximately 7.5 mV introduced for single-supply operation, so the output is 7.5 mV with no light. Section 8.3.3, noise performance - the output stage powers down when the output is within about 50 mV of the negative supply, reducing the bandwidth and with it the noise, from a nominal 300 microvolts RMS to about 100. Section 9.1 - decoupling capacitors close to the device pins where the supply is not of low impedanceti.com/lit/ds/symlink/opt101.pdftier 1, primary2026-09-05
- 02ADS111x ultra-small, low-power, I2C-compatible, 860-SPS, 16-bit ADCs with internal reference, oscillator and programmable comparator, data sheet SBAS444Texas Instruments Incorporated, 2024§ Section 7.5.1.1 and Table 7-2, I2C address selection - the ADDR pin tied to GND gives the target address 1001000b. Section 8.1, register map - four registers reached through an address pointer whose low two bits select them, 00b the conversion register and 01b the config register. Section 8.1.2 - the conversion result in 16-bit two's complement. Section 8.1.3 and Table 8-3, the config register - bit 15 OS, which starts a single conversion when written as 1 and reads back as 1 when the device is not converting; MUX[2:0], of which 100b is AIN0 against ground and 101b is AIN1 against ground; PGA[2:0] setting the full-scale range, 010b for plus or minus 2.048 V and 101b for plus or minus 0.256 V; MODE, 1b for single-shot; DR[2:0], 100b for the default 128 samples per second; and COMP_QUE[1:0] set to 11b to disable the comparator. Section 5.3, recommended operating conditions - supply 2.0 to 5.5 V and no analogue input above the supply plus 0.3 V. Section 5.5 - 16 bits with no missing codes, integral nonlinearity 1 LSB, single-ended offset error plus or minus 3 LSB, offset drift 0.005 LSB per degree C, and gain match between any two gain settings of 0.02 per cent typical and 0.1 per cent maximum. Section 6.1, noise performance - input-referred noise of one least-significant bit on every range, 62.5 microvolts RMS on plus or minus 2.048 V and 7.81 microvolts on plus or minus 0.256 V, with effective and noise-free resolution both a full 16 bits at every data rate up to 128 SPSti.com/lit/ds/symlink/ads1115.pdftier 1, primary2026-09-05
- 03Quick reference for the RP2, MicroPython documentationDamien P. George, Paul Sokolovsky and contributors§ Hardware I2C bus - machine.I2C(0) takes the default assignment for the Pico of scl on Pin 5 and sda on Pin 4, with the same read and write methods as the software bus, and i2c.scan() returning the addresses that answer; Pins and GPIO - the internal pull-up on an inputdocs.micropython.org/en/latest/rp2/quickref.htmltier 1, primary2026-09-05
- 04Raspberry Pi Pico Datasheet: An RP2040-based microcontroller boardRaspberry Pi Ltd§ Section 2.3 and the pin descriptions - the 3V3 pin is an output whose external load is to be kept below 300 mA, and VBUS is the 5 V from the USB connector; section 4.2, general purpose IO - the GPIO are powered from the on-board 3.3 V rail and are fixed at 3.3 V, with a reverse diode to that rail limiting the voltage that may be applied to a pindatasheets.raspberrypi.com/pico/pico-datasheet.pdftier 1, primary2026-09-05
- 05Si photodiodes, technical note KSPD9001EHamamatsu Photonics K.K., Solid State Division§ Section 2-1 - an op amp connected to the photodiode presents an equivalent load of the feedback resistance divided by the open-loop gain, several orders of magnitude smaller, which is what allows the short-circuit current to be measured; section 2-2, linearity - the photocurrent is linear over more than nine orders of magnitude for incident power between 10 to the minus 12 and 10 to the minus 2 watts, with the lower limit set by the noise equivalent power and the upper by the load and series resistances, and the linearity degrading as the series resistance riseshamamatsu.com/content/dam/hamamatsu-photonics/sites/documents/99_SALES_LIBRARY/ssd/si_pd_kspd9001e.pdftier 1, primary2026-09-05
- 06BPW 34 silicon PIN photodiode, data sheet version 1.5ams-OSRAM AG, 2020§ Characteristics at 25 C - radiant sensitive area 7.02 square millimetres, half angle 60 degrees, wavelength of maximum sensitivity 920 nm, spectral range 420 to 1120 nm, dark current 2 nA typical and 30 nA maximum at a reverse voltage of 10 V; Maximum Ratings - reverse voltage 32 Vlook.ams-osram.com/m/65d547088a09187c/original/BPW-34.pdftier 1, primary2026-09-05
- 07X-Rite 361T Desktop Transmission Densitometer, product brochure L11-010X-Rite, Incorporated§ Specification table - repeatability plus or minus 0.01 D and linearity plus or minus 0.02 D from 0.0 to 5.0 D on the Ortho and Visual responses, zero stability plus or minus 0.02 D per eight hours, and a two-minute warm-up; taken here as the benchmark the finished chain is measured againstxrite.com/-/media/xrite/files/literature/l11/l11-000_l11-099/l11-010_361t_product_brochure/l11-010_361t_en.pdftier 1, primary2026-09-05
- 08X-Rite 361T Transmission Densitometer, operation manual, part number 361T-500X-Rite, Incorporated§ Chapter four, calibration - the two-point procedure in which the zero, called Calibration Low, is taken with all film removed and the scale, called Calibration High, is taken on the marked cal step of a five-step transmission reference; the check tolerance of 0.02 D on that step; and the statement introducing the Quick CAL procedure that the zero is the major factor of drift over a period of timexrite.com/-/media/xrite/files/manuals_and_userguides/3/361t-500_361t_densitometer_operation_manual_en.pdftier 1, primary2026-09-05
- 09Transmission Step WedgesStouffer Industries, doing business as Stouffer Graphic Arts§ Product table - the T2115, 21 steps at a nominal 0.15 increment from about 0.05 to a maximum density of 3.05; and the note that only the T2120CC and T1530CC are calibrated, against NIST Standard Reference Material 38120Cstouffer.net/TransPage.htmtier 1, primary2026-09-05
- 10EH40/2005 Workplace exposure limits, containing the list of workplace exposure limits for use with COSHHHealth and Safety Executive, 2005§ Table 1 - rosin-based solder flux fume, CAS 8050-09-7, 0.05 mg/m3 over eight hours and 0.15 mg/m3 over fifteen minutes, annotated Sen; Annotations - Sen means capable of causing occupational asthmahse.gov.uk/pubns/priced/eh40.pdftier 1, primary2026-09-05
- 11Controlling airborne contaminants at work: A guide to local exhaust ventilation (LEV), HSG258Health and Safety Executive, 2011§ The order of controls, in which elimination and substitution come before extraction; and paragraph 93, that some bench-mounted fan and filter units commonly used for solder fume control are ineffective and that a supplier should be asked to show the proposed system provides adequate controlhse.gov.uk/pubns/priced/hsg258.pdftier 1, primary2026-09-05
- 12Electrical safety and you: A brief guide, INDG231(rev1)Health and Safety Executive, 2012§ Reducing the risk, Reduce the voltage - limit the supply voltage to the lowest needed to get the job done; What are the hazards - normal mains voltage, 230 volts AC, can killhse.gov.uk/pubns/indg231.pdftier 1, primary2026-09-05
- 13Burns and scalds: TreatmentNational Health Service, 2026§ Treatment - cool running water for 20 minutes as soon as possible, remove clothing and jewellery near the burn but nothing stuck to it, and cover with cling film laid over rather than wrappednhs.uk/conditions/burns-and-scalds/treatmenttier 1, primary2026-09-05
Formulas, hazard statements, historical dates and process descriptions on this page were checked against the sources above on the date shown. Safety data changes: obtain the current safety data sheet for the product you actually buy before you open it.