Decoding ‘scope traces, adding a SmartShunt

With the Titan battery connected to the Victron, I’m able to see what the battery monitor deems to be the current flow out of the battery, with the SmartSolar potentially offsetting up to about 5A of draw on a sunny day. What I can’t see is what’s coming off of the starter battery, and I don’t have any good sense of the state of charge (or health) of that battery. I know it’s not in great health though.

I do have a 100A shunt, but today’s experiments with the SmartShunt 500 show that it’s probably a good thing that I didn’t try using it to get a feel for the cold crank draw of the 3GM30F. The goal with the experiments was to use the ‘scope to monitor the millivolts across the shunt terminals, and thus estimate the amps used when the engine starts.

The scope is an OWON HDS1021M-N – a single channel, 20MHz oscilloscope. It’s not the greatest in the world, but it’s battery powered and I’m starting to get a feel for using it. It comes with Windows software, though that software is just some Java and some Win32 DLLs to communicate with the USB connection. Alas, Wine doesn’t expose enough of the USB port to the software (I’d need to get the driver installed, and I haven’t faffed around with that yet), so I opted to use a VM of a Windows XP machine to get some data.

With the help of a LLM suggesting the usbmon kernel module on Linux, plus Wireshark’s USB packet capture (two things I did not know existed!), I was able to get a capture of the STARTBIN mode, and the LLM was able to cross-reference the binary structure with OWON documentation it could find, and a working Python script was produced. This actually took an hour or so, going back and forth with captures, because I attempted to start at the deep end of remote control and continuous monitoring. This wasn’t working in the VM – the usbmon capture never saw any data coming back from the ‘scope – so I don’t know why I thought it might work from Python!

The generated code isn’t terrible for a non-coding LLM. It starts somewhat strong with some lookup tables, and typehints on the function variables, but descends into untyped Python, so it’ll need some cleanup (and I prefer Click for CLI handling). It handles all the binary data, the USB communication via pyusb, and export of the data as both captured binary data and a graphical representation. The ‘scope does have an export button that saves the binary data, and I presume the Windows software can load it and use it. I’ve never had to deal with unpacking binary data, so having the LLM solve that aspect was quite useful (and now I have an idea about how it works).

Anyway, the experimentation was all about turning the engine over from cold to see how much current gets drawn by the work of spinning the starter motor to spin the diesel engine. The answer 800 amps in the first few milliseconds, tailing off to about 200 amps within a second. In theory, the shunt should have no problem with this kind of load – a 500A shunt is rated to deal with 350A for long periods, and spikes over 1000A will generate some heat (and starting an engine is at most a few seconds, not a few minutes).

Time (relative to trigger)Current (Amps)
320803
340608
360505
380438
400343
420324
440299
460314
480337
500377
520329
540276
560247
580219

It’s interesting how the curve is fairly similar between a decompression-levers-open and decompression-levers-closed start. There’s a lot more noise for the ‘scope when the engine starts spinning, and it’s possible the second hump is a conflict between the starter and the engine. There was also a constant 30mV of ripple visible (the left hand blue “bar”) – one day I might try isolating connections to work out whether that’s the scope (it shouldn’t be that bad!), the environment, or something that’s connected to the common ground of the boat.

def decode(blob):
    if len(blob) < 61:
        raise RuntimeError("Waveform blob too short")

    magic = blob[0:6].decode("ascii", errors="replace")

    if magic != "SPBV14":
        raise RuntimeError(
            f"Unexpected waveform magic {magic!r}"
        )

    file_len, = struct.unpack_from("<I", blob, 6)
    channel = blob[10:13].decode("ascii", errors="replace")
    block_len, = struct.unpack_from("<I", blob, 13)
    screen_points, = struct.unpack_from("<I", blob, 17)
    sample_count, = struct.unpack_from("<I", blob, 21)
    slow_move_count, = struct.unpack_from("<I", blob, 25)
    timebase_index, = struct.unpack_from("<I", blob, 29)
    vertical_position, = struct.unpack_from("<i", blob, 33)
    voltage_index, = struct.unpack_from("<I", blob, 37)
    probe_index, = struct.unpack_from("<I", blob, 41)
    sample_spacing_us, = struct.unpack_from("<f", blob, 45)
    frequency_hz, = struct.unpack_from("<f", blob, 49)
    period_us, = struct.unpack_from("<f", blob, 53)
    mv_per_count, = struct.unpack_from("<f", blob, 57)
    data_offset = 61
    needed = data_offset + sample_count * 2

    if len(blob) < needed:
        raise RuntimeError(
            f"Waveform blob truncated: need {needed}, got {len(blob)}"
        )

    samples = list(
        struct.unpack_from(
            f"<{sample_count}h",
            blob,
            data_offset,
        )
    )

The LLM dug up a PDF document from OWON that documents the USB packet format, and the binary data format. Interestingly, that document seems to predate the HDS unit I have, as it stops at SPBV12 – “SPBV12 correspond to HDS1021M” (note the lack of -N). The documented format seems to align fairly closely to the generated code, but I never came across that PDF file in my searching (the “original” comes from bikealive.nl, which has an invalid TLS certificate). Now that I search for SPBV12, I can find the blog that PDF is linked from, but no one appears to have documented the SPBV14 format (it looks the same, but search engines failed me).

The same decoder should work for the .bin files that the “Copy” button will save to an attached USB thumb stick – tethering to a PC isn’t needed.