Skip to content
Level 3 · AdvancedBuildPart 15 · page 4 of 7180 minSafety level A · Standard home darkroomCraftScience££
180Minutes
13Sources
ASafety level

Safety level A, standard home darkroom. Suitable with ordinary darkroom controls: nitrile gloves, eye protection, a well-ventilated room, dedicated utensils and correct labelling.

Build: The Densitometer Electronics and Firmware

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.

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.

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.

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.

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.

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 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
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

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.

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

5 V, certified USB supply0 Vconst. Idriver or R1green LED, in the headMOS330 Ω2100 kΩRaspberryPi PicoGPIO15 → gateGPIO14/13/12GPIO5 = SCLGPIO4 = SDA3V3 out ↓33.3 V to everything analogueSCL / SDAADS11150x48, ADDRto 0 V4SSD1306optional5OPT101in the head6AIN07screen, one end onlymonitor→ AIN189one ground point, and no other
  1. Lamp branch, on 5 V — constant-current element, green LED, low-side MOSFET — the Part XIV circuit at a twentieth of the current
  2. Gate network — 330 Ω in series from GPIO15, 100 kΩ to ground so the lamp is off whenever the Pico is not driving it
  3. 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
  4. ADS1115 at 0x48 — ADDR to ground gives target address 1001000b. Supplied from 3V3, so no input may exceed 3.6 V
  5. SSD1306 display — optional, same bus, its own address. The firmware runs headless without it
  6. 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
  7. Screened cable — inner to AIN0, screen to ground at the board end only. Grounded at both ends it is a loop, not a screen
  8. Monitor photodiode — in the lamp chamber, to AIN1, so every reading is logged with the lamp beside it
  9. 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
Read it in two halves separated by the 3.3 V pin: above it, current that switches; below it, current that means something. The screened cable and the single ground point are what keep them apart.

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, I2C
i2c = 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 register
raw = 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.

ΔV = FSR ÷ 32 768
What one count is worth on each range

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

overlap: steps 6 to 9full scale, 32 767 counts0.00.51.01.52.02.53.03.505000100001500020000250003000035000DensityCounts on the range in use
  • ±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
Two falling curves on axes of density from 0 to 3.5 against counts from 0 to 35,000. The first, for the coarse plus or minus 2.048 volt range, starts at 21,904 counts at zero density and falls by a factor of ten for every unit of density, reaching 219 counts at 2.0 and 22 counts at 3.0, where one count is worth a fortieth of a wedge step and the reading has become coarse. The second, for the fine plus or minus 0.256 volt range, does not exist below about 0.74 density because the signal there exceeds the range's full scale of 32,767 counts; it enters at the top of the plot at 0.74 and falls along a parallel path eight times higher than the coarse curve, reaching 1,752 counts at 2.0 and 175 counts at 3.0. A shaded vertical band between 0.74 and 1.3 marks the region where both curves exist, which on a 21-step wedge of nominal 0.15 increment is steps 6 to 9, and which is where the ratio between the two ranges is measured rather than assumed. The drawing's point is that neither range covers the instrument's working range alone and that the join between them has to be measured on the instrument itself.
SeriesDensityCounts on the range in use
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D0.0021904.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D0.2512318.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D0.506927.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D0.753896.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D1.002190.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D1.251232.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D1.50693.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D1.75390.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D2.00219.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D2.5069.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D3.0022.00
±2.048 V range: 21 904 counts at air, unusable above about 1.8 D3.507.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D0.7432000.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D1.0017523.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D1.259856.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D1.505541.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D1.753117.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D2.001752.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D2.50554.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D3.00175.00
±0.256 V range: over scale below 0.74 D, 175 counts at 3.0 D3.5055.00
Computed from the reference geometry's 1.369 V air reading and the two ranges' full-scale values; it is arithmetic rather than a measurement of any instrument. What is real in it is the shape and the width of the overlap, and yours will sit wherever your own air reading puts it. The curve is drawn to show the shape, not measured from a real material. Your own materials will differ, and measuring them is what the sensitometry part of the course is for.

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.

Save the listing below to the board as densitometer.py. Then:

