1. Problem Definition: Reject Position Drift on a 36000 BPH PET Line
A PET bottling line running at 36000 bottles/hour produces one bottle every 100 ms (3,600,000 ms per hour divided by 36,000 = 100 ms per bottle). The label inspection and reject system must:
- Track each bottle's position via an encoder (10 pulses/revolution)
- Build a bit-shift register in OB1 that follows the bottle from the inspection point to the reject point
- Actuate a reject cylinder exactly when the bottle is in front of the rejecter
Reported symptom: the rejecter sometimes pushes a bottle that is "a little behind" or "a little late" of the expected position. The pulse count between bottles is not constant because the gap between two encoder pulses is "bigger than required" and the "conveyor speed also varies."
Fundamental timing constraint at 36000 BPH:
- 1 bottle = 100 ms window
- CPU 317-2 PN/DP (6ES7317-2EK14-0AB0) typical OB1 scan = 3 ms (as reported)
- Encoder disc with N pulses per bottle yields pulse period = 100 ms / N
| Pulses per bottle (N) | Pulse period (ms) | Sampling margin vs 3 ms OB1 |
|---|---|---|
| 4 | 25.0 | Plenty of margin |
| 10 (10 ppr encoder) | 10.0 | Adequate if edges always visible |
| 16 (16-tooth sprocket) | 6.25 | Marginal - jitter sensitive |
| 32 | 3.125 | Critical - 1 OB1 cycle per pulse |
If a pulse high time drops below the OB1 cycle plus the SM 321 input filter (~3 ms default), the discrete input is read only on alternate OB1 passes. A 6.25 ms pulse period sampled at 3 ms means roughly half of the OB1 cycles will see the pulse high and half will not, depending on phase relationship. This is the root cause of the rejection drift.
2. System Architecture and Hardware Part Numbers
| Module | MLFB | Function |
|---|---|---|
| CPU 317-2 PN/DP | 6ES7317-2EK14-0AB0 | CPU with PN/PN and DP master/slave; bit instruction 0.05 µs, typical OB1 cycle 1–5 ms |
| SM 321 DI16 (standard) | 6ES7321-1BH02-0AA0 | 16 DI at 24 V DC, default 3 ms input filter, no HW interrupt |
| SM 321 DI16 (interrupt-capable) | 6ES7321-7BH01-0AB0 | 16 DI at 24 V DC, 0.1 ms / 3 ms / 15 ms filter, HW interrupt on rising/falling, 3 fast counters |
| SM 322 DO16 | 6ES7322-1BH01-0AA0 | 16 DO at 24 V DC / 0.5 A |
| FM 350-1 counter | 6ES7350-1AH03-0AB0 | 1 channel, 500 kHz, 32-bit, comparator output |
| FM 350-2 counter | 6ES7350-2AH01-0AE0 | 8 channels, 20 kHz each, 32-bit |
Reference: SIMATIC S7-300 CPU 31xC and CPU 31x Operating Instructions (Siemens support entry ID 12996906), and the S7-300 Module Data Reference Manual (8859629).
3. Timing Analysis: Why Pulses Are Lost
Apply sampling theory to discrete inputs in OB1:
- Sampling rate: fsample = 1 / TOB1
- Signal rate: fsignal = 1 / Tpulse
- A pulse is detected when its high time Thigh ≥ TOB1 + Tinput_filter
For the user's application:
- TOB1 ≈ 3 ms (measured)
- Thigh ≈ Tlow = Tpulse / 2 (50% duty cycle, square wave)
- For N = 16 teeth: Thigh = 3.125 ms, Tlow = 3.125 ms
- SM 321 input filter default 3 ms: Thigh barely exceeds the filter; jitter at the input may suppress the pulse entirely on some scans.
4. Solution A: OB40 Hardware Interrupt with Timestamp (Software-Only on Interrupt-Capable DI)
SIMATIC S7-300 supports hardware interrupts on digital inputs when the input is configured to OB40. Each event records:
- OB40_POINT_ADDR: byte/bit address of the triggering input
- OB40_TIMESTAMP: 100 ns units timestamp of the interrupt (CPU 31x-2 PN/DP V3.3+)
OB40 runs in microseconds, decoupled from OB1, capturing the pulse edge deterministically. The bit-shift register pointer is incremented inside OB40, eliminating OB1 jitter.
4.1 Configuration in STEP 7 / TIA Portal
- Open the SM 321 DI properties in HW Config (TIA: Device view > DI module > Properties)
- Enable "Hardware Interrupt" on the encoder input channel
- Assign OB40 to the rising-edge trigger (select "Rising edge" to avoid double-counting on falling edge)
- Set input filter to 0.1 ms (or smallest supported)
- Download hardware configuration to the CPU
4.2 Sample OB40 in STL
OB40 // Hardware interrupt OB - runs on encoder rising edge
L #OB40_TIMESTAMP // 100 ns ticks, DWORD
T "db_edge_time".time // store as last edge time
L #OB40_TIMESTAMP
L "db_edge_time".prev
-D
T "db_edge_period" // period in 100 ns units
L "db_edge_time".time
T "db_edge_time".prev // shift for next call
// Increment bit-shift register write pointer (circular)
L "bsr_write_ptr"
L 1
+I
T "bsr_write_ptr"
L "bsr_max_len"
MOD
T "bsr_write_ptr"
SET
= "bsr_data".bit["bsr_write_ptr"] // mark bottle present at write position
CLR
= "bsr_data".bit["bsr_write_ptr"] // (illustration only - DB bool array access syntax varies by tool)
OB40 timestamp resolution: 100 ns on CPU 317-2 PN/DP (firmware V3.3+). Use SFC64 "TIME_TCK" to correlate OB40 timestamps to wall clock and to read system time for logging.
Reference: SIMATIC S7-300/400 System Software, System and Standard Functions Reference Manual, OB40 and SFC64 chapters (44240604).
5. Solution B: FM 350-1 Counter Module with SFB47 "COUNT"
The FM 350-1 (6ES7350-1AH03-0AB0) provides a dedicated counter independent of OB1:
| Parameter | Value |
|---|---|
| Channels | 1 |
| Max frequency | 500 kHz |
| Count width | 32-bit signed |
| Count modes | Continuous, single, periodic |
| Gate control | HW gate, SW gate, or both |
| Comparator | 1 with HW output and HW interrupt |
| Z (index) input | Yes, used for latch/reset |
| Signal type | 24 V single-ended or 5 V RS422 differential |
5.1 Configuration in HW Config
- Insert FM 350-1 in slot 4 (right of CPU, before signal modules)
- Set input type to 24 V single-ended (for typical PNP encoder) or 5 V differential (for RS422)
- Select "Count continuously" mode
- Enable SW gate (CPU controls counting start/stop)
- Enable HW interrupt on comparator match and on zero/overflow
- Assign OB40 to comparator interrupt
- Download configuration
5.2 Wiring
- Encoder A → FM 350-1 pin 1 (signal A)
- Encoder 24 V → FM 350-1 pin 3 (encoder supply)
- Encoder GND → FM 350-1 pin 4 (M)
- Encoder shield → FM 350-1 pin 5 (functional earth)
5.3 SFB47 "COUNT" Parameters
| Parameter | Direction | Type | Meaning |
|---|---|---|---|
| LADDR | INPUT | WORD | I/O start address of FM 350-1 (e.g., 256 / 100h) |
| CHANNEL | INPUT | BOOL | 0 = channel 1, 1 = channel 2 (FM 350-2 only) |
| SW_GATE | INPUT | BOOL | 0 = closed, 1 = open |
| CTRL_DO | INPUT | BOOL | Enable comparator output |
| SET_DO | INPUT | BOOL | Force comparator output state |
| JOB_REQ | INPUT | BOOL | 1 = trigger JOB |
| JOB_ID | INPUT | INT | Job code (0 = no job, 1 = read, 2 = write, 14 = read/write all) |
| JOB_VAL | INPUT | DINT | Job value |
| STS_GATE | OUTPUT | BOOL | Gate state |
| STS_DO | OUTPUT | BOOL | Comparator output state |
| STS_C_DN | OUTPUT | BOOL | Count direction (1 = down) |
| COUNTVAL | OUTPUT | DINT | Current 32-bit count value |
5.4 Sample STL: Start the Counter and Read the Value
// First scan: configure FM 350-1, leave gate closed
AN "m_first_scan_done"
S "m_count_run"
R "m_first_scan"
CALL "COUNT" , "DB_COUNT"
LADDR :=256 // FM 350-1 base address 100h
CHANNEL :=0
SW_GATE :="m_count_run"
CTRL_DO :=
SET_DO :=
JOB_REQ :=0
JOB_ID :=0
JOB_VAL :=
STS_GATE:=
STS_DO :=
STS_C_DN:=
COUNTVAL:="md_count_val"
S "m_first_scan_done"
// Each scan: read current count
CALL "COUNT" , "DB_COUNT"
LADDR :=256
CHANNEL :=0
SW_GATE :="m_count_run"
COUNTVAL:="md_count_val"
Reference: SIMATIC FM 350-1 Counter Module Function Manual, Siemens support entry 1404537.
6. Solution C: Pure Software Pulse Period Measurement with SFC64 TIME_TCK
When hardware changes are not permitted, measure the time between two rising edges using SFC64 "TIME_TCK" in OB1. The CPU 317-2 PN/DP does not timestamp discrete inputs natively, so the technique reads the system clock each time a pulse-edge is observed in OB1 and subtracts the previous reading.
SFC64 "TIME_TCK" returns the CPU system time as a DWORD. When the operand passed is type DWORD, the resolution is 10 ms; when the operand is type INT, the resolution is 1 ms. Use the INT variant for finer resolution.
// SFC64 call to read system time in 1 ms units
CALL "TIME_TCK" // SFC64
RET_VAL := "mw_clock_ms" // WORD, 1 ms units, wraps at 32767 ms
// Edge detection for encoder input I 0.0 (example)
A "i_encoder"
FP "m_encoder_fp" // one-shot on rising edge
JC _meas
_meas: L "mw_clock_ms"
T "mw_clock_ms_prev" // save for next cycle
L "mw_clock_ms"
L "mw_clock_ms_prev"
-I
T "mw_period_ms" // period in ms
// Exponential moving average: avg = (avg * 7 + period) / 8
L "md_avg_period"
L 7
*D
L "mw_period_ms"
+D
L 8
/D
T "md_avg_period"
Accuracy is bounded by the OB1 cycle: a pulse edge can be observed 0 to TOB1 (3 ms) after it occurs. For pulses whose high time is ≥ 3 ms, this technique yields a usable average within ±1 pulse. Missed pulses appear as period jumps of 2× expected; a 50% guard band on the average handles this:
// Reject distance in pulses
L "md_reject_distance_mm" // physical distance inspection to reject
L "md_avg_period" // average ms per pulse
*D
L "md_conveyor_speed_mmps" // mm/s conveyor speed
/D
T "mw_reject_pulses" // integer number of pulses
7. Bit-Shift Register with Adaptive Reject Distance
The original bit-shift register uses a fixed reject distance (constant index offset from write pointer to read pointer). When the conveyor speeds up or slows down, the pulses-per-bottle ratio changes and the fixed offset drifts relative to the actual bottle position.
7.1 Adaptive Algorithm
- Maintain
bsr_write_ptrupdated by OB40 (Solution A) or by FP-edge in OB1 (Solution C). - Maintain
bsr_read_ptr = bsr_write_ptr - reject_distance_pulses(modbsr_max_len). - Recalculate
reject_distance_pulseseach scan from the running average period and the known mechanical distance between the inspection sensor and the rejecter. - Set reject output when bit at
bsr_read_ptr= 1 (bottle is at the rejecter). - Clear the bit at
bsr_write_ptrimmediately after writing if a single-pulse window is needed.
7.2 Minimum Stroke Time
// Minimum stroke stretch
A "bsr_data".bit["bsr_read_ptr"]
= "m_reject_raw"
A "m_reject_raw"
L "t_stroke_time" // 80 ms
SD "t_stretch_reject"
A "t_stretch_reject"
= "q_reject_cylinder"
8. Solution D: Proximity Sensor on a 16-Tooth Sprocket (Hardware Augmentation)
The most cost-effective mechanical fix is to add a proximity sensor (or photoelectric sensor) detecting teeth on a sprocket coupled to the conveyor. With 16 teeth at 36000 BPH:
- Pulse period = 100 ms / 16 = 6.25 ms
- Pulse high time ≈ 3.1 ms (50% duty) — marginal with a 3 ms input filter but adequate with a 0.1 ms filter
- Position resolution = 22.5 mechanical degrees per pulse
This raises the pulse rate above the OB1 sampling threshold (after filter adjustment) and gives finer position resolution. Pair this with Solution A (OB40) for full immunity to OB1 jitter.
9. Comparison of All Solutions
| Solution | Hardware change | OB1 sensitivity | Max pulse rate | Cost | Complexity |
|---|---|---|---|---|---|
| A. OB40 + interrupt-capable DI | Swap SM 321 to 6ES7321-7BH01-0AB0 | None (sub-ms) | ~1 kHz (limited by 0.1 ms filter) | Low | Low |
| B. FM 350-1 counter | Add FM module + slot | None | 500 kHz | Medium | Medium |
| C. SFC64 in OB1 | None | High (bounded by 3 ms) | ~300 Hz @ 3 ms scan | None | Low |
| D. Proximity sensor on sprocket (16-tooth) | Sensor + bracket | Some (sensor filter) | ~1 kHz typical | Low | Low |
| E. S7-300 to S7-1500 migration | Replace CPU, I/O, TIA Portal project | None | Process image with 1 µs interrupt OB | High | High |
Recommended sequence for this application:
- Implement Solution A first (lowest cost, highest payoff) by replacing SM 321 with the interrupt-capable variant.
- If further precision is needed, layer Solution D (16-tooth sprocket + sensor) to multiply position resolution by 1.6×.
- If pulse rates exceed 1 kHz or the system must count multiple encoders, upgrade to Solution B (FM 350-1).
10. Verification and Commissioning Procedure
- Disable the reject cylinder (manual mode). Set
m_test_mode= 1 to inject bottle detection at a fixed rate (10 BPH) for initial verification. - Trace
OB40_TIMESTAMPin a data block or in the CPU online watch table. Confirm monotonic increments and that no two consecutive timestamps differ by more than 2× the expected period. - Connect the encoder input to a 1 Hz test pulse (function generator or PLC-built test pulse). Confirm OB40 fires exactly once per second with timestamp delta of 1,000,000,000 (100 ns units = 1 s).
- Reconnect to the live encoder. Oscilloscope the encoder output and capture the bit-shift register pointer for 10,000 pulses into a circular trace buffer; verify no skipped increments.
- Run the line at 36000 BPH with the reject cylinder disabled. Place a fluorescent-marked bottle upstream. Verify via vision or position sensor that the bit at the rejecter index is set to 1 when the marked bottle is in front of the rejecter.
- Re-enable the reject. Run for 8 hours. Count reject actuations and visually inspect the reject log for false rejects or missed rejects. Target: 0 missed rejects, < 0.1% false rejects.
- Check the CPU diagnostic buffer in STEP 7 (PLC > Diagnostic Buffer) for OB40 start events: one entry per pulse, no error codes.
10.1 Diagnostic Buffer Code Reference
| Event ID (hex) | Meaning | Action |
|---|---|---|
| 0x4940 | Mode transition (START/STOP/RUN) | Expected on first start |
| 0x4300 | OB40 start information updated | Expected once per encoder pulse |
| 0x3570 | OB not loaded (e.g., OB40 not present in CPU) | Download OB40 to CPU |
| 0x39A1 | Hardware interrupt lost (OB40 service time exceeded event rate) | Shorten OB40, reduce pulse rate, or switch to FM 350-1 |
| 0x39A2 | Hardware interrupt: diagnostic interrupt from SM 321 | Check encoder wiring and supply |
11. Spare-Part Selection for Upgrades
| Solution | Recommended module | MLFB | Note |
|---|---|---|---|
| A. OB40 | SM 321 DI16 x 24 V DC, HW interrupt | 6ES7321-7BH01-0AB0 | 3 fast counters built in, 0.1 ms filter |
| B. FM 350-1 | FM 350-1 counter | 6ES7350-1AH03-0AB0 | 1 channel, 500 kHz, 32-bit, comparator HW output |
| B (alt). FM 350-2 | FM 350-2 counter | 6ES7350-2AH01-0AE0 | 8 channels, 20 kHz each, for multi-encoder systems |
| D. Sprocket sensor | Sick IME12-08BPSZW2K (PNP, 8 mm range) | 6028262 | Industrial proximity, IP67, 24 V DC |
All part numbers are orderable through the Siemens Industry Mall and are supported by TIA Portal V15.1 and later. The S7-300 family is in phase-out but remains in active spare-parts supply per the SIMATIC S7-300 product phase-out notice (109751706).
12. Field-Proven Caveats
- Mechanical slip: The largest source of position drift not solvable in software. Verify that the encoder is spring-loaded and tracks the belt, not the conveyor pulley. A worn or loose belt will cause the encoder count to diverge from the bottle position over a full conveyor run.
- VFD-driven conveyor: Variable-frequency drives change the speed within ±2% during acceleration and deceleration. The bit-shift register must tolerate a ±2 pulse variation in bottle spacing during line startup. A startup delay (timer 3-5 s) before enabling the reject prevents spurious rejects during ramp-up.
- Bottle skip or jam: If a bottle lies on its side, the optical sensor may not detect it. Use a backup proximity switch on the conveyor frame (separately wired) to detect "conveyor moving but no bottle at inspection" conditions and flag a line alarm.
- OB40 priority: OB40 runs at priority class 16 by default. Set it higher than OB1 (priority class 1) to ensure deterministic pulse handling. OB40 priority is configurable in HW Config under CPU > Properties > Priority.
- OB40 nesting: If the pulse rate exceeds the OB40 service time, OB40 will queue events. If the encoder produces 2000 pulses/s and the OB40 logic takes 100 µs, OB40 will run 20% of CPU time. Check CPU utilization with STEP 7: PLC > Module Information > Scan cycle time. Target OB1 cycle < 50% of available time.
- Power supply headroom: The interrupt-capable SM 321 (6ES7321-7BH01-0AB0) draws 30 mA more from the 24 V backplane than the standard SM 321. Check the PS 305/PS 307 load calculation when upgrading.
- Stepper / VFD interference: Encoder signals running parallel to VFD output cables pick up common-mode noise. Use shielded twisted-pair cable, ground the shield at the FM 350-1 end only, and route the encoder cable at least 30 cm from VFD cables.
13. Alternative Platforms (S7-1200 / S7-1500 Migration Path)
If the S7-300 is approaching end-of-life in the user's plant, the modern Siemens equivalents are:
| Function | S7-300 equivalent | S7-1200 / S7-1500 equivalent | Notes |
|---|---|---|---|
| CPU | CPU 317-2 PN/DP (6ES7317-2EK14-0AB0) | S7-1500 CPU 1511-1 PN (6ES7511-1AK02-0AB0) | Bit instruction 10 ns, OB1 cycle < 0.5 ms typical |
| DI interrupt | SM 321-7BH01 + OB40 | Built-in DI on CPU or SM 521 with HSC | Native 1 µs interrupt OB on S7-1500 |
| Counter | FM 350-1 | High-speed counter (HSC) on SM 521 / built-in DI | Up to 1 MHz on S7-1500 HSC |
| Time stamp | OB40_TIMESTAMP 100 ns | OB40_TIMESTAMP 1 ns | Higher resolution and accuracy |
The S7-1500 platform resolves the OB1 jitter problem at the CPU level (deterministic cycle), so the software-only path (Solution C) is fully adequate on S7-1500 for pulse rates up to 5 kHz. Migration effort is 1-3 engineering days for a system of this size.
14. Spare Logic Block: Adaptive Period FB in SCL
The following SCL function block (FB) implements an adaptive period filter that can be called from OB1 once per scan. It returns the current average pulse period in milliseconds and a flag indicating data validity.
FUNCTION_BLOCK FB_PulsePeriod
VAR_INPUT
i_encoder : BOOL; // raw encoder input
END_VAR
VAR_OUTPUT
q_period_ms : INT; // current period in ms (0 if not yet valid)
q_avg_period_ms : INT; // exponentially-filtered period in ms
q_valid : BOOL; // TRUE after at least 2 edges observed
END_VAR
VAR
s_last_edge : INT; // SFC64 reading at last edge
s_now : INT; // SFC64 reading now
s_period : INT; // raw period
s_avg : INT := 0; // running average
s_count : INT := 0; // edge count
s_fp : BOOL; // edge memory bit
END_VAR
BEGIN
// Read system time in 1 ms units
s_now := TIME_TCK(); // conceptual; SFC64 invocation in SCL uses a wrapper or direct call
// Edge detection
IF i_encoder AND NOT s_fp THEN
// Rising edge detected
IF s_count > 0 THEN
s_period := s_now - s_last_edge;
IF s_period < 0 THEN
s_period := s_period + 32767; // handle wrap-around
END_IF;
q_period_ms := s_period;
// Exponential moving average, alpha = 1/8
s_avg := (s_avg * 7 + s_period) / 8;
q_avg_period_ms := s_avg;
q_valid := TRUE;
END_IF;
s_last_edge := s_now;
s_count := s_count + 1;
END_IF;
s_fp := i_encoder;
END_FUNCTION_BLOCK
Notes on SCL usage: SFC64 "TIME_TCK" is invoked via the SCL wrapper or via inline STL. The wrap-around handling assumes the 1 ms counter wraps at 32767 ms; if higher resolution is needed, use the DWORD variant (10 ms resolution, 49-day wrap).
15. Summary and Field Decision Tree
- Confirm the line speed in BPH and the pulses-per-bottle ratio. Calculate pulse period. If pulse period < 5 ms, OB1 alone will miss pulses; proceed to step 2.
- If you can swap the SM 321 to the interrupt-capable variant 6ES7321-7BH01-0AB0, configure OB40 on rising edge and capture timestamps. This is the lowest-cost, highest-payoff fix.
- If the encoder runs faster than 1 kHz or you need absolute position recovery after power-off, install an FM 350-1 counter module and use SFB47 to read the count.
- If hardware changes are forbidden, apply Solution C (SFC64 in OB1) with an exponential moving average and accept the ±1 pulse accuracy. Do not expect this to be deterministic; it is a best-effort estimate.
- For all solutions, stretch the reject output to a minimum of 80 ms to ensure full cylinder stroke, and add a 2-3 second startup delay to ignore the conveyor ramp-up.
- Validate against a fluorescent-marked bottle; do not trust the encoder count alone.
FAQ
How do I measure the time between two encoder pulses in STEP 7 without any extra module?
Use SFC64 "TIME_TCK" in OB1 to read the 1 ms system clock on each rising edge of the encoder input (detected with the FP one-shot helper). Subtract the previous reading from the current one. The resolution is 1 ms; the accuracy is bounded by the OB1 cycle (3 ms in your case). For better accuracy, switch to an interrupt-capable SM 321 (6ES7321-7BH01-0AB0) and capture timestamps in OB40 at 100 ns resolution.
Can the CPU 317-2 PN/DP timestamp a digital input directly?
No. Only the CPU 31xC variants (for example 6ES7315-2AF03-0AB0) timestamp discrete inputs natively. With a CPU 317-2 PN/DP, you must use the OB40 event time (OB40_TIMESTAMP), which records when the interrupt fired at 100 ns resolution. This requires the SM 321 to be the interrupt-capable variant 6ES7321-7BH01-0AB0.
Why is the gap between two encoder pulses larger than required?
The pulse density (pulses per bottle) is too low for the 3 ms OB1 scan time. At 36000 BPH and 10 ppr, each pulse represents 10 ms of travel. If OB1 = 3 ms, edges are not always seen and the bit-shift register occasionally misses a count, drifting relative to the actual bottle position. Increase pulses per bottle to 16 (16-tooth sprocket) or 32 to raise each pulse window to ~6.25 ms or ~3.1 ms, then enable OB40 for deterministic capture and reduce the SM 321 input filter from 3 ms to 0.1 ms.
How do I know if pulses are being lost in OB1?
Add a 100 ms TON that is reset by every encoder pulse. If the timer elapses (Q = 1), the pulse stream is interrupted. Alternatively, log the bit-shift register pointer and the SFC64 timestamp every OB1 cycle to a data block. Missed pulses appear as jumps in the timestamp delta (for example 2× or 3× the expected period). You can also enable the CPU diagnostic buffer and look for OB40 hardware-interrupt-lost events (event ID 0x39A1).
What is the minimum OB1 cycle time achievable on a CPU 317-2 PN/DP?
The minimum OB1 cycle is limited by the program length, not by the CPU itself. The bit instruction time is 0.05 µs. A program of 1000 boolean instructions takes about 0.05 ms. The 3 ms you measure is dominated by I/O update and the bit-shift register DB scan. Optimizing the DB to use a BOOL array with direct bit access (instead of byte-level masking) and avoiding structured accesses inside loops can drop the cycle to under 1 ms, but for the pulse-capture problem alone the OB40 path is the cleaner fix.