import densitometer as d
d.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.

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 a
comment, so a captured terminal session is already a data file.
The converter driver is written out below rather than imported. That is
deliberate: every register write on this page can be found in the ADS1115
datasheet's register map, and no third-party licence enters the instrument.
"""
import machine
import math
import micropython
import sys
import 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-down
PIN_READ = 14 # -> button -> 0 V; internal pull-up, so pressed reads 0
PIN_ZERO = 13 # -> button -> 0 V; held for HOLD_MS to take a new zero
PIN_CAL = 12 # -> button -> 0 V
PIN_SCL = 5 # I2C(0) default on this port
PIN_SDA = 4 # I2C(0) default on this port
ADS_ADDR = 0x48 # ADDR pin to GND: target address 1001000b
OLED_ADDR = 0x3C # read it off your own module; selftest() prints the scan
MUX_SAMPLE = 4 # 100b: AINP = AIN0, AINN = GND
MUX_MONITOR = 5 # 101b: AINP = AIN1, AINN = GND
PGA_COARSE = 2 # 010b: full scale +/- 2.048 V, used from air to about 1.0 D
PGA_FINE = 5 # 101b: full scale +/- 0.256 V, used above about 1.0 D
DR = 4 # 100b: 128 samples per second, the datasheet's default
# --- Settings the calibration experiment replaces with measured ones. -------
SAMPLES = 64 # conversions averaged into one reading
TRIM = 8 # highest and lowest TRIM readings discarded before the mean
WARM_UP_S = 900 # continuous run before the first reading of a session
HOLD_MS = 1000 # the zero button must be held this long
DEBOUNCE_MS = 250 # further edges on any button ignored for this long
SWITCH_COUNTS = 3000 # below this on the coarse range, re-read on the fine one
SATURATED = 32500 # at or above this, the reading is off scale
FLOOR_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 density
DMAX = 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.

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

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.
Each test holds the others fixed, which is the only reason a failure tells you where to go back to. Run them in this order after any rebuild.

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:

σD = σcounts ÷ (N × ln 10)
A spread in counts as a spread in density

σ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.

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
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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.

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

Question 1. Your air reading is 24 000 counts on the plus or minus 2.048 V range and your dark reading is 118 counts. If the dark were never subtracted, what is the highest density the instrument could report, and why is this term unlike stray light?
Show the answer and why

Answer: About 2.31 D, and it is unlike stray light because it can be measured on its own and removed completely

The fraction is 118 divided by 24 000, which is 0.00492, and the ceiling is minus the logarithm of that, 2.31 D. Arithmetically it behaves exactly like stray light, which is why option 1 is tempting and why the two produce the same curved residual. The difference is operational rather than mathematical: switching the lamp off removes every other contribution and leaves the dark term alone, so it can be measured in isolation and subtracted. Stray light arrives only when the lamp is on, mixed with the signal, and cannot be separated that way. That is why the firmware retakes a dark reading before every zero and why no amount of software can remove a leaky head.

Question 2. Which config word starts a single conversion on AIN0 against ground, on the plus or minus 0.256 V range, at 128 samples per second, with the comparator disabled?
Show the answer and why

Answer: 0xCB83

Bit 15 set starts the conversion; MUX 100b selects AIN0 against ground; PGA 101b selects plus or minus 0.256 V; MODE 1 is single-shot; DR 100b is 128 SPS; COMP_QUE 11b disables the comparator. That is 1100 1011 1000 0011, or 0xCB83. Option 1 is the same word on the plus or minus 2.048 V range, which is the only difference between the two: three bits. Option 3 has bit 15 clear, so it configures the device and starts nothing, and the firmware would then poll a bit that never changes. Option 4 leaves COMP_QUE at 00b, which enables the comparator and drives the ALERT pin, which changes nothing about the reading but is a register you did not mean to write.

Question 3. Why does the reference build run the detector, the converter and the bus all from the Pico 3.3 V pin rather than from the 5 V USB rail?
Show the answer and why

Answer: Because a detector on 5 V can swing to about 3.85 V, which exceeds what a converter supplied at 3.3 V may see, and because the Pico GPIO are fixed at 3.3 V

Two datasheet lines close the question. The converter must see no analogue input above its own supply plus 0.3 V, and the OPT101 on a 5 V supply reaches 5 minus 1.15, which is 3.85 V. Meanwhile the Pico GPIO are fixed at 3.3 V with a reverse diode to that rail, so 5 V logic on the bus is a fault rather than an incompatibility. Option 3 is wrong on the facts: the converter is specified from 2.0 to 5.5 V. Option 4 is a rule of thumb rather than a reason, and the useful consequence of the real answer is the coincidence it produces, since a detector on 3.3 V tops out near 2.0 V and the most useful converter range is plus or minus 2.048 V.

Question 4. Your air reading is 1.10 V. Between which two densities can both the plus or minus 2.048 V and the plus or minus 0.256 V ranges be used, so that the ratio between them can be measured?
Show the answer and why

Answer: Between about 0.64 and 1.7 D

The fine range saturates at 0.256 V, so it can only be used once the signal has fallen below that, which from 1.10 V is a density of log of 1.10 divided by 0.25, about 0.64. The coarse range stops being worth having when its counts fall to a few hundred, which at 62.5 microvolts per count is about 20 mV, or a density of about 1.7. Between those two both are on scale, and reading one patch alternately on both is how the trim that stitches the two halves of the scale together is measured rather than assumed. Option 3 forgets that a range has a top as well as a bottom.

Question 5. Which of these is NOT improved by averaging more conversions?
Show the answer and why

Answer: A lamp that is slowly dimming as it warms

Averaging divides random scatter by the square root of the number of readings, and the first, second and fourth options are all random. A drifting lamp is not scatter; it is a trend, and averaging a trend returns its mean rather than removing it. The same applies to the converter integral-nonlinearity sawtooth, which is the same error on every reading of the same code, and to the amplifier offset drift. This is why the instrument carries a monitor photodiode and why the firmware logs it beside every reading: the one error class averaging cannot touch is the one a ratio can.

Sources for this page

13 cited · checked 2026-09-05

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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
  6. 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
  7. 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
  8. 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
  9. 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
  10. 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
  11. 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
  12. 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
  13. 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.