Compare commits

...
3 Commits
Author SHA1 Message Date
grabowski f2f78080e0 Repository renamed to B4L/HPCS6500 2026-10-22 17:35:33 +07:00
grabowski 8f667dc6cf Flag the unverified spectrum wavelength axis; keep the fixture generator
The linear 380-1050 nm axis gives spectrum chromaticities 0.09-0.13 (xy)
away from the instrument's own x, y, so TM-30 and spd.csv inherit an axis
error until it is confirmed. tools/make_fixtures.py regenerates the golden
data from the python-final driver. The release profile moves to the
workspace that builds the binary.
2026-10-22 17:25:09 +07:00
grabowski 54242da04b Rewrite the driver in Rust; move the testbench tools out
The repository is now only the HPCS 6500 driver: a Rust library (serial
transport, every protocol command, block parsing, USBPcap captures, a
simulated instrument) and the hpcs6500 CLI with the flags of the former
hpcs6500.py, plus dump/capture for reverse engineering.

The lamp station, lamp export, TM-30, label printer and LED calibration
move to B4L/lamp-testbench. Golden tests pin the Rust output to the Python
driver's: parsed values bit-exact, report text, CSV and number formatting.
The Python driver is at tag python-final.
2026-10-22 17:01:47 +07:00
80 changed files with 23493 additions and 3246 deletions

No files matched your search

+4
View File
@@ -0,0 +1,4 @@
# keep test data byte-exact on every platform
*.bin binary
*.pcap binary
tests/golden/** -text
+9 -18
View File
@@ -1,27 +1,18 @@
# Python
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
# UV
uv
uv.lock
# Rust
target/
# Captures (binary data, not tracked)
captures/
# Lamp export bundles (published to the website repo instead)
# left over from the Python tools
__pycache__/
*.py[oc]
.venv
uv
uv.lock
lamps/
# Claude Code
.claude/
# Local AI-tooling state
.claude/
.claude-flow/
.swarm/
-1
View File
@@ -1 +0,0 @@
3.13
Generated
+832
View File
@@ -0,0 +1,832 @@
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 4
[[package]]
name = "android_system_properties"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "autocfg"
version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "bitflags"
version = "2.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06"
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "bumpalo"
version = "3.20.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649"
[[package]]
name = "cc"
version = "1.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877bf67ac8e52449eb2f9e98a344ea163beb70dd1f9eebe3cc5357dcfeaee4df"
dependencies = [
"find-msvc-tools",
"shlex",
]
[[package]]
name = "cfg-if"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600"
[[package]]
name = "cfg_aliases"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527"
[[package]]
name = "chrono"
version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [
"iana-time-zone",
"num-traits",
"windows-link",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "core-foundation"
version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation-sys"
version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "ctrlc"
version = "3.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e0b1fab2ae45819af2d0731d60f2afe17227ebb1a1538a236da84c93e9a60162"
dependencies = [
"dispatch2",
"nix 0.31.3",
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"block2",
"libc",
"objc2",
]
[[package]]
name = "equivalent"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "find-msvc-tools"
version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "foldhash"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
[[package]]
name = "futures-core"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e"
[[package]]
name = "futures-task"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd"
[[package]]
name = "futures-util"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc"
dependencies = [
"futures-core",
"futures-task",
"pin-project-lite",
"slab",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hpcs6500"
version = "0.2.0"
dependencies = [
"chrono",
"clap",
"ctrlc",
"serde",
"serde_json",
"serialport",
"thiserror",
]
[[package]]
name = "iana-time-zone"
version = "0.1.65"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
dependencies = [
"android_system_properties",
"core-foundation-sys",
"iana-time-zone-haiku",
"js-sys",
"log",
"wasm-bindgen",
"windows-core",
]
[[package]]
name = "iana-time-zone-haiku"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
dependencies = [
"cc",
]
[[package]]
name = "indexmap"
version = "2.14.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
dependencies = [
"equivalent",
"hashbrown",
]
[[package]]
name = "io-kit-sys"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "617ee6cf8e3f66f3b4ea67a4058564628cde41901316e19f559e14c7c72c5e7b"
dependencies = [
"core-foundation-sys",
"mach2",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itoa"
version = "1.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "js-sys"
version = "0.3.106"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
dependencies = [
"cfg-if",
"futures-util",
"wasm-bindgen",
]
[[package]]
name = "libc"
version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libudev"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78b324152da65df7bb95acfcaab55e3097ceaab02fb19b228a9eb74d55f135e0"
dependencies = [
"libc",
"libudev-sys",
]
[[package]]
name = "libudev-sys"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c8469b4a23b962c1396b9b451dda50ef5b283e8dd309d69033475fa9b334324"
dependencies = [
"libc",
"pkg-config",
]
[[package]]
name = "log"
version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
[[package]]
name = "mach2"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d640282b302c0bb0a2a8e0233ead9035e3bed871f0b7e81fe4a1ec829765db44"
dependencies = [
"libc",
]
[[package]]
name = "memchr"
version = "2.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "nix"
version = "0.26.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "598beaf3cc6fdd9a5dfb1630c2800c7acd31df7aaf0f565796fba2b53ca1af1b"
dependencies = [
"bitflags 1.3.2",
"cfg-if",
"libc",
]
[[package]]
name = "nix"
version = "0.31.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"cfg_aliases",
"libc",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "pin-project-lite"
version = "0.2.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd"
[[package]]
name = "pkg-config"
version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "proc-macro2"
version = "1.0.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
dependencies = [
"unicode-ident",
]
[[package]]
name = "quote"
version = "1.0.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
dependencies = [
"proc-macro2",
]
[[package]]
name = "rustversion"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f"
[[package]]
name = "scopeguard"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "serde"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
dependencies = [
"serde_core",
"serde_derive",
]
[[package]]
name = "serde_core"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
dependencies = [
"serde_derive",
]
[[package]]
name = "serde_derive"
version = "1.0.229"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "serde_json"
version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"foldhash",
"indexmap",
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "serialport"
version = "4.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba5f8f29aa20853c4e3e85a33ec580eb66be1f057142e77a333834a318bacf2"
dependencies = [
"bitflags 2.13.2",
"cfg-if",
"core-foundation",
"core-foundation-sys",
"io-kit-sys",
"libudev",
"mach2",
"nix 0.26.4",
"scopeguard",
"unescaper",
"windows-sys 0.52.0",
]
[[package]]
name = "shlex"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]]
name = "slab"
version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "2.0.119"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "syn"
version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
"unicode-ident",
]
[[package]]
name = "thiserror"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e"
dependencies = [
"thiserror-impl",
]
[[package]]
name = "thiserror-impl"
version = "2.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.9",
]
[[package]]
name = "unescaper"
version = "0.1.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7285e83a80ce76f5e7bce79fa41f68d78ba62d1003cf27bf748ab24413808cf4"
dependencies = [
"thiserror",
]
[[package]]
name = "unicode-ident"
version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "wasm-bindgen"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
dependencies = [
"cfg-if",
"once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-macro"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
]
[[package]]
name = "wasm-bindgen-macro-support"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
"syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
version = "0.2.129"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
dependencies = [
"unicode-ident",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-result"
version = "0.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
"windows-targets",
]
[[package]]
name = "windows-sys"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-targets"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
"windows_aarch64_gnullvm",
"windows_aarch64_msvc",
"windows_i686_gnu",
"windows_i686_gnullvm",
"windows_i686_msvc",
"windows_x86_64_gnu",
"windows_x86_64_gnullvm",
"windows_x86_64_msvc",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
[[package]]
name = "zmij"
version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
+36
View File
@@ -0,0 +1,36 @@
[package]
name = "hpcs6500"
version = "0.2.0"
edition = "2024"
rust-version = "1.85"
description = "Open-source driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere"
repository = "https://git.b4l.co.th/B4L/HPCS6500"
readme = "README.md"
publish = false
[features]
default = ["cli"]
# the `hpcs6500` command line tool; library users can turn it off
cli = ["dep:clap", "dep:serde_json", "dep:chrono", "dep:ctrlc"]
[lib]
name = "hpcs6500"
path = "src/lib.rs"
[[bin]]
name = "hpcs6500"
path = "src/cli/main.rs"
required-features = ["cli"]
[dependencies]
serialport = "4.7"
serde = { version = "1", features = ["derive"] }
thiserror = "2"
clap = { version = "4.5", features = ["derive"], optional = true }
serde_json = { version = "1", features = ["preserve_order"], optional = true }
chrono = { version = "0.4", default-features = false, features = ["clock", "std"], optional = true }
ctrlc = { version = "3.4", optional = true }
[dev-dependencies]
serde_json = { version = "1", features = ["preserve_order"] }
+15 -1
View File
@@ -1,6 +1,7 @@
# HPCS 6500 USB Protocol Reference
Reverse-engineered from USB packet captures of the vendor software.
Reverse-engineered from USB packet captures of the vendor software. The
Rust driver in `src/` implements it (`protocol.rs`, `device.rs`, `reading.rs`).
## Device Overview
@@ -189,6 +190,19 @@ The 350 floats cover **380 nm to 1050 nm** at ~1.92 nm steps
((1050-380) / (350-1) = 1.917 nm/step). Values are absolute spectral
irradiance; the vendor software normalizes by dividing by the peak value.
> **Unverified: the wavelength axis.** The linear 380-1050 nm mapping above
> is an assumption. Integrating the published lamp spectra with the CIE 1931
> observer on this axis gives chromaticities 0.09-0.13 (xy) away from the
> x, y the instrument itself reports in the same block (offsets 52/56); for
> L0011 the spectrum says x,y = 0.358, 0.295 against the instrument's 0.380,
> 0.382. No other linear range fits all lamps (380-780 nm comes closer but
> moves the blue LED peak to ~419 nm). The 350 points are probably on a
> non-linear (spectrometer pixel) axis. Everything computed from the
> spectrum (TM-30 Rf/Rg, the spd.csv files) inherits this until the axis is
> confirmed, e.g. against a spectrum exported by the vendor software with
> its wavelengths, or by fitting the axis so the spectrum reproduces the
> instrument's own XYZ.
### Unmapped Regions
Offsets 176-223, 256-271, and 1832-3903 contain additional data not yet
+118 -136
View File
@@ -1,170 +1,152 @@
# HPCS 6500 — Open-Source Driver
# HPCS 6500 — open-source driver
Python driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere.
Rust driver and command line tool for the HPCS 6500 spectrophotometer /
integrating sphere. It talks to the instrument directly over its USB serial
port and replaces the vendor software for measurement automation and data
extraction.
Communicates directly with the instrument over USB serial, replacing the
vendor software for measurement automation and data extraction.
This repository is only the driver. The lamp measurement station, the lamp
comparison export (TM-30, label printing) and the LED light calibration are
in the testbench repository,
[B4L/lamp-testbench](https://git.b4l.co.th/B4L/lamp-testbench), which pulls
this repository in as a submodule (the same layout as
[mppt-testbench](https://git.b4l.co.th/B4L/mppt-testbench)).
## What This Does
## What it reads
- **Measure**: Luminous flux (lm), CCT (K), CRI (Ra, R1-R15), chromaticity
(CIE xy, uv, u'v'), radiometric flux, spectrum 380-1050 nm
- **Electrical**: Voltage, current, power, power factor, frequency
- **Harmonics**: 50-harmonic voltage/current analysis, UThd, AThd, waveforms
- **Power supply**: Control the built-in AC (100-240V, 50/60Hz) and DC
(1-60V, 0-5A) power supply
- **Export**: CSV output for data logging
- **TM-30**: ANSI/IES TM-30-18 Rf, Rg, and hue-bin data computed from the
measured spectrum (via colour-science)
- **Lamp bundles**: one-command export of spectrum + TM-30 + metrics for the
[buildfor.life lamp comparison](https://buildfor.life/comparisons)
- **Photometric**: luminous flux (lm), efficacy (lm/W), CCT (K), Duv
- **Colour**: CRI Ra and R1-R15, SDCM, TLCI-2012, CIE xy / uv / u'v', CIE 1931 XYZ
- **Radiometric**: total, UV, blue, yellow, red, far-red and IR flux (mW)
- **Spectrum**: 350 points, 380-1050 nm
- **Electrical**: voltage, current, power, power factor, frequency
- **Harmonics**: 50 voltage and current harmonics, UThd, AThd, one cycle of both waveforms
- **Power supply**: the built-in AC (100-240 V, 50/60 Hz) and DC (1-60 V, 0-5 A) supply
## Quick Start
## Install
```bash
# Install
uv sync
# Single measurement (auto-detects device, turns PSU on/off)
uv run hpcs6500.py
# Continuous measurements
uv run hpcs6500.py --continuous
# Quick test (lumen + CCT only)
uv run hpcs6500.py --quick
uv run hpcs6500.py --quick --continuous
# Show full spectrum + harmonics
uv run hpcs6500.py --spectrum --harmonics
# Save to CSV
uv run hpcs6500.py --continuous --csv output.csv
cargo install --git https://git.b4l.co.th/B4L/HPCS6500 # puts `hpcs6500` on the PATH
# or, in a checkout:
cargo build --release # target/release/hpcs6500(.exe)
```
## Power Supply Control
Windows needs no driver: the instrument is an STM32 virtual COM port
(VID `0483`, PID `5741`) and is found automatically. Linux: add yourself to
the `dialout` group.
## Command line
The flags are the ones of the former Python tool: `uv run hpcs6500.py ...`
is now `hpcs6500 ...`.
```bash
# Read current PSU settings
uv run hpcs6500.py --psu-status
hpcs6500 # single reading (vendor single-test flow)
hpcs6500 --continuous # continuous readings, PSU on while measuring
hpcs6500 --continuous --count 10 --csv out.csv
hpcs6500 --quick --continuous # lumen + CCT only
hpcs6500 --spectrum --harmonics # full spectrum and harmonics
hpcs6500 --json # one JSON object per reading on stdout
hpcs6500 --passive --continuous # read only, while the vendor software drives the instrument
hpcs6500 --port COM42 # pick the port
hpcs6500 ports # list serial ports, the instrument is marked
# Set AC mode, 230V 50Hz
uv run hpcs6500.py --mode ac --voltage 230 --frequency 50
# Set DC mode, 12V with 1A current limit
uv run hpcs6500.py --mode dc --voltage 12 --current 1.0
# Manual PSU on/off
uv run hpcs6500.py --psu-on
uv run hpcs6500.py --psu-off
# Set integration time (ms)
uv run hpcs6500.py --integration 500
# power supply
hpcs6500 --psu-status
hpcs6500 --mode ac --voltage 230 --frequency 50
hpcs6500 --mode dc --voltage 12 --current 1.0
hpcs6500 --psu-on / hpcs6500 --psu-off
hpcs6500 --integration 500 # ms, 0 = auto
```
## Lamp Comparison Export
Ctrl+C stops a continuous run cleanly: the supply is switched off and the
instrument reset. The text output and the CSV files are character for
character those of the Python tool, so scripts that read them keep working.
Produces the per-lamp data bundle consumed by the buildfor.life comparison
pages: `spd.csv` (full 380-1050 nm spectrum), `tm30.csv` (TM-30-18 hue-bin
data for the color vector graphic), and `metrics.json` (photometric,
colorimetric, CRI R1-R15, TM-30 Rf/Rg, electrical).
### Without the instrument
`--simulate` talks to a simulated instrument that answers every command of
the protocol: with a capture it replays the readings recorded in it,
without one it uses a built-in sample reading.
```bash
# Single reading from the device
uv run lamp_export.py --name philips-a60-8w --manufacturer Philips --model "A60 8W 927"
# Average several readings
uv run lamp_export.py --name philips-a60-8w --readings 5
# Power the lamp from the built-in supply: 230 V / 50 Hz, 60 s warm-up,
# PSU switches on before the readings and off afterwards
uv run lamp_export.py --name philips-a60-8w --voltage 230 --frequency 50 --settle 60
# From an existing pcap capture
uv run lamp_export.py --name some-lamp --parse captures/run.pcap
hpcs6500 --simulate # built-in sample
hpcs6500 --simulate captures/run.pcap --continuous # replay a capture
```
Output lands in `lamps/<name>/`. TM-30 is computed from the measured spectrum
with [colour-science](https://www.colour-science.org/); the spectrum is
relative, which TM-30 is invariant to. Sanity check of the implementation:
`uv run tm30.py` reproduces the published values for the CIE FL2 illuminant
(Rf 70, Rg 86).
## Lamp Station (barcode workflow)
Interactive loop around `lamp_export.py` for building the comparison database
end to end. Every lamp gets an internal ID (L0001, L0002, ...) as primary key;
the EAN barcode is optional metadata, so one barcode can cover daylight/warm
white variants (distinct IDs + variant field) and unbranded lamps work too.
Every run opens with a freshly allocated ID (the ID only becomes real once a
bundle is written, so aborting never burns a number). Then: scan the box's EAN
(optional, Enter skips it), or type an existing ID like L0004 to re-measure
that lamp with everything pre-filled, which also rewrites its bundle in the
current metrics format. Scanned EANs are checksum-validated and resolved to
manufacturer/model (previously measured lamps, local cache, then
upcitemdb.com; GS1-restricted 20-29 barcodes are flagged as
retailer-internal), lamps already measured under the same EAN are offered for
re-measurement, you confirm the identification and type the ADVERTISED values
from the packaging (flux, CCT, power, CRI, lifetime, equivalent W -> stored
under "rated" in metrics.json for claimed-vs-measured comparison), insert the
lamp, and the tool measures per the published procedure (230 V / 50 Hz, 60 s
settle, 5 readings averaged), writes lamps/<ID>/ into the comparison-data
checkout, commits, and pushes, then prints two ID labels (QR code linking to
the lamp's page plus the ID, DK-11221 23x23 mm) on a Brother QL-820NWB network
printer: one for the box, one for the lamp. Set HPCS_LABEL_PRINTER (e.g.
tcp://192.168.1.50) or pass --printer; reprint anytime with
`uv run label_printer.py <ID>`. The website picks the lamp up automatically:
its device list is data-driven from the comparison-data bundles and the push
triggers the auto-bump deploy. An .md page in the web repo is only ever needed
for photos, where-to-buy links, or notes.
### USB captures (reverse engineering)
```bash
uv run lamp_station.py # scan -> measure -> commit -> push
uv run lamp_station.py --no-push # commit locally only
uv run lamp_station.py --no-commit # just write the bundle
uv run lamp_station.py --settle 0 --readings 1 # quick smoke test
hpcs6500 --parse captures/run.pcap [--quick] [--spectrum] [--json] # readings in a capture
hpcs6500 dump captures/run.pcap # traffic timeline + <name>_TX.bin / _RX.bin
hpcs6500 capture # record the vendor software with USBPcap (admin, Windows)
```
Defaults assume `C:/dev/buildfor_life_web` with the comparison-data submodule
initialized; override with --data-repo.
`capture` starts `USBPcapCMD` on every root hub, waits for Ctrl+C, keeps the
largest file and dumps it. Captures go to `captures/` (not in git).
## Offline Parsing
## Library
Parse previously captured USB traffic (pcap files from USBPcap):
```bash
uv run hpcs6500.py --parse captures/some_capture.pcap
uv run hpcs6500.py --parse captures/some_capture.pcap --quick
```toml
[dependencies]
hpcs6500 = { git = "https://git.b4l.co.th/B4L/HPCS6500", default-features = false }
```
## Files
`default-features = false` leaves out the command line tool's dependencies.
| File | Description |
|------------------|----------------------------------------------------|
| `hpcs6500.py` | Driver class (`HPCS6500`) and CLI |
| `lamp_export.py` | Lamp comparison bundle export (spd/tm30/metrics) |
| `tm30.py` | ANSI/IES TM-30-18 computation from a spectrum |
| `usb_capture.py` | USB traffic capture tool (requires USBPcap) |
| `PROTOCOL.md` | Complete protocol reference (byte-level) |
| `pyproject.toml` | Project metadata and dependencies |
```rust
use hpcs6500::{Hpcs6500, Mode};
## Hardware
let port = hpcs6500::find_port().ok_or(hpcs6500::Error::NotFound)?;
let mut dev = Hpcs6500::open(&port)?;
dev.identify()?; // "HPCS6500"
dev.set_mode(Mode::Ac)?;
dev.set_ac_voltage(230.0)?;
dev.set_ac_frequency(50.0)?;
dev.psu_on()?;
dev.send_test_config(false)?; // the vendor's single-test configuration
let r = dev.take_single_reading()?; // trigger, poll, read both blocks, reset
dev.psu_off()?;
println!("{:.0} lm {:.0} K Ra {:.1}", r.flux_lm(), r.cct_k(), r.ra());
println!("{:?}", r.get("Phi_eb_mW")); // every field by its CSV name
let nm = r.spectrum_nm(); // with r.spectrum
```
- **Device**: HPCS 6500 spectrophotometer / integrating sphere
- **USB**: STM32 Virtual COM Port (VID `0483`, PID `5741`)
- **Protocol**: Custom binary over serial, documented in [PROTOCOL.md](PROTOCOL.md)
| Item | |
|---|---|
| `Hpcs6500::open(port)` / `with_transport(t, name)` | the instrument over the serial port or any `Transport` |
| `identify`, `poll_state`, `wait_for_data` | |
| `take_single_reading`, `take_reading(psu)`, `read_current` | single test, one continuous reading, passive read |
| `start_measurement`, `stop_measurement`, `reset`, `send_test_config` | the raw sequence steps |
| `psu_on`, `psu_off`, `set_mode`, `set_ac_voltage`, `set_ac_frequency`, `set_dc_voltage`, `set_dc_current`, `read_psu_settings` | built-in supply |
| `set_integration_time(ms)` | 0 = auto |
| `read_measurement_block`, `read_electrical_block` | raw 3904 / 1584-byte blocks |
| `set_cancel_flag(flag)`, `pause(d)` | long waits end with `Error::Cancelled` when the flag goes up (Ctrl+C) |
| `Reading` | `values` by `FIELDS`, `get(name)`, `scalars()`, `spectrum`, `spectrum_nm()`, `electrical` with `harmonics`; serializes to the Python driver's JSON keys |
| `pcap::read_messages`, `pcap::readings`, `pcap::blocks` | USBPcap captures |
| `sim::Simulator` | simulated instrument (`sample()`, `from_pcap(path)`), logs every frame |
| `format::*` | the text report and the CSV export |
| `pyfmt::*` | Python-compatible number formatting (`repr`, `.6g`, `.2f`, `round`) |
## Dependencies
- Python 3.11+
- `pyserial` (serial communication)
- `colour-science` (TM-30 computation)
- USBPcap (only for `usb_capture.py`, not needed for normal operation)
Every method returns `Result`; errors say what was expected and what came
back (`no acknowledge for command 0x72 (got nothing)`).
## Protocol
The binary protocol is fully documented in [PROTOCOL.md](PROTOCOL.md),
including all command bytes, data block layouts, field offsets, and the
complete measurement sequence. This was reverse-engineered from USB
packet captures of the vendor software.
The binary protocol is documented byte by byte in [PROTOCOL.md](PROTOCOL.md):
command bytes, block layouts, field offsets and the measurement sequences.
It was reverse-engineered from USB captures of the vendor software.
## Development
```bash
cargo test # protocol, simulator, golden tests
cargo clippy --all-targets -- -D warnings
```
`tests/golden/` holds the output of the former Python driver for real
readings (`tests/fixtures/`): the parsed values (bit-exact), the text report,
the CSV row and 2,000 number-formatting cases. The Rust driver reproduces all
of them exactly. Where `captures/` is present, the `--parse` reports of real
captures are compared too.
The Python driver this replaces is at tag `python-final`.
-895
View File
@@ -1,895 +0,0 @@
#!/usr/bin/env python3
"""
LED Light Calibration Tool
Calibrates the bilinear interpolation table for CCT/Lumen mixing ratios.
Works with an external measurement script that writes to measurement.json.
Usage:
1. Start the external measurement script (reads spectrometer/lux meter)
2. Run this calibration tool
3. The tool sets test points and waits for measurements
4. After all points are measured, it generates a calibration table
"""
import serial
import time
import json
import sys
import argparse
import subprocess
from pathlib import Path
# Calibration grid (matches firmware)
CCT_POINTS = [2600, 2900, 3200, 3500, 3800, 4100, 4400, 4700, 5000]
LUMEN_POINTS = [200, 375, 550, 725, 900]
NUM_CCT = len(CCT_POINTS)
NUM_LUMEN = len(LUMEN_POINTS)
# UART commands
CMD_SET_COLOR = 0x81
CMD_SET_INTENSITY = 0x82
CMD_PROFILE_OFF = 0x84
CMD_CAL_START = 0x8A
CMD_CAL_WRITE = 0x8B
CMD_CAL_COMMIT = 0x8C
CMD_CAL_CLEAR = 0x8D
CMD_CAL_READ = 0x8E
CMD_CAL_WRITE_Y = 0x8F
RSP_ACK = 0xC0
# Default y-data tables (mA points: 0, 11, 33, 65, 150 -> lumen output)
Y27_DEFAULT = [0, 39, 179, 369, 844]
Y50_DEFAULT = [0, 51, 217, 441, 969]
X_DATA = [0, 11, 33, 65, 150] # mA drive points
# Default paths
CALIBRATION_OUTPUT = Path(__file__).parent / "calibration_table.json"
DEFAULT_SPECTROMETER = Path(__file__).parent / "spectrometer.py"
class CalibrationTool:
def __init__(self, port, baudrate=115200, spectrometer_script=None):
self.port = port
self.baudrate = baudrate
self.spectrometer_script = spectrometer_script or DEFAULT_SPECTROMETER
self.ser = None
self.measurements = {} # (cct, lumen) -> {'measured_cct': x, 'measured_lumen': y}
def connect(self):
"""Connect to the light via serial port."""
self.ser = serial.Serial(self.port, self.baudrate, timeout=1)
time.sleep(0.5) # Wait for connection to stabilize
self.ser.reset_input_buffer()
print(f"Connected to {self.port}")
def disconnect(self):
"""Disconnect from the light."""
if self.ser:
self.ser.close()
self.ser = None
def send_command(self, cmd, payload, timeout=2.0):
"""Send a 5-byte command and wait for response, skipping diagnostic messages."""
if len(payload) != 4:
raise ValueError("Payload must be 4 bytes")
# Flush any pending input
self.ser.reset_input_buffer()
data = bytes([cmd]) + bytes(payload)
self.ser.write(data)
# Read 5-byte messages until we get a response (0xC0 or 0xC1)
start = time.time()
while time.time() - start < timeout:
response = self.ser.read(5)
if len(response) < 5:
continue
# Check if this is a command response (0xC0=ACK, 0xC1=NAK)
if response[0] == RSP_ACK:
# Verify the echoed command matches what we sent
if response[1] == cmd:
return True
else:
print(f"ACK mismatch: sent 0x{cmd:02x}, got {response.hex()}")
# Keep reading, this might be from a previous command
continue
elif response[0] == 0xC1: # RSP_NAK
print(f"NAK received: {response.hex()}")
return False
# Otherwise it's a diagnostic message, skip it
print("Timeout waiting for response")
return False
def set_intensity(self, intensity):
"""Set light intensity (0-1000 lux target)."""
lo = intensity & 0xFF
hi = (intensity >> 8) & 0xFF
return self.send_command(CMD_SET_INTENSITY, [lo, hi, 0, 0])
def set_color(self, cct):
"""Set color temperature (2600-5000K)."""
lo = cct & 0xFF
hi = (cct >> 8) & 0xFF
return self.send_command(CMD_SET_COLOR, [lo, hi, 0, 0])
def disable_profile(self):
"""Disable automatic profile, switch to manual mode."""
return self.send_command(CMD_PROFILE_OFF, [0, 0, 0, 0])
def cal_start(self):
"""Start calibration write sequence (erases flash)."""
result = self.send_command(CMD_CAL_START, [0, 0, 0, 0], timeout=5.0)
print(f" cal_start result: {result}")
return result
def cal_write(self, index, value):
"""Write a single calibration value (index 0-44, value 0-255)."""
result = self.send_command(CMD_CAL_WRITE, [index, value, 0, 0])
if not result:
print(f" cal_write failed at index {index}, value {value}")
return result
def cal_write_y(self, table, index, value):
"""Write a y-data calibration value. table: 0=y27, 1=y50. value: uint16."""
lo = value & 0xFF
hi = (value >> 8) & 0xFF
return self.send_command(CMD_CAL_WRITE_Y, [table, index, lo, hi])
def cal_commit(self):
"""Commit calibration data to flash."""
return self.send_command(CMD_CAL_COMMIT, [0, 0, 0, 0], timeout=5.0)
def cal_clear(self):
"""Clear calibration (revert to defaults)."""
return self.send_command(CMD_CAL_CLEAR, [0, 0, 0, 0])
def write_calibration_table(self, table, y27_cal=None, y50_cal=None, verbose=True):
"""Write calibration data to the device.
Args:
table: 9x5 mixing ratio table
y27_cal: 5-element list of lumen values for 2700K LED (optional)
y50_cal: 5-element list of lumen values for 5000K LED (optional)
"""
if verbose:
print("Writing calibration to device...")
# Ensure clean UART state before starting
self.ser.reset_input_buffer()
time.sleep(0.1)
if not self.cal_start():
if verbose:
print(" Failed to start calibration write")
return False
time.sleep(1.0) # Wait for flash erase to complete
# Write mixing ratios
index = 0
for i, row in enumerate(table):
for j, value in enumerate(row):
if not self.cal_write(index, value):
if verbose:
print(f" Failed to write mixing ratio index {index}")
return False
index += 1
time.sleep(0.05)
# Write y27 data (use defaults if not provided)
y27 = y27_cal if y27_cal else Y27_DEFAULT
for i, value in enumerate(y27):
if not self.cal_write_y(0, i, int(value)):
if verbose:
print(f" Failed to write y27[{i}]")
return False
time.sleep(0.05)
# Write y50 data (use defaults if not provided)
y50 = y50_cal if y50_cal else Y50_DEFAULT
for i, value in enumerate(y50):
if not self.cal_write_y(1, i, int(value)):
if verbose:
print(f" Failed to write y50[{i}]")
return False
time.sleep(0.05)
time.sleep(0.5) # Wait before commit
if not self.cal_commit():
if verbose:
print(" Failed to commit calibration")
return False
if verbose:
print(" Calibration written successfully!")
return True
def cal_read(self, index):
"""Read a single calibration value. Returns (index, value, valid) or None."""
self.ser.write(bytes([CMD_CAL_READ, index, 0, 0, 0]))
response = self.ser.read(5)
if len(response) < 5 or response[0] != RSP_ACK:
return None
return (response[1], response[2], response[3])
def read_calibration_table(self, verbose=True):
"""Read the entire calibration table from device."""
if verbose:
print("Reading calibration table from device...")
table = []
valid = None
for i in range(NUM_CCT):
row = []
for j in range(NUM_LUMEN):
index = i * NUM_LUMEN + j
result = self.cal_read(index)
if result is None:
if verbose:
print(f" Failed to read index {index}")
return None
row.append(result[1])
if valid is None:
valid = result[2]
table.append(row)
if verbose:
status = "calibrated" if valid else "defaults"
print(f" Read complete ({status})")
return table, valid
def set_test_point(self, cct, lumen, verbose=False):
"""Set the light to a specific CCT and lumen test point."""
if verbose:
print(f" Setting CCT={cct}K, Lumen={lumen}")
if not self.set_color(cct):
if verbose:
print(" Failed to set color")
return False
time.sleep(0.2)
if not self.set_intensity(lumen):
if verbose:
print(" Failed to set intensity")
return False
return True
def take_measurement(self, timeout=30, verbose=False):
"""
Call spectrometer script to take a measurement.
Expected JSON output: {"lumen": 450.5, "cct": 3250}
"""
if verbose:
print(f" Taking measurement...")
try:
result = subprocess.run(
['uv', 'run', 'python', str(self.spectrometer_script), '--measure'],
capture_output=True,
text=True,
timeout=timeout
)
if result.returncode != 0:
if verbose:
print(f" Spectrometer error: {result.stderr.strip()}")
return None
data = json.loads(result.stdout)
if 'lumen' in data and 'cct' in data:
return data
if verbose:
print(f" Invalid response: missing lumen or cct")
return None
except subprocess.TimeoutExpired:
if verbose:
print(f" Timeout waiting for spectrometer")
return None
except json.JSONDecodeError as e:
if verbose:
print(f" Invalid JSON from spectrometer: {e}")
return None
except FileNotFoundError:
if verbose:
print(f" Spectrometer script not found: {self.spectrometer_script}")
return None
def measure_all_points(self):
"""Measure all calibration points once. Returns True if all succeeded.
Points are ordered to minimize drive current changes between consecutive
measurements: sorted by lumen (ascending), with CCT in snake pattern
(alternating direction) to avoid large jumps.
"""
total_points = len(CCT_POINTS) * len(LUMEN_POINTS)
current_point = 0
success_count = 0
# Generate measurement order: snake pattern to minimize current jumps
# At each lumen level, alternate CCT direction
measurement_order = []
for i, lumen in enumerate(LUMEN_POINTS):
ccts = CCT_POINTS if i % 2 == 0 else list(reversed(CCT_POINTS))
for cct in ccts:
measurement_order.append((cct, lumen))
for cct, lumen in measurement_order:
current_point += 1
key = (cct, lumen)
print(f" [{current_point}/{total_points}] CCT={cct}K, Lumen={lumen}", end="")
# Set test point
if not self.set_test_point(cct, lumen):
print(" - FAILED to set")
continue
# Wait for light to stabilize
time.sleep(4.0)
# Take measurement with plausibility check
max_retries = 3
for attempt in range(max_retries):
measurement = self.take_measurement()
if not measurement:
if attempt < max_retries - 1:
time.sleep(2.0)
continue
print(" - NO READING")
break
# Plausibility check: deviation must be < 60%
cct_dev = abs(measurement['cct'] - cct) / cct
lumen_dev = abs(measurement['lumen'] - lumen) / lumen if lumen > 0 else 0
if cct_dev > 0.6 or lumen_dev > 0.6:
if attempt < max_retries - 1:
time.sleep(4.0)
continue
print(f" - IMPLAUSIBLE ({cct_dev*100:.0f}%/{lumen_dev*100:.0f}%)")
break
# Measurement is plausible, store it
self.measurements[key] = {
'target_cct': cct,
'target_lumen': lumen,
'measured_cct': measurement['cct'],
'measured_lumen': measurement['lumen']
}
cct_err = (measurement['cct'] - cct) / cct * 100
lumen_err = (measurement['lumen'] - lumen) / lumen * 100
print(f" -> CCT:{cct_err:+5.1f}% Lum:{lumen_err:+5.1f}%")
success_count += 1
break
return success_count == total_points
def print_deviation_table(self):
"""Print a table showing deviation of all measured points."""
print("\n" + "=" * 70)
print("DEVIATION TABLE (% error from target)")
print("=" * 70)
# Header
header = " |"
for lumen in LUMEN_POINTS:
header += f" {lumen:4d}lm |"
print(header)
print("-" * len(header))
max_cct_dev = 0
max_lumen_dev = 0
all_within_tolerance = True
cct_devs = []
lumen_devs = []
for cct in CCT_POINTS:
row = f"{cct:5d}K |"
for lumen in LUMEN_POINTS:
key = (cct, lumen)
if key in self.measurements:
m = self.measurements[key]
cct_dev = (m['measured_cct'] - cct) / cct * 100
lumen_dev = (m['measured_lumen'] - lumen) / lumen * 100
cct_devs.append(cct_dev)
lumen_devs.append(lumen_dev)
max_cct_dev = max(max_cct_dev, abs(cct_dev))
max_lumen_dev = max(max_lumen_dev, abs(lumen_dev))
if abs(cct_dev) > 5 or abs(lumen_dev) > 5:
all_within_tolerance = False
row += f"{cct_dev:+4.1f}/{lumen_dev:+4.1f}|"
else:
row += " --- |"
all_within_tolerance = False
print(row)
# Calculate standard deviation
def calc_std(values):
if len(values) < 2:
return 0.0
mean = sum(values) / len(values)
variance = sum((x - mean) ** 2 for x in values) / len(values)
return variance ** 0.5
cct_std = calc_std(cct_devs)
lumen_std = calc_std(lumen_devs)
print("-" * len(header))
print(f"Format: CCT%/Lumen% | Max CCT: {max_cct_dev:.1f}% | Max Lumen: {max_lumen_dev:.1f}%")
print(f"Standard deviation | CCT: {cct_std:.2f}% | Lumen: {lumen_std:.2f}%")
return all_within_tolerance, max_cct_dev, max_lumen_dev
def calibrate_y_data(self):
"""Calibrate y-data tables by measuring lumen output at fixed drive points.
Returns (y27_cal, y50_cal) or (None, None) on failure.
"""
print("\n" + "=" * 70)
print("Y-DATA CALIBRATION")
print("=" * 70)
print("Measuring lumen output at fixed drive currents")
print(f"Drive points (mA): {X_DATA}")
print("=" * 70)
# First, clear any existing calibration to use defaults
print("\nClearing existing calibration to use default tables...")
time.sleep(0.2) # Brief delay to ensure UART is idle
self.ser.reset_input_buffer() # Clear any pending diagnostics
if not self.cal_clear():
print("Failed to clear calibration")
return None, None
time.sleep(1.0)
# Calibrate 2700K LED (use 2600K CCT = pure warm LED, mixing ratio 0)
print("\n--- Calibrating 2700K LED (pure warm) ---")
y27_cal = self._measure_led_curve(2600, Y27_DEFAULT)
if y27_cal is None:
return None, None
# Turn off LEDs and wait for thermal stabilization before switching
print("\nTurning off LEDs, waiting 10s for thermal stabilization...")
self.set_intensity(0)
time.sleep(10.0)
# Calibrate 5000K LED (use 5000K CCT = pure cool LED, mixing ratio 255)
print("\n--- Calibrating 5000K LED (pure cool) ---")
y50_cal = self._measure_led_curve(5000, Y50_DEFAULT)
if y50_cal is None:
return None, None
print("\n" + "=" * 70)
print("Y-DATA CALIBRATION RESULTS")
print("=" * 70)
print(f" mA points: {X_DATA}")
print(f" y27 (2700K): {y27_cal} (default: {Y27_DEFAULT})")
print(f" y50 (5000K): {y50_cal} (default: {Y50_DEFAULT})")
return y27_cal, y50_cal
def _measure_led_curve(self, cct, default_lumens):
"""Measure lumen output at each drive point for a single LED type.
Args:
cct: Color temperature to set (2600 for pure 2700K LED, 5000 for pure 5000K LED)
default_lumens: Default y-data values to use as intensity targets
Returns:
List of 5 measured lumen values, or None on failure
"""
measured = []
# Set color (this determines which LED is primarily driven)
if not self.set_color(cct):
print(f" Failed to set color to {cct}K")
return None
time.sleep(0.5)
for i, target_lumen in enumerate(default_lumens):
ma = X_DATA[i]
print(f" Point {i+1}/5: {ma:3d}mA (intensity={target_lumen:3d})", end="")
if ma == 0:
# Zero point is always 0 lumen
measured.append(0)
print(" -> 0 lm (zero point)")
continue
# Set intensity to the default lumen value
# With cleared calibration (defaults), this drives at the target mA
if not self.set_intensity(target_lumen):
print(" - FAILED to set intensity")
return None
# Wait for LED to stabilize (longer for last point at max current)
stabilize_time = 5.0 if i == len(default_lumens) - 1 else 2.0
time.sleep(stabilize_time)
# Take measurement with retry
reading = None
for attempt in range(3):
reading = self.take_measurement()
if reading and reading['lumen'] > 0:
break
time.sleep(1.0)
if reading is None:
print(" - NO READING")
return None
actual_lumen = int(round(reading['lumen']))
deviation = (actual_lumen - target_lumen) / target_lumen * 100 if target_lumen > 0 else 0
measured.append(actual_lumen)
print(f" -> {actual_lumen:4d} lm ({deviation:+5.1f}%)")
return measured
def run_calibration(self, skip_y=False):
"""Run full calibration: y-data first, then iterative mixing ratios.
Args:
skip_y: If True, skip y-data calibration (only do mixing ratios)
"""
print("=" * 70)
print("LED Light Full Calibration")
print("=" * 70)
print("Phase 1: Y-data calibration (LED lumen curves)")
print("Phase 2: Mixing ratio calibration (CCT/lumen targeting)")
print("=" * 70)
self.connect()
y27_cal = None
y50_cal = None
try:
# Disable profile mode
print("\nDisabling profile mode...")
self.disable_profile()
time.sleep(0.5)
# Phase 1: Y-data calibration
if not skip_y:
y27_cal, y50_cal = self.calibrate_y_data()
if y27_cal is None:
print("\nY-data calibration failed, aborting.")
return
# Write y-data immediately with default mixing ratios
print("\nWriting y-data to device...")
default_mixing = [
[ 0, 0, 0, 0, 0],
[ 32, 38, 36, 37, 36],
[ 84, 72, 70, 71, 71],
[142, 100, 99, 99, 99],
[175, 131, 126, 127, 126],
[155, 154, 154, 157, 153],
[206, 170, 184, 184, 185],
[242, 223, 208, 216, 217],
[255, 255, 255, 255, 255]
]
if not self.write_calibration_table(default_mixing, y27_cal, y50_cal, verbose=False):
print("Failed to write y-data, aborting.")
return
print("Y-data written, proceeding to mixing ratio calibration...")
time.sleep(1.0)
# Phase 2: Mixing ratio calibration
print("\n" + "=" * 70)
print("MIXING RATIO CALIBRATION")
print("=" * 70)
print("Target: < 5% deviation on all points")
print("Maximum passes: 10")
max_passes = 10
for pass_num in range(1, max_passes + 1):
print(f"\n{'='*70}")
print(f"PASS {pass_num}/{max_passes}")
print("=" * 70)
# Clear previous measurements for fresh pass
self.measurements = {}
# Measure all points
print("\nMeasuring all points...")
self.measure_all_points()
# Print deviation table
converged, max_cct, max_lumen = self.print_deviation_table()
# Save progress
self.save_calibration()
if converged:
print(f"\n*** CONVERGED after {pass_num} pass(es)! ***")
print("All points within 5% tolerance.")
break
if pass_num == max_passes:
print(f"\n*** MAX PASSES REACHED ***")
print(f"Best achieved: CCT {max_cct:.1f}%, Lumen {max_lumen:.1f}%")
break
# Generate and apply corrections
print("\nCalculating and applying corrections...")
calibrated_table = self.generate_table(verbose=False)
if calibrated_table:
if self.write_calibration_table(calibrated_table, y27_cal, y50_cal, verbose=False):
print("Corrections applied, starting next pass...")
time.sleep(1.0)
else:
print("Failed to write calibration, stopping.")
break
finally:
self.disconnect()
print("\n" + "=" * 70)
print("Calibration complete!")
print("=" * 70)
def save_calibration(self):
"""Save current calibration state to file."""
data = {
'measurements': {
f"{k[0]},{k[1]}": v for k, v in self.measurements.items()
},
'cct_points': CCT_POINTS,
'lumen_points': LUMEN_POINTS
}
with open(CALIBRATION_OUTPUT, 'w') as f:
json.dump(data, f, indent=2)
def generate_table(self, verbose=False):
"""
Generate the calibrated mixing ratio table using direct proportional correction.
Simple approach: mixing ratio controls CCT.
- If measured CCT < target CCT: increase ratio (more 5000K LED)
- If measured CCT > target CCT: decrease ratio (less 5000K LED)
- Correction is proportional to error, scaled by CCT-to-ratio gradient
"""
if verbose:
print("\nGenerating calibration table...")
# Read current mixing ratios from device (baseline)
baseline_table = None
if self.ser and self.ser.is_open:
result = self.read_calibration_table(verbose=verbose)
if result:
baseline_table, _ = result
if verbose:
print("Using current device values as baseline")
if baseline_table is None:
if verbose:
print("Using firmware defaults as baseline")
baseline_table = [
[ 0, 0, 0, 0, 0], # 2600K
[ 32, 38, 36, 37, 36], # 2900K
[ 84, 72, 70, 71, 71], # 3200K
[142, 100, 99, 99, 99], # 3500K
[175, 131, 126, 127, 126], # 3800K
[155, 154, 154, 157, 153], # 4100K
[206, 170, 184, 184, 185], # 4400K
[242, 223, 208, 216, 217], # 4700K
[255, 255, 255, 255, 255] # 5000K
]
# CCT range: 2600K to 5000K = 2400K maps to ratio 0-255
# Gradient: ~9.4 K per ratio unit (but use 10 for simpler math)
K_PER_RATIO = 10.0
corrections = {}
for (cct, lumen), m in self.measurements.items():
i = CCT_POINTS.index(cct)
j = LUMEN_POINTS.index(lumen)
baseline_r = baseline_table[i][j]
target_cct = m['target_cct']
target_lumen = m['target_lumen']
measured_cct = m['measured_cct']
measured_lumen = m['measured_lumen']
cct_error = target_cct - measured_cct # positive = need higher CCT = more 5000K = higher ratio
lumen_error = target_lumen - measured_lumen
# Simple proportional correction: dr = cct_error / K_PER_RATIO
# If measured CCT is 100K too low, increase ratio by 10 units
dr = cct_error / K_PER_RATIO
# Apply correction with gain factor (can be tuned)
# Use 1.0 for full correction, <1 for damping, >1 for aggressive
gain = 1.0
dr = dr * gain
# Limit adjustment per iteration to prevent oscillation
dr = max(-30, min(30, dr))
corrections[(cct, lumen)] = {
'cct_error': cct_error,
'lumen_error': lumen_error,
'lumen_error_pct': 100 * lumen_error / target_lumen if target_lumen > 0 else 0,
'mixing_adjust': dr,
'baseline_ratio': baseline_r
}
# Apply corrections to generate calibrated table
calibrated_table = []
for i, cct in enumerate(CCT_POINTS):
row = []
for j, lumen in enumerate(LUMEN_POINTS):
baseline_r = baseline_table[i][j]
key = (cct, lumen)
if key in corrections:
c = corrections[key]
adjusted = baseline_r + c['mixing_adjust']
adjusted = max(0, min(255, round(adjusted)))
else:
adjusted = baseline_r
row.append(int(adjusted))
calibrated_table.append(row)
# Print correction details
if verbose:
print("\nCorrection summary:")
print(f"{'CCT':>5} {'Lumen':>5} | {'CCT err':>8} {'Lum err':>8} | {'Base':>4} {'Adj':>6} {'New':>4}")
print("-" * 65)
for i, cct in enumerate(CCT_POINTS):
for j, lumen in enumerate(LUMEN_POINTS):
key = (cct, lumen)
if key in corrections:
c = corrections[key]
new_r = calibrated_table[i][j]
print(f"{cct:>5} {lumen:>5} | {c['cct_error']:>+7.0f}K {c['lumen_error']:>+7.0f}lm | {c['baseline_ratio']:>4} {c['mixing_adjust']:>+6.1f} {new_r:>4}")
# Print final table
print("\nCalibrated mixing_ratios table:")
print("const uint8_t mixing_ratios[NUM_CCT][NUM_LUMEN] = {")
for i, (cct, row) in enumerate(zip(CCT_POINTS, calibrated_table)):
values = ", ".join(f"{v:3d}" for v in row)
comment = f"// {cct}K"
if i == 0:
comment += " (pure 2700K)"
elif i == len(CCT_POINTS) - 1:
comment += " (pure 5000K)"
comma = "," if i < len(CCT_POINTS) - 1 else ""
print(f" {{{values}}}{comma} {comment}")
print("};")
# Save to calibration file
data = {
'measurements': {f"{k[0]},{k[1]}": v for k, v in self.measurements.items()},
'corrections': {f"{k[0]},{k[1]}": v for k, v in corrections.items()},
'cct_points': CCT_POINTS,
'lumen_points': LUMEN_POINTS,
'baseline_table': baseline_table,
'calibrated_table': calibrated_table
}
with open(CALIBRATION_OUTPUT, 'w') as f:
json.dump(data, f, indent=2)
if verbose:
print(f"\nCalibration saved to {CALIBRATION_OUTPUT}")
return calibrated_table
def main():
parser = argparse.ArgumentParser(description='LED Light Calibration Tool')
parser.add_argument('port', nargs='?', default='COM3',
help='Serial port (default: COM3)')
parser.add_argument('--baudrate', '-b', type=int, default=115200,
help='Baud rate (default: 115200)')
parser.add_argument('--spectrometer', '-s', type=Path,
default=DEFAULT_SPECTROMETER,
help='Path to spectrometer script (default: spectrometer.py)')
parser.add_argument('--generate-only', '-g', action='store_true',
help='Only generate table from existing measurements')
parser.add_argument('--write', '-w', action='store_true',
help='Write calibration table to device')
parser.add_argument('--clear', '-c', action='store_true',
help='Clear calibration on device (revert to defaults)')
parser.add_argument('--read', action='store_true',
help='Read calibration table from device')
parser.add_argument('--skip-y', action='store_true',
help='Skip y-data calibration (only do mixing ratios)')
parser.add_argument('--y-only', action='store_true',
help='Only run y-data calibration (no mixing ratios)')
args = parser.parse_args()
tool = CalibrationTool(args.port, args.baudrate, args.spectrometer)
if args.read:
# Read calibration from device
tool.connect()
try:
result = tool.read_calibration_table()
if result:
table, valid = result
status = "CALIBRATED" if valid else "DEFAULTS"
print(f"\nCalibration status: {status}")
print("\nMixing ratios table:")
for i, (cct, row) in enumerate(zip(CCT_POINTS, table)):
values = ", ".join(f"{v:3d}" for v in row)
print(f" {cct}K: [{values}]")
finally:
tool.disconnect()
elif args.clear:
# Clear calibration on device
tool.connect()
try:
if tool.cal_clear():
print("Calibration cleared successfully")
else:
print("Failed to clear calibration")
finally:
tool.disconnect()
elif args.y_only:
# Only run y-data calibration
tool.connect()
try:
tool.disable_profile()
time.sleep(0.5)
y27_cal, y50_cal = tool.calibrate_y_data()
if y27_cal and y50_cal:
# Write y-data with default mixing ratios
print("\nWriting y-data calibration (with default mixing ratios)...")
default_mixing = [
[ 0, 0, 0, 0, 0], # 2600K
[ 32, 38, 36, 37, 36], # 2900K
[ 84, 72, 70, 71, 71], # 3200K
[142, 100, 99, 99, 99], # 3500K
[175, 131, 126, 127, 126], # 3800K
[155, 154, 154, 157, 153], # 4100K
[206, 170, 184, 184, 185], # 4400K
[242, 223, 208, 216, 217], # 4700K
[255, 255, 255, 255, 255] # 5000K
]
if tool.write_calibration_table(default_mixing, y27_cal, y50_cal):
print("Y-data calibration saved successfully!")
# Save to file as well
data = {
'y27_cal': y27_cal,
'y50_cal': y50_cal,
'x_data': X_DATA
}
with open(CALIBRATION_OUTPUT, 'w') as f:
json.dump(data, f, indent=2)
else:
print("Failed to write y-data calibration")
finally:
tool.disconnect()
elif args.generate_only or args.write:
# Load existing measurements and generate/write table
if CALIBRATION_OUTPUT.exists():
with open(CALIBRATION_OUTPUT, 'r') as f:
saved = json.load(f)
if 'measurements' in saved:
tool.measurements = {
tuple(map(int, k.split(','))): v
for k, v in saved['measurements'].items()
}
# Connect to read current values from device
tool.connect()
try:
table = tool.generate_table(verbose=True)
if args.write and table:
# Load y-data if available
y27 = saved.get('y27_cal')
y50 = saved.get('y50_cal')
tool.write_calibration_table(table, y27, y50)
finally:
tool.disconnect()
else:
print(f"No calibration file found: {CALIBRATION_OUTPUT}")
else:
tool.run_calibration(skip_y=args.skip_y)
if __name__ == "__main__":
main()
-887
View File
@@ -1,887 +0,0 @@
"""
HPCS 6500 Spectrophotometer / Integrating Sphere — Direct Reader
Communicates with the HPCS 6500 over USB serial (STM32 VCP) to take
measurements and extract photometric, colorimetric, radiometric,
electrical, and spectral data.
Usage:
uv run hpcs6500.py # auto-detect port, single reading
uv run hpcs6500.py --port COM42 # specify port
uv run hpcs6500.py --continuous # continuous readings
uv run hpcs6500.py --csv out.csv # save to CSV
uv run hpcs6500.py --spectrum # also print full spectrum
"""
import argparse
import csv
import struct
import sys
import time
from datetime import datetime
import serial
import serial.tools.list_ports
# Protocol constants
SYNC = 0x8C
CMD_IDENTIFY = 0x00
CMD_INTEGRATION_TIME = 0x01
CMD_POLL_STATE = 0x03
CMD_STATUS = 0x05
CMD_START = 0x0E
CMD_READ_MEASUREMENT = 0x13
CMD_RESET = 0x25
CMD_CONFIG = 0x2A
CMD_SET_DC = 0x73
CMD_READ_ELECTRICAL = 0x77
CMD_SET_AC = 0x78
CMD_READ_PSU = 0x79
CMD_PSU_OUTPUT = 0x72
CMD_SET_MODE = 0x7A
CMD_TEST_CONFIG = 0x2B
# Single-test config payloads captured from vendor software.
# Sub 00: test parameters, sub 01: test config with auto-PSU flag at byte 37.
_TEST_CONFIG_SUB0 = bytes.fromhex(
"00003c00000001008813037c011a04000000484200000208"
"000000000001060000000000803f0000803f0000803f0000"
"803f0000803f00000000003f"
)
_TEST_CONFIG_SUB1_BASE = bytes.fromhex(
"017c010c037c010c037c010c030000803f000000000000000000000000"
"00000000000000000001000000400000803f"
)
# State polling byte[5] values
STATE_IDLE = 0x04
STATE_MEASURING = 0x01
# Spectrum parameters
SPECTRUM_START_NM = 380
SPECTRUM_END_NM = 1050
SPECTRUM_OFFSET = 432
SPECTRUM_COUNT = 350
SPECTRUM_STEP = (SPECTRUM_END_NM - SPECTRUM_START_NM) / (SPECTRUM_COUNT - 1)
# Measurement block field offsets (all float32 LE)
FIELDS = {
"Phi_lm": 36,
"eta_lm_W": 40,
"CCT_K": 44,
"Duv": 48,
"x": 52,
"y": 56,
"u": 60,
"v": 64,
"u_prime": 68,
"v_prime": 72,
"SDCM": 76,
"Ra": 80,
"R1": 84,
"R2": 88,
"R3": 92,
"R4": 96,
"R5": 100,
"R6": 104,
"R7": 108,
"R8": 112,
"R9": 116,
"R10": 120,
"R11": 124,
"R12": 128,
"R13": 132,
"R14": 136,
"R15": 140,
"Phi_e_mW": 144,
"Phi_euv_mW": 148,
"Phi_eb_mW": 152,
"Phi_ey_mW": 156,
"Phi_er_mW": 160,
"Phi_efr_mW": 164,
"Phi_eir_mW": 168,
"Phi_e_total": 172,
"CIE_X": 224,
"CIE_Y": 228,
"CIE_Z": 232,
"TLCI": 236,
"PeakSignal": 244,
"DarkSignal": 248,
"Compensate": 252,
}
# Electrical block field offsets
ELECTRICAL_FIELDS = {
"Voltage_V": 8,
"Current_A": 12,
"Power_W": 16,
"Freq_Hz": 20,
"PF": 24,
}
# Harmonics offsets within the electrical block
HARMONICS_V_OFFSET = 544 # 50 x float32 voltage harmonics (%)
HARMONICS_V_COUNT = 50
UTHD_OFFSET = 744 # float32 UThd (%)
HARMONICS_I_OFFSET = 800 # 50 x float32 current harmonics (%)
HARMONICS_I_COUNT = 50
ATHD_OFFSET = 1000 # float32 AThd (%)
# Waveform offsets (int16 LE samples, 128 per waveform)
WAVEFORM_V_OFFSET = 30
WAVEFORM_I_OFFSET = 286
WAVEFORM_SAMPLES = 128
def parse_pcap_messages(filepath):
"""Parse a USBPcap pcap file and extract bulk transfer payloads."""
with open(filepath, "rb") as f:
data = f.read()
magic = struct.unpack_from("<I", data, 0)[0]
endian = "<" if magic == 0xA1B2C3D4 else ">"
offset = 24
messages = []
first_ts = None
while offset + 16 <= len(data):
ts_sec, ts_usec, incl_len, orig_len = struct.unpack_from(
f"{endian}IIII", data, offset
)
offset += 16
if offset + incl_len > len(data):
break
pkt_data = data[offset : offset + incl_len]
offset += incl_len
if len(pkt_data) < 27:
continue
hdr_len = struct.unpack_from("<H", pkt_data, 0)[0]
endpoint = pkt_data[21]
transfer = pkt_data[22]
payload = pkt_data[hdr_len:]
if len(payload) == 0 or transfer != 3:
continue
ts = ts_sec + ts_usec / 1_000_000
if first_ts is None:
first_ts = ts
ep_dir = "IN" if (endpoint & 0x80) else "OUT"
messages.append(
{
"ts": ts - first_ts,
"dir": "TX" if ep_dir == "OUT" else "RX",
"data": payload,
}
)
return messages
class HPCS6500:
"""Driver for the HPCS 6500 spectrophotometer."""
def __init__(self, port, timeout=2.0):
self.ser = serial.Serial(
port=port,
baudrate=115200,
timeout=timeout,
write_timeout=timeout,
)
self.port = port
def close(self):
self.ser.close()
def _send(self, *cmd_bytes):
"""Send a command to the device."""
data = bytes([SYNC] + list(cmd_bytes))
self.ser.write(data)
def _recv(self, expected_len, timeout=3.0):
"""Read expected_len bytes from the device."""
data = b""
deadline = time.monotonic() + timeout
while len(data) < expected_len:
remaining = expected_len - len(data)
chunk = self.ser.read(remaining)
if chunk:
data += chunk
elif time.monotonic() > deadline:
break
return data
def _recv_response(self, cmd_id, timeout=3.0):
"""Read a response, expecting it to start with 8C <cmd_id>."""
# Read the 2-byte header first
hdr = self._recv(2, timeout)
if len(hdr) < 2:
return None
if hdr[0] != SYNC or hdr[1] != cmd_id:
# Try to resync — read until we find 8C <cmd_id>
buf = hdr
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
b = self.ser.read(1)
if not b:
continue
buf += b
idx = buf.find(bytes([SYNC, cmd_id]))
if idx >= 0:
hdr = buf[idx:idx+2]
break
else:
return None
return hdr
def identify(self):
"""Send identify command, return device name string."""
self.ser.reset_input_buffer()
self._send(CMD_IDENTIFY)
resp = self._recv(16, timeout=2.0)
if len(resp) < 16:
return None
if resp[0] != SYNC or resp[1] != CMD_IDENTIFY:
return None
name = resp[2:10].decode("ascii", errors="replace").rstrip("\x00")
return name
def read_measurement_block(self):
"""Send 8C 13 and read the 3904-byte measurement block."""
self.ser.reset_input_buffer()
self._send(CMD_READ_MEASUREMENT)
# First, read the 4-byte header: 8C 13 0F 40
hdr = self._recv(4, timeout=3.0)
if len(hdr) < 4 or hdr[0] != SYNC or hdr[1] != CMD_READ_MEASUREMENT:
return None
# Then read the 3904-byte payload
payload = self._recv(3904, timeout=5.0)
if len(payload) < 3904:
print(f"Warning: measurement block short ({len(payload)}/3904 bytes)")
return None
return payload
def read_electrical_block(self):
"""Send 8C 77 and read the 1584-byte electrical block."""
self.ser.reset_input_buffer()
self._send(CMD_READ_ELECTRICAL)
# 4-byte header: 8C 77 06 30
hdr = self._recv(4, timeout=3.0)
if len(hdr) < 4 or hdr[0] != SYNC or hdr[1] != CMD_READ_ELECTRICAL:
return None
payload = self._recv(1584, timeout=5.0)
if len(payload) < 1584:
print(f"Warning: electrical block short ({len(payload)}/1584 bytes)")
return None
return payload
def poll_state(self):
"""Send 8C 03 and return (data_available, state)."""
self.ser.reset_input_buffer()
self._send(CMD_POLL_STATE)
resp = self._recv(9, timeout=2.0)
if len(resp) < 9 or resp[0] != SYNC or resp[1] != CMD_POLL_STATE:
return None, None
data_available = resp[2] == 1
state = resp[5]
return data_available, state
def send_test_config(self, auto_psu=False):
"""Send single-test configuration (8C 2B sub 00 + sub 01).
If auto_psu=True, device handles PSU on/off automatically."""
self.ser.reset_input_buffer()
self.ser.write(bytes([SYNC, CMD_TEST_CONFIG]) + _TEST_CONFIG_SUB0)
resp = self._recv(2, timeout=2.0)
if resp != bytes([SYNC, CMD_TEST_CONFIG]):
return False
sub1 = bytearray(_TEST_CONFIG_SUB1_BASE)
if auto_psu:
sub1[37] = 0x01
self.ser.reset_input_buffer()
self.ser.write(bytes([SYNC, CMD_TEST_CONFIG]) + bytes(sub1))
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_TEST_CONFIG])
def start_measurement(self):
"""Send 8C 0E 01 to start measurement."""
self.ser.reset_input_buffer()
self._send(CMD_START, 0x01)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_START])
def stop_measurement(self):
"""Send 8C 0E 02 to stop measurement."""
self.ser.reset_input_buffer()
self._send(CMD_START, 0x02)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_START])
def reset(self):
"""Send 8C 25 to reset."""
self.ser.reset_input_buffer()
self._send(CMD_RESET)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_RESET])
# --- Power supply control ---
def psu_on(self):
"""Turn PSU output on."""
self.ser.reset_input_buffer()
self._send(CMD_PSU_OUTPUT, 0x00)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_PSU_OUTPUT])
def psu_off(self):
"""Turn PSU output off."""
self.ser.reset_input_buffer()
self._send(CMD_PSU_OUTPUT, 0x01)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_PSU_OUTPUT])
def set_mode(self, mode):
"""Set output mode: 'ac' or 'dc'."""
mode_byte = 0x00 if mode.lower() == "ac" else 0x01
self.ser.reset_input_buffer()
self._send(CMD_SET_MODE, mode_byte)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_SET_MODE])
def set_ac_voltage(self, volts):
"""Set AC output voltage (100-240 V)."""
self.ser.reset_input_buffer()
data = bytes([SYNC, CMD_SET_AC, 0x00]) + struct.pack("<f", volts)
self.ser.write(data)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_SET_AC])
def set_ac_frequency(self, hz):
"""Set AC output frequency (50 or 60 Hz)."""
self.ser.reset_input_buffer()
data = bytes([SYNC, CMD_SET_AC, 0x01]) + struct.pack("<f", hz)
self.ser.write(data)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_SET_AC])
def set_dc_voltage(self, volts):
"""Set DC output voltage (1-60 V)."""
self.ser.reset_input_buffer()
data = bytes([SYNC, CMD_SET_DC, 0x01]) + struct.pack("<f", volts)
self.ser.write(data)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_SET_DC])
def set_dc_current(self, amps):
"""Set DC current limit (0-5 A)."""
self.ser.reset_input_buffer()
data = bytes([SYNC, CMD_SET_DC, 0x00]) + struct.pack("<f", amps)
self.ser.write(data)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_SET_DC])
def set_integration_time(self, ms):
"""Set integration time in milliseconds. Use 0 for auto."""
us = int(ms * 1000)
self.ser.reset_input_buffer()
data = bytes([SYNC, CMD_INTEGRATION_TIME]) + struct.pack("<I", us)
self.ser.write(data)
resp = self._recv(2, timeout=2.0)
return resp == bytes([SYNC, CMD_INTEGRATION_TIME])
def read_psu_settings(self):
"""Read current power supply settings."""
self.ser.reset_input_buffer()
self._send(CMD_READ_PSU)
resp = self._recv(20, timeout=2.0)
if len(resp) < 20 or resp[0] != SYNC or resp[1] != CMD_READ_PSU:
return None
payload = resp[2:]
return {
"ac_voltage": struct.unpack_from("<f", payload, 0)[0],
"ac_frequency": struct.unpack_from("<f", payload, 4)[0],
"dc_voltage": struct.unpack_from("<f", payload, 8)[0],
"dc_current": struct.unpack_from("<f", payload, 12)[0],
"mode": "DC" if payload[16] == 1 else "AC",
}
def wait_for_data(self, timeout=30.0):
"""Poll until data is available or timeout."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
data_avail, state = self.poll_state()
if data_avail:
return True
time.sleep(0.1)
return False
def take_reading(self, psu=True):
"""Take a single measurement reading. Returns dict or None.
If psu=True, turns PSU output on before measuring and off after."""
if psu:
self.psu_on()
time.sleep(0.2)
# Start measurement
if not self.start_measurement():
print("Failed to start measurement")
if psu:
self.psu_off()
return None
# Wait for data
if not self.wait_for_data(timeout=30.0):
print("Timeout waiting for measurement data")
self.stop_measurement()
if psu:
self.psu_off()
return None
# Read measurement block
meas = self.read_measurement_block()
elec = self.read_electrical_block()
if meas is None:
print("Failed to read measurement block")
self.stop_measurement()
if psu:
self.psu_off()
return None
result = self._parse_measurement(meas)
if elec is not None:
result.update(self._parse_electrical(elec))
# Stop measurement
self.stop_measurement()
if psu:
self.psu_off()
return result
def take_single_reading(self):
"""Trigger a single measurement matching the vendor's single-test flow.
Sends stop to trigger a one-shot reading, waits for data, reads it,
then resets. The device should be in single-test mode (configured via
vendor software UI or send_test_config())."""
self.stop_measurement()
if not self.wait_for_data(timeout=30.0):
print("Timeout waiting for measurement data")
return None
meas = self.read_measurement_block()
elec = self.read_electrical_block()
if meas is None:
print("Failed to read measurement block")
return None
result = self._parse_measurement(meas)
if elec is not None:
result.update(self._parse_electrical(elec))
self.reset()
return result
def read_current(self):
"""Read current data without starting/stopping measurement.
Use this when the vendor software is controlling the instrument."""
meas = self.read_measurement_block()
elec = self.read_electrical_block()
if meas is None:
return None
result = self._parse_measurement(meas)
if elec is not None:
result.update(self._parse_electrical(elec))
return result
def _parse_measurement(self, data):
"""Parse the 3904-byte measurement block into a dict."""
result = {}
# Check header
header = data[:10].decode("ascii", errors="replace").rstrip("\x00")
result["device"] = header
# Extract all named fields
for name, offset in FIELDS.items():
if offset + 4 <= len(data):
result[name] = struct.unpack_from("<f", data, offset)[0]
# Extract date/time strings
date_off = 272
time_off = 283
if date_off + 10 <= len(data):
date_str = data[date_off:date_off+10].decode("ascii", errors="replace").rstrip("\x00")
result["test_date"] = date_str
if time_off + 8 <= len(data):
time_str = data[time_off:time_off+8].decode("ascii", errors="replace").rstrip("\x00")
result["test_time"] = time_str
# Extract spectrum
spectrum = []
for i in range(SPECTRUM_COUNT):
off = SPECTRUM_OFFSET + i * 4
if off + 4 <= len(data):
val = struct.unpack_from("<f", data, off)[0]
spectrum.append(val)
result["spectrum"] = spectrum
result["spectrum_nm"] = [
SPECTRUM_START_NM + i * SPECTRUM_STEP for i in range(len(spectrum))
]
return result
def _parse_electrical(self, data):
"""Parse the 1584-byte electrical block into a dict."""
result = {}
for name, offset in ELECTRICAL_FIELDS.items():
if offset + 4 <= len(data):
result[name] = struct.unpack_from("<f", data, offset)[0]
# Check if harmonics data is present (non-zero at harmonics offset)
if len(data) >= ATHD_OFFSET + 4:
h1_v = struct.unpack_from("<f", data, HARMONICS_V_OFFSET)[0]
if abs(h1_v - 100.0) < 1.0: # H1 should be ~100%
# Voltage harmonics
v_harmonics = []
for i in range(HARMONICS_V_COUNT):
off = HARMONICS_V_OFFSET + i * 4
v_harmonics.append(struct.unpack_from("<f", data, off)[0])
result["V_harmonics"] = v_harmonics
result["UThd"] = struct.unpack_from("<f", data, UTHD_OFFSET)[0]
# Current harmonics
i_harmonics = []
for i in range(HARMONICS_I_COUNT):
off = HARMONICS_I_OFFSET + i * 4
i_harmonics.append(struct.unpack_from("<f", data, off)[0])
result["I_harmonics"] = i_harmonics
result["AThd"] = struct.unpack_from("<f", data, ATHD_OFFSET)[0]
# Waveforms (int16 LE)
v_waveform = []
for i in range(WAVEFORM_SAMPLES):
off = WAVEFORM_V_OFFSET + i * 2
v_waveform.append(struct.unpack_from("<h", data, off)[0])
result["V_waveform"] = v_waveform
i_waveform = []
for i in range(WAVEFORM_SAMPLES):
off = WAVEFORM_I_OFFSET + i * 2
i_waveform.append(struct.unpack_from("<h", data, off)[0])
result["I_waveform"] = i_waveform
return result
def find_hpcs_port():
"""Auto-detect the HPCS 6500 COM port."""
for port in serial.tools.list_ports.comports():
if port.vid == 0x0483 and port.pid == 0x5741:
return port.device
return None
def format_reading(r, show_spectrum=False):
"""Format a reading dict for display."""
lines = []
lines.append(f" Device: {r.get('device', '?')}")
lines.append(f" Date: {r.get('test_date', '?')} Time: {r.get('test_time', '?')}")
lines.append("")
lines.append(" --- Photometric ---")
lines.append(f" Phi (lm): {r.get('Phi_lm', 0):10.2f}")
lines.append(f" eta (lm/W): {r.get('eta_lm_W', 0):10.2f}")
lines.append(f" CCT (K): {r.get('CCT_K', 0):10.0f}")
lines.append(f" Duv: {r.get('Duv', 0):10.5f}")
lines.append("")
lines.append(" --- Chromaticity ---")
lines.append(f" x: {r.get('x', 0):.4f} y: {r.get('y', 0):.4f}")
lines.append(f" u: {r.get('u', 0):.4f} v: {r.get('v', 0):.4f}")
lines.append(f" u': {r.get('u_prime', 0):.4f} v': {r.get('v_prime', 0):.4f}")
lines.append("")
lines.append(" --- CRI ---")
ra = r.get('Ra', 0)
lines.append(f" Ra: {ra:.1f} SDCM: {r.get('SDCM', 0):.2f}")
ri = []
for i in range(1, 16):
ri.append(f"R{i}={r.get(f'R{i}', 0):.0f}")
lines.append(f" {', '.join(ri[:8])}")
lines.append(f" {', '.join(ri[8:])}")
lines.append("")
lines.append(" --- CIE 1931 ---")
lines.append(f" X: {r.get('CIE_X', 0):.3f} Y: {r.get('CIE_Y', 0):.3f} Z: {r.get('CIE_Z', 0):.3f}")
lines.append(f" TLCI-2012: {r.get('TLCI', 0):.0f}")
lines.append("")
lines.append(" --- Radiometric ---")
lines.append(f" Phi_e (mW): {r.get('Phi_e_mW', 0):10.3f}")
lines.append(f" Phi_eb (mW): {r.get('Phi_eb_mW', 0):10.3f}")
lines.append(f" Phi_ey (mW): {r.get('Phi_ey_mW', 0):10.3f}")
lines.append(f" Phi_er (mW): {r.get('Phi_er_mW', 0):10.3f}")
lines.append(f" Phi_efr (mW): {r.get('Phi_efr_mW', 0):10.3f}")
lines.append("")
lines.append(" --- Electrical ---")
lines.append(f" Voltage (V): {r.get('Voltage_V', 0):10.2f}")
lines.append(f" Current (A): {r.get('Current_A', 0):10.4f}")
lines.append(f" Power (W): {r.get('Power_W', 0):10.3f}")
lines.append(f" Frequency (Hz):{r.get('Freq_Hz', 0):10.2f}")
lines.append(f" Power Factor: {r.get('PF', 0):10.4f}")
if "UThd" in r:
lines.append(f" UThd (%): {r['UThd']:10.4f}")
lines.append(f" AThd (%): {r.get('AThd', 0):10.4f}")
lines.append("")
if show_spectrum and "V_harmonics" in r:
lines.append(" --- Voltage Harmonics (%) ---")
vh = r["V_harmonics"]
for i, val in enumerate(vh):
if val > 0.001 or i == 0:
lines.append(f" H{i+1:2d}: {val:8.4f}")
lines.append("")
lines.append(" --- Current Harmonics (%) ---")
ih = r["I_harmonics"]
for i, val in enumerate(ih):
if val > 0.001 or i == 0:
lines.append(f" H{i+1:2d}: {val:8.4f}")
lines.append("")
lines.append(" --- Sensor ---")
lines.append(f" Peak Signal: {r.get('PeakSignal', 0):10.0f}")
lines.append(f" Dark Signal: {r.get('DarkSignal', 0):10.0f}")
lines.append(f" Compensate: {r.get('Compensate', 0):10.0f}")
if show_spectrum and "spectrum" in r:
lines.append("")
lines.append(" --- Spectrum (380-1050nm) ---")
spectrum = r["spectrum"]
nm = r["spectrum_nm"]
peak_val = max(spectrum) if spectrum else 1
for i, (wl, val) in enumerate(zip(nm, spectrum)):
if i % 5 == 0: # Print every 5th point (~10nm spacing)
norm = val / peak_val if peak_val > 0 else 0
bar = "#" * int(norm * 40)
lines.append(f" {wl:7.1f}nm: {val:8.4f} [{bar}]")
return "\n".join(lines)
def format_quick(r, reading_num=None):
"""Format a reading for quick test mode: lumen + CCT only."""
lm = r.get("Phi_lm", 0)
cct = r.get("CCT_K", 0)
prefix = f"#{reading_num} " if reading_num else ""
return f" {prefix}{lm:.1f} lm {cct:.0f} K"
def main():
parser = argparse.ArgumentParser(description="HPCS 6500 Spectrophotometer Reader")
parser.add_argument("--port", help="COM port (auto-detect if not specified)")
parser.add_argument("--continuous", action="store_true", help="Continuous reading mode")
parser.add_argument("--csv", metavar="FILE", help="Save readings to CSV file")
parser.add_argument("--spectrum", action="store_true", help="Show full spectrum data")
parser.add_argument("--passive", action="store_true",
help="Passive mode: read data without controlling instrument")
parser.add_argument("--quick", action="store_true",
help="Quick test mode: only report lumen and CCT")
parser.add_argument("--harmonics", action="store_true",
help="Show harmonics data (voltage/current THD and per-harmonic)")
parser.add_argument("--parse", metavar="PCAP", help="Parse a pcap capture file instead")
# Power supply control
psu = parser.add_argument_group("power supply")
psu.add_argument("--mode", choices=["ac", "dc"], help="Set output mode")
psu.add_argument("--voltage", type=float, help="Set output voltage (V)")
psu.add_argument("--frequency", type=float, help="Set AC frequency (Hz)")
psu.add_argument("--current", type=float, help="Set DC current limit (A)")
psu.add_argument("--integration", type=float, help="Set integration time (ms), 0=auto")
psu.add_argument("--psu-on", action="store_true", help="Turn PSU output on")
psu.add_argument("--psu-off", action="store_true", help="Turn PSU output off")
psu.add_argument("--no-psu", action="store_true",
help="Don't auto-control PSU during measurements")
psu.add_argument("--psu-status", action="store_true", help="Read power supply settings")
args = parser.parse_args()
# --harmonics implies --spectrum for the detailed display
if args.harmonics:
args.spectrum = True
# Parse mode: analyze an existing capture
if args.parse:
messages = parse_pcap_messages(args.parse)
blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 3904
and m["data"][:8] == b"HPCS6500"]
config_blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 1584]
dev = HPCS6500.__new__(HPCS6500)
for i, block in enumerate(blocks):
result = dev._parse_measurement(block["data"])
if i < len(config_blocks):
result.update(dev._parse_electrical(config_blocks[i]["data"]))
if args.quick:
print(format_quick(result, i + 1))
else:
print(f"\n{'='*60}")
print(f"Reading {i+1} (t={block['ts']:.2f}s)")
print(f"{'='*60}")
print(format_reading(result, show_spectrum=args.spectrum))
return
# Find port
port = args.port or find_hpcs_port()
if not port:
print("ERROR: HPCS 6500 not found. Connect the device or specify --port.")
print("\nAvailable ports:")
for p in serial.tools.list_ports.comports():
print(f" {p.device}: {p.description} [{p.hwid}]")
sys.exit(1)
print(f"HPCS 6500 Spectrophotometer Reader")
print(f"Port: {port}")
print("=" * 60)
dev = HPCS6500(port)
# Identify
name = dev.identify()
if name:
print(f"Device: {name}")
else:
print("Warning: no identify response (device may be busy)")
# Power supply control
has_psu_cmd = any([args.mode, args.voltage is not None, args.frequency is not None,
args.current is not None, args.integration is not None,
args.psu_status, args.psu_on, args.psu_off])
if args.psu_status:
settings = dev.read_psu_settings()
if settings:
print(f"\nPower Supply Settings:")
print(f" Mode: {settings['mode']}")
print(f" AC Voltage: {settings['ac_voltage']:.0f} V")
print(f" AC Frequency: {settings['ac_frequency']:.0f} Hz")
print(f" DC Voltage: {settings['dc_voltage']:.1f} V")
print(f" DC Current: {settings['dc_current']:.1f} A")
else:
print("Failed to read PSU settings")
if not args.continuous and args.voltage is None and args.mode is None:
dev.close()
return
if args.mode:
ok = dev.set_mode(args.mode)
print(f"Set mode to {args.mode.upper()}: {'OK' if ok else 'FAILED'}")
if args.voltage is not None:
mode = args.mode or "ac"
if mode == "ac":
ok = dev.set_ac_voltage(args.voltage)
else:
ok = dev.set_dc_voltage(args.voltage)
print(f"Set {mode.upper()} voltage to {args.voltage} V: {'OK' if ok else 'FAILED'}")
if args.frequency is not None:
ok = dev.set_ac_frequency(args.frequency)
print(f"Set AC frequency to {args.frequency} Hz: {'OK' if ok else 'FAILED'}")
if args.current is not None:
ok = dev.set_dc_current(args.current)
print(f"Set DC current limit to {args.current} A: {'OK' if ok else 'FAILED'}")
if args.integration is not None:
ok = dev.set_integration_time(args.integration)
print(f"Set integration time to {args.integration} ms: {'OK' if ok else 'FAILED'}")
if args.psu_on:
ok = dev.psu_on()
print(f"PSU output ON: {'OK' if ok else 'FAILED'}")
if args.psu_off:
ok = dev.psu_off()
print(f"PSU output OFF: {'OK' if ok else 'FAILED'}")
# If only PSU commands were given with no measurement request, exit
if has_psu_cmd and not args.continuous and not args.passive:
dev.close()
return
csv_writer = None
csv_file = None
if args.csv:
csv_file = open(args.csv, "w", newline="")
fieldnames = ["timestamp"] + list(FIELDS.keys()) + list(ELECTRICAL_FIELDS.keys())
csv_writer = csv.DictWriter(csv_file, fieldnames=fieldnames, extrasaction="ignore")
csv_writer.writeheader()
print(f"Saving to: {args.csv}")
# Only control PSU if explicitly requested or in continuous mode
use_psu = args.continuous and not args.no_psu and not args.passive
try:
if use_psu:
dev.psu_on()
time.sleep(0.2)
reading_num = 0
while True:
reading_num += 1
if not args.quick:
print(f"\n{'='*60}")
print(f"Reading {reading_num} ({datetime.now().strftime('%H:%M:%S')})")
print(f"{'='*60}")
if args.passive:
result = dev.read_current()
elif args.continuous:
result = dev.take_reading(psu=False)
else:
result = dev.take_single_reading()
if result is None:
print("Failed to get reading")
if not args.continuous:
break
time.sleep(1)
continue
if args.quick:
print(format_quick(result, reading_num))
else:
print(format_reading(result, show_spectrum=args.spectrum))
if csv_writer:
row = {"timestamp": datetime.now().isoformat()}
row.update({k: v for k, v in result.items()
if isinstance(v, (int, float))})
csv_writer.writerow(row)
csv_file.flush()
if not args.continuous:
break
time.sleep(0.5)
except KeyboardInterrupt:
print("\nStopped.")
finally:
if use_psu:
dev.psu_off()
dev.reset()
dev.close()
if csv_file:
csv_file.close()
print(f"Data saved to: {args.csv}")
if __name__ == "__main__":
main()
-112
View File
@@ -1,112 +0,0 @@
"""
Lamp ID labels on the Brother QL-820NWB (DK-11221, 23x23 mm die-cut).
Each label carries the lamp ID as a QR code and as text. These are internal
inventory labels: scanning one with the station's barcode scanner types the
ID, which drops straight into the re-measure flow. The station prints two
per lamp: one for the box, one for the lamp itself.
Printer address comes from --printer or the HPCS_LABEL_PRINTER environment
variable, e.g. tcp://192.168.1.50 (the QL-820NWB listens on port 9100).
Usage:
uv run label_printer.py L0006 # print 2 labels
uv run label_printer.py L0006 --copies 1
uv run label_printer.py L0006 --preview out.png # render only, no printer
"""
import argparse
import os
import sys
import qrcode
from PIL import Image, ImageDraw, ImageFont
LABEL_ID = "23x23" # DK-11221
DOTS = (202, 202) # printable area of the 23x23 label
MODEL = "QL-820NWB"
TEXT_HEIGHT = 52 # bottom strip for the ID text
def _font(size):
for name in ("arialbd.ttf", "arial.ttf", "DejaVuSans-Bold.ttf"):
try:
return ImageFont.truetype(name, size)
except OSError:
continue
return ImageFont.load_default(size)
def render_label(lamp_id: str) -> Image.Image:
"""202x202 1-bit label: the bare lamp ID as QR on top, as text below."""
image = Image.new("1", DOTS, 1)
# Just the ID (internal use): a 5-character QR stays at version 1, so the
# modules print large and scan reliably at 23 mm. Scanning it at the
# station types the ID and lands in the re-measure flow.
qr = qrcode.QRCode(
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=1,
border=2, # quiet zone; tight but fine for close-range scanning
)
qr.add_data(lamp_id.upper())
qr.make(fit=True)
modules = len(qr.get_matrix())
qr_size = DOTS[1] - TEXT_HEIGHT
qr.box_size = max(1, qr_size // modules)
qr_img = qr.make_image().get_image().convert("1")
image.paste(qr_img, ((DOTS[0] - qr_img.width) // 2, (qr_size - qr_img.height) // 2))
draw = ImageDraw.Draw(image)
size = 44
while size > 10:
font = _font(size)
box = draw.textbbox((0, 0), lamp_id, font=font)
if box[2] - box[0] <= DOTS[0] - 8:
break
size -= 2
draw.text(
((DOTS[0] - (box[2] - box[0])) // 2 - box[0], DOTS[1] - TEXT_HEIGHT + (TEXT_HEIGHT - (box[3] - box[1])) // 2 - box[1]),
lamp_id,
font=font,
fill=0,
)
return image
def print_labels(lamp_id: str, printer: str, copies: int = 2) -> None:
"""Render and send `copies` labels to the QL-820NWB."""
from brother_ql.backends.helpers import send
from brother_ql.conversion import convert
from brother_ql.raster import BrotherQLRaster
image = render_label(lamp_id)
raster = BrotherQLRaster(MODEL)
instructions = convert(qlr=raster, images=[image] * copies, label=LABEL_ID, cut=True)
send(instructions=instructions, printer_identifier=printer, backend_identifier="network", blocking=True)
def main():
parser = argparse.ArgumentParser(description="Print lamp ID labels (QR + ID)")
parser.add_argument("lamp_id", help="e.g. L0006")
parser.add_argument("--printer", default=os.environ.get("HPCS_LABEL_PRINTER", ""),
help="tcp://<ip> of the QL-820NWB (default: HPCS_LABEL_PRINTER env)")
parser.add_argument("--copies", type=int, default=2)
parser.add_argument("--preview", metavar="PNG",
help="Write the rendered label to a PNG instead of printing")
args = parser.parse_args()
lamp_id = args.lamp_id.upper()
if args.preview:
render_label(lamp_id).save(args.preview)
print(f"Wrote {args.preview}")
return
if not args.printer:
print("ERROR: no printer configured. Pass --printer tcp://<ip> or set HPCS_LABEL_PRINTER.")
sys.exit(1)
print_labels(lamp_id, args.printer, args.copies)
print(f"Printed {args.copies} label(s) for {lamp_id} on {args.printer}")
if __name__ == "__main__":
main()
-283
View File
@@ -1,283 +0,0 @@
"""
Lamp comparison export — one measurement, one publishable data bundle.
Takes a reading from the HPCS 6500 (or an existing pcap capture) and writes
the per-lamp files consumed by the buildfor.life comparison pages:
<out>/<name>/spd.csv full spectrum, wavelength_nm,value (380-1050 nm)
<out>/<name>/tm30.csv TM-30-18 hue-bin data for the color vector graphic
<out>/<name>/metrics.json photometric / colorimetric / CRI / TM-30 /
electrical summary
Usage:
uv run lamp_export.py --name philips-a60-8w
uv run lamp_export.py --name x --manufacturer Philips --model "A60 8W 927"
uv run lamp_export.py --name x --readings 5 # average 5 readings
uv run lamp_export.py --name x --voltage 230 --frequency 50 --settle 60
uv run lamp_export.py --name x --passive # vendor SW drives the instrument
uv run lamp_export.py --name x --parse captures/run.pcap
With --voltage/--frequency/--current/--mode (or --psu), the built-in supply is
configured, switched on for the measurement, and switched off afterwards.
--settle waits after power-on so the lamp stabilizes before the first reading.
"""
import argparse
import csv
import json
import sys
import time
from datetime import datetime
from pathlib import Path
from hpcs6500 import HPCS6500, find_hpcs_port, parse_pcap_messages
SCALAR_GROUPS = {
"photometric": ["Phi_lm", "eta_lm_W", "CCT_K", "Duv", "SDCM", "TLCI"],
"chromaticity": ["x", "y", "u", "v", "u_prime", "v_prime", "CIE_X", "CIE_Y", "CIE_Z"],
"radiometric": [
"Phi_e_mW", "Phi_euv_mW", "Phi_eb_mW", "Phi_ey_mW",
"Phi_er_mW", "Phi_efr_mW", "Phi_eir_mW", "Phi_e_total",
],
"electrical": ["Voltage_V", "Current_A", "Power_W", "Freq_Hz", "PF", "UThd", "AThd"],
"sensor": ["PeakSignal", "DarkSignal", "Compensate"],
}
def average_readings(readings):
"""Element-wise average of scalars and spectra across readings."""
result = dict(readings[0])
n = len(readings)
if n == 1:
return result
for key, value in readings[0].items():
if isinstance(value, (int, float)):
result[key] = sum(r.get(key, 0.0) for r in readings) / n
elif key == "spectrum":
result[key] = [
sum(r["spectrum"][i] for r in readings) / n
for i in range(len(value))
]
return result
def readings_from_pcap(path):
"""Extract parsed readings from a pcap capture."""
messages = parse_pcap_messages(path)
blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 3904
and m["data"][:8] == b"HPCS6500"]
elec_blocks = [m for m in messages if m["dir"] == "RX" and len(m["data"]) == 1584]
dev = HPCS6500.__new__(HPCS6500)
readings = []
for i, block in enumerate(blocks):
r = dev._parse_measurement(block["data"])
if i < len(elec_blocks):
r.update(dev._parse_electrical(elec_blocks[i]["data"]))
readings.append(r)
return readings
def readings_from_device(port, count, passive, psu):
dev = HPCS6500(port)
name = dev.identify()
if name:
print(f"Device: {name}")
readings = []
supply = None
try:
if psu["mode"]:
ok = dev.set_mode(psu["mode"])
print(f"Mode {psu['mode'].upper()}: {'OK' if ok else 'FAILED'}")
if psu["voltage"] is not None:
if psu["mode"] == "dc":
ok = dev.set_dc_voltage(psu["voltage"])
else:
ok = dev.set_ac_voltage(psu["voltage"])
print(f"Voltage {psu['voltage']:g} V: {'OK' if ok else 'FAILED'}")
if psu["frequency"] is not None:
ok = dev.set_ac_frequency(psu["frequency"])
print(f"Frequency {psu['frequency']:g} Hz: {'OK' if ok else 'FAILED'}")
if psu["current"] is not None:
ok = dev.set_dc_current(psu["current"])
print(f"DC current limit {psu['current']:g} A: {'OK' if ok else 'FAILED'}")
supply = dev.read_psu_settings()
if psu["enable"]:
if not dev.psu_on():
print("ERROR: failed to turn PSU on")
sys.exit(1)
print("PSU on")
if psu["settle"] > 0:
print(f"Settling {psu['settle']:g} s ...")
time.sleep(psu["settle"])
else:
time.sleep(0.2)
for i in range(count):
print(f"Reading {i + 1}/{count} ...")
if passive:
r = dev.read_current()
else:
# Vendor single-shot cycle (verified from USB captures):
# test config -> trigger (8C 0E 02) -> poll -> read -> reset,
# repeated per reading. auto_psu stays off; we hold the PSU.
dev.send_test_config(auto_psu=False)
r = dev.take_single_reading()
if r is None:
print("ERROR: failed to get reading")
sys.exit(1)
readings.append(r)
if i < count - 1:
if psu["enable"] and not passive:
# take_single_reading() ends with an instrument reset;
# make sure the lamp stays powered for the next reading.
dev.psu_on()
if psu["interval"] > 0:
time.sleep(psu["interval"])
finally:
if psu["enable"]:
dev.psu_off()
print("PSU off")
dev.close()
return readings, supply
def write_bundle(reading, out_dir, meta, n_readings, supply=None):
# Deferred: importing colour-science takes a few seconds, so it happens
# after the readings rather than at startup.
from tm30 import compute_tm30
out_dir.mkdir(parents=True, exist_ok=True)
spectrum = reading.get("spectrum") or []
nm = reading.get("spectrum_nm") or []
if not spectrum or max(spectrum) <= 0:
print("ERROR: reading contains no spectrum data")
sys.exit(1)
# spd.csv — full measured range; consumers trim to visible as needed.
with open(out_dir / "spd.csv", "w", newline="") as f:
w = csv.writer(f)
w.writerow(["wavelength_nm", "value"])
for wl, val in zip(nm, spectrum):
w.writerow([f"{wl:.2f}", f"{val:.6g}"])
# TM-30 from the same spectrum.
tm30 = compute_tm30(nm, spectrum)
bin_fields = list(tm30["bins"][0].keys())
with open(out_dir / "tm30.csv", "w", newline="") as f:
w = csv.DictWriter(f, fieldnames=bin_fields)
w.writeheader()
for b in tm30["bins"]:
w.writerow({k: (f"{v:.6g}" if isinstance(v, float) else v) for k, v in b.items()})
# metrics.json — grouped scalars plus identification. ean/variant are set
# by lamp_station.py when one barcode covers several versions of a lamp
# (e.g. daylight and warm white sharing box art and EAN).
metrics = {"name": meta["name"]}
if meta.get("ean"):
metrics["ean"] = meta["ean"]
if meta.get("type"):
# Lamp technology: led, halogen, cfl, incandescent, ...
metrics["type"] = meta["type"]
if meta.get("dimmable") is not None:
metrics["dimmable"] = meta["dimmable"]
if meta.get("variant"):
metrics["variant"] = meta["variant"]
if meta.get("rated"):
# Advertised values from the packaging, keyed like their measured
# counterparts so consumers can pair them (truth-in-advertising rows).
metrics["rated"] = meta["rated"]
metrics |= {
"manufacturer": meta["manufacturer"],
"model": meta["model"],
"notes": meta["notes"],
"instrument": reading.get("device", "HPCS6500"),
"measured_at": datetime.now().astimezone().isoformat(timespec="seconds"),
"instrument_date": reading.get("test_date"),
"instrument_time": reading.get("test_time"),
"readings_averaged": n_readings,
"tm30": {"Rf": round(tm30["Rf"], 1), "Rg": round(tm30["Rg"], 1)},
"cri": {"Ra": reading.get("Ra")}
| {f"R{i}": reading.get(f"R{i}") for i in range(1, 16)},
}
if supply:
metrics["supply"] = supply
for group, keys in SCALAR_GROUPS.items():
metrics[group] = {k: reading[k] for k in keys if k in reading}
with open(out_dir / "metrics.json", "w") as f:
json.dump(metrics, f, indent=2)
return tm30
def main():
parser = argparse.ArgumentParser(description="Export a lamp measurement bundle")
parser.add_argument("--name", required=True,
help="Lamp slug, becomes the output directory name")
parser.add_argument("--manufacturer", default="")
parser.add_argument("--model", default="")
parser.add_argument("--notes", default="")
parser.add_argument("--out", default="lamps", help="Output base directory")
parser.add_argument("--port", help="COM port (auto-detect if omitted)")
parser.add_argument("--readings", type=int, default=1,
help="Number of readings to average (default 1)")
parser.add_argument("--passive", action="store_true",
help="Read without controlling the instrument")
parser.add_argument("--parse", metavar="PCAP",
help="Export from a pcap capture instead of the device")
psu_group = parser.add_argument_group("power supply")
psu_group.add_argument("--mode", choices=["ac", "dc"],
help="Supply mode (default ac when --voltage is given)")
psu_group.add_argument("--voltage", type=float, help="Supply voltage (V)")
psu_group.add_argument("--frequency", type=float, help="AC frequency (Hz)")
psu_group.add_argument("--current", type=float, help="DC current limit (A)")
psu_group.add_argument("--psu", action="store_true",
help="Power the lamp from the built-in supply "
"(implied by --mode/--voltage/--frequency/--current)")
psu_group.add_argument("--settle", type=float, default=0,
help="Seconds to wait after PSU on before the first reading")
parser.add_argument("--interval", type=float, default=1.0,
help="Seconds to wait between readings (default 1)")
args = parser.parse_args()
psu = {
"mode": args.mode,
"voltage": args.voltage,
"frequency": args.frequency,
"current": args.current,
"settle": args.settle,
"interval": args.interval,
"enable": args.psu or args.mode is not None or args.voltage is not None
or args.frequency is not None or args.current is not None,
}
supply = None
if args.parse:
readings = readings_from_pcap(args.parse)
if not readings:
print(f"ERROR: no measurement blocks in {args.parse}")
sys.exit(1)
print(f"Using {len(readings)} reading(s) from capture")
else:
port = args.port or find_hpcs_port()
if not port:
print("ERROR: HPCS 6500 not found. Connect the device or specify --port.")
sys.exit(1)
readings, supply = readings_from_device(port, args.readings, args.passive, psu)
reading = average_readings(readings)
out_dir = Path(args.out) / args.name
meta = {k: getattr(args, k) for k in ("name", "manufacturer", "model", "notes")}
tm30 = write_bundle(reading, out_dir, meta, len(readings), supply)
print(f"\nWrote {out_dir}/spd.csv, tm30.csv, metrics.json")
print(f" {reading.get('Phi_lm', 0):.0f} lm {reading.get('eta_lm_W', 0):.1f} lm/W "
f"{reading.get('CCT_K', 0):.0f} K Ra {reading.get('Ra', 0):.1f}")
print(f" TM-30: Rf {tm30['Rf']:.1f} Rg {tm30['Rg']:.1f}")
if __name__ == "__main__":
main()
-392
View File
@@ -1,392 +0,0 @@
"""
Lamp measurement station — scan a barcode, measure, commit, done.
Interactive loop for building the buildfor.life lamp comparison database.
Every lamp gets an internal ID (L0001, L0002, ...) as its primary key; the
EAN barcode is optional metadata. This keeps lamps distinct when one barcode
covers several versions (daylight / warm white in the same box art) and
supports unbranded lamps without any barcode.
1. Every run opens with a freshly allocated ID (L0012, ...). The ID only
becomes real once the bundle is written, so aborting never burns one.
2. Scan the box with a keyboard-wedge barcode scanner (types EAN +
Enter), press Enter for a lamp without a barcode, or type an existing
ID (e.g. L0004) to re-measure that lamp with everything pre-filled,
which also rewrites its bundle in the current metrics format. Scanned
EANs are checksum-validated; lamps already measured with the same EAN
are listed (pick one to re-measure, or continue as the new lamp: that
is how same-EAN variants get their own IDs). Identification is
suggested from previous lamps, a local cache, then upcitemdb.com
(best effort; GS1-restricted 20-29 barcodes skip the online lookup).
3. Confirm or edit manufacturer / model / variant / notes, then type the
ADVERTISED values from the packaging (lumen, CCT, watts, CRI,
lifetime, equivalent watts; Enter skips unknown ones). They are
stored under "rated" in metrics.json so the site can show
advertised-vs-measured deltas.
4. Insert the lamp, press Enter: the built-in supply powers it per the
published procedure (230 V / 50 Hz, 60 s settle, 5 readings averaged)
and the bundle (spd.csv, tm30.csv, metrics.json) is written to
<data-repo>/lamps/<ID>/.
5. The bundle is committed to the comparison-data repo and pushed. The
website picks it up automatically (data-driven device list plus the
auto-bump deploy); an .md page in the web repo is only ever needed
for photos, where-to-buy links, or notes.
6. Two ID labels (QR to the lamp's page + the ID, DK-11221 23x23 mm)
print on the Brother QL-820NWB: one for the box, one for the lamp.
Configure the printer via HPCS_LABEL_PRINTER or --printer; reprint
any lamp later with `uv run label_printer.py L0006`.
Usage:
uv run lamp_station.py # defaults below
uv run lamp_station.py --data-repo D:/comparison-data --no-push
uv run lamp_station.py --settle 0 --readings 1 # quick smoke test
"""
import argparse
import json
import os
import re
import subprocess
import sys
import urllib.request
from pathlib import Path
from hpcs6500 import find_hpcs_port
from lamp_export import average_readings, readings_from_device, write_bundle
DEFAULT_DATA_REPO = Path(r"C:\dev\buildfor_life_web\apps\web\comparison-data")
CACHE_FILE = Path.home() / ".cache" / "hpcs6500" / "ean-cache.json"
LOOKUP_URL = "https://api.upcitemdb.com/prod/trial/lookup?upc={}"
ID_RE = re.compile(r"^L(\d{4,})$")
# Advertised values typed from the packaging; keys match the measured
# metrics they are compared against on the site.
RATED_FIELDS = [
("Phi_lm", "Rated flux (lm)"),
("CCT_K", "Rated CCT (K)"),
("Power_W", "Rated power (W)"),
("Ra", "Rated CRI (Ra)"),
("lifetime_h", "Rated lifetime (h)"),
("W_equiv", "Incandescent equivalent (W)"),
]
def ean_normalize(code):
"""Return a valid EAN-13 (UPC-A gets a leading zero), or None."""
code = re.sub(r"\D", "", code)
if len(code) == 12:
code = "0" + code
if len(code) != 13:
return None
digits = [int(c) for c in code]
check = (10 - sum(d * (3 if i % 2 else 1) for i, d in enumerate(digits[:12])) % 10) % 10
return code if check == digits[12] else None
def is_restricted(ean):
"""GS1 prefixes 20-29 are retailer-internal, never publicly resolvable."""
return 20 <= int(ean[:2]) <= 29
def lamp_index(data_repo):
"""{id: metrics-dict} for every measured lamp."""
lamps = {}
for f in sorted((data_repo / "lamps").glob("L*/metrics.json")):
if ID_RE.match(f.parent.name):
try:
lamps[f.parent.name] = json.loads(f.read_text())
except ValueError:
pass
return lamps
def next_lamp_id(data_repo):
used = [int(ID_RE.match(d.name).group(1))
for d in (data_repo / "lamps").glob("L*") if ID_RE.match(d.name)]
return f"L{max(used, default=0) + 1:04d}"
# Canonical spellings for brands; extended at runtime by whatever is already
# in the database and the cache, so the first accepted spelling of a new brand
# becomes its canonical form for every later scan.
BRAND_SEED = [
"Philips", "OSRAM", "LAMPTAN", "TKL", "ST", "IKEA", "Panasonic",
"Toshiba", "GE", "Sylvania", "Xiaomi", "Opple", "EVE",
]
def canonical_brand(name, lamps):
"""Map a manufacturer string to its canonical capitalization."""
name = " ".join(name.split())
if not name:
return name
brands = {b.lower(): b for b in BRAND_SEED}
for entry in load_cache().values():
b = entry.get("manufacturer", "")
if b:
brands.setdefault(b.lower(), b)
for m in lamps.values():
b = m.get("manufacturer", "")
if b:
brands[b.lower()] = b # the database is the strongest source
return brands.get(name.lower(), name)
def load_cache():
try:
return json.loads(CACHE_FILE.read_text())
except (OSError, ValueError):
return {}
def save_cache(cache):
CACHE_FILE.parent.mkdir(parents=True, exist_ok=True)
CACHE_FILE.write_text(json.dumps(cache, indent=2))
def lookup_ean(ean, lamps):
"""Best-effort (manufacturer, model, source) for an EAN."""
for m in lamps.values():
if m.get("ean") == ean and (m.get("manufacturer") or m.get("model")):
return m.get("manufacturer", ""), m.get("model", ""), f"previous lamp {m['name']}"
cache = load_cache()
if ean in cache:
return cache[ean]["manufacturer"], cache[ean]["model"], "local cache"
if is_restricted(ean):
return "", "", "retailer-internal barcode, not resolvable"
try:
req = urllib.request.Request(LOOKUP_URL.format(ean),
headers={"User-Agent": "hpcs6500-lamp-station"})
with urllib.request.urlopen(req, timeout=10) as resp:
data = json.loads(resp.read())
item = (data.get("items") or [{}])[0]
brand, title = item.get("brand", ""), item.get("title", "")
if brand or title:
return brand, title, "upcitemdb.com (verify against packaging)"
except Exception as e: # noqa: BLE001 — lookup is strictly best-effort
print(f" (online lookup failed: {e})")
return "", "", "no match"
def prompt(label, default=""):
text = input(f"{label} [{default}]: " if default else f"{label}: ").strip()
return text or default
def prompt_rated(previous=None):
"""Advertised values from the box; Enter skips a field."""
previous = previous or {}
print(" Advertised values from the packaging (Enter to skip):")
rated = {}
for key, label in RATED_FIELDS:
default = previous.get(key, "")
raw = prompt(f" {label}", str(default) if default != "" else "")
if not raw:
continue
try:
value = float(raw)
rated[key] = int(value) if value == int(value) else value
except ValueError:
print(f" (not a number, skipped: {raw!r})")
return rated
def git(repo, *args, check=True):
return subprocess.run(["git", "-C", str(repo), *args],
check=check, capture_output=True, text=True)
def commit_bundle(data_repo, lamp_id, label, existed, push):
git(data_repo, "add", f"lamps/{lamp_id}")
if not git(data_repo, "status", "--porcelain", f"lamps/{lamp_id}").stdout.strip():
print("No data changes to commit.")
return
verb = "Re-measure" if existed else "Add"
git(data_repo, "commit", "-m", f"{verb} lamp {lamp_id} ({label})")
print(f"Committed to comparison-data: {verb} lamp {lamp_id} ({label})")
if push:
git(data_repo, "push")
print("Pushed.")
def measure_one(new_id, ean, lamps, args, data_repo, existing_id=None):
# The run already owns its fresh ID (new_id). Typing an existing ID at
# the scan prompt re-measures that lamp instead (all its identification
# pre-filled, bundle rewritten in the current format); an EAN may also
# redirect to a re-measure when it matches an existing lamp.
lamp_id, previous = new_id, {}
if existing_id:
lamp_id = existing_id
previous = lamps[existing_id]
ean = previous.get("ean", "")
print(f" Re-measuring {lamp_id}" + (f" (EAN {ean})" if ean else ""))
elif ean:
same = {i: m for i, m in lamps.items() if m.get("ean") == ean}
if same:
print(" Lamps already measured with this EAN:")
for i, m in same.items():
variant = f", {m['variant']}" if m.get("variant") else ""
print(f" {i}: {m.get('manufacturer', '')} {m.get('model', '')}{variant}")
choice = prompt(f"Re-measure one of these (type its ID) or Enter to continue as {new_id}").strip()
if choice:
if choice.upper() not in same:
print(f" Unknown ID {choice!r}, aborting this run.")
return
lamp_id = choice.upper()
previous = same[lamp_id]
print(f" Re-measuring {lamp_id}")
if previous:
manufacturer, model = previous.get("manufacturer", ""), previous.get("model", "")
elif ean:
manufacturer, model, source = lookup_ean(ean, lamps)
print(f" Lookup: {manufacturer or '?'} / {model or '?'} ({source})")
else:
manufacturer = model = ""
manufacturer = prompt("Manufacturer", canonical_brand(manufacturer, lamps))
canon = canonical_brand(manufacturer, lamps)
if canon != manufacturer:
print(f" Using canonical brand name: {canon}")
manufacturer = canon
model = prompt("Model", model)
lamp_type = prompt("Type (led / halogen / cfl / incandescent / ...)",
previous.get("type", "led")).lower().strip()
prev_dim = previous.get("dimmable")
dim_default = "" if prev_dim is None else ("y" if prev_dim else "n")
dim_raw = prompt("Dimmable? (y / n, Enter if unknown)", dim_default).lower()
dimmable = True if dim_raw.startswith("y") else False if dim_raw.startswith("n") else None
variant = prompt("Variant (e.g. daylight / warm white, Enter if none)",
previous.get("variant", "")).lower()
notes = prompt("Notes", previous.get("notes", "retailer-internal barcode, brand unknown"
if ean and is_restricted(ean) and not manufacturer else ""))
rated = prompt_rated(previous.get("rated"))
if ean and (manufacturer or model):
cache = load_cache()
cache[ean] = {"manufacturer": manufacturer, "model": model}
save_cache(cache)
input("Insert the lamp, close the sphere, press Enter to start ... ")
port = args.port or find_hpcs_port()
if not port:
print("ERROR: HPCS 6500 not found. Connect the device or use --port.")
return
psu = {"mode": "ac", "voltage": args.voltage, "frequency": args.frequency,
"current": None, "settle": args.settle, "interval": args.interval,
"enable": True}
readings, supply = readings_from_device(port, args.readings, False, psu)
reading = average_readings(readings)
meta = {"name": lamp_id, "ean": ean or "", "type": lamp_type, "dimmable": dimmable,
"variant": variant, "manufacturer": manufacturer, "model": model,
"notes": notes, "rated": rated}
out_dir = data_repo / "lamps" / lamp_id
tm30 = write_bundle(reading, out_dir, meta, len(readings), supply)
print(f"\n {reading.get('Phi_lm', 0):.0f} lm {reading.get('eta_lm_W', 0):.1f} lm/W "
f"{reading.get('CCT_K', 0):.0f} K Ra {reading.get('Ra', 0):.1f} "
f"TM-30 Rf {tm30['Rf']:.1f} / Rg {tm30['Rg']:.1f}")
if rated.get("Phi_lm"):
delta = reading.get("Phi_lm", 0) / rated["Phi_lm"] * 100 - 100
print(f" vs advertised: {delta:+.1f} % flux", end="")
if rated.get("CCT_K"):
print(f", {reading.get('CCT_K', 0) - rated['CCT_K']:+.0f} K CCT", end="")
print()
print()
label_parts = [x for x in (manufacturer, model) if x]
if variant:
label_parts.append(variant)
if ean:
label_parts.append(f"EAN {ean}")
if not args.no_commit:
commit_bundle(data_repo, lamp_id, ", ".join(label_parts) or "unidentified",
existed=bool(previous), push=not args.no_push)
print(" The lamp publishes automatically (auto-bump deploy); add an .md under")
print(" src/content/comparisons/lamps/ only for photos, buy links, or notes.")
# Two ID labels on the QL-820NWB: one for the box, one for the lamp.
if not args.no_labels:
if args.printer:
try:
from label_printer import print_labels
print_labels(lamp_id, args.printer, copies=2)
print(f" Printed 2 ID labels for {lamp_id}.")
except Exception as e: # noqa: BLE001 — a printer problem must not lose the run
print(f" Label printing failed ({e}); reprint later with:")
print(f" uv run label_printer.py {lamp_id}")
else:
print(f" (no label printer configured; set HPCS_LABEL_PRINTER or use --printer,")
print(f" reprint later with: uv run label_printer.py {lamp_id})")
def main():
parser = argparse.ArgumentParser(description="Barcode-driven lamp measurement station")
parser.add_argument("--data-repo", default=str(DEFAULT_DATA_REPO),
help="comparison-data checkout (bundles land in lamps/<ID>/)")
parser.add_argument("--port", help="COM port (auto-detect if omitted)")
parser.add_argument("--readings", type=int, default=5)
parser.add_argument("--voltage", type=float, default=230.0)
parser.add_argument("--frequency", type=float, default=50.0)
parser.add_argument("--settle", type=float, default=60.0,
help="Seconds of stabilization after PSU on (procedure: 60)")
parser.add_argument("--interval", type=float, default=1.0)
parser.add_argument("--no-commit", action="store_true",
help="Write the bundle but skip git entirely")
parser.add_argument("--no-push", action="store_true",
help="Commit locally but do not push")
parser.add_argument("--printer", default=os.environ.get("HPCS_LABEL_PRINTER", ""),
help="QL-820NWB address, e.g. tcp://192.168.1.50 "
"(default: HPCS_LABEL_PRINTER env)")
parser.add_argument("--no-labels", action="store_true",
help="Skip printing the two ID labels after each lamp")
args = parser.parse_args()
data_repo = Path(args.data_repo)
# In a submodule checkout .git is a file, not a directory; exists() covers both.
if not (data_repo / ".git").exists():
print(f"ERROR: {data_repo} is not a git checkout of comparison-data.")
sys.exit(1)
print("Lamp station ready. Every run starts a new lamp ID; the EAN is optional")
print("(scan it, or press Enter to skip). 'q' quits.")
while True:
# A fresh ID opens every run. It only becomes real once a bundle is
# written, so quitting or re-measuring never burns a number.
lamps = lamp_index(data_repo)
new_id = next_lamp_id(data_repo)
print(f"\n--- New lamp {new_id} ---")
try:
code = input("Scan EAN (Enter to skip, L#### re-measures an existing lamp, q quits): ").strip()
except (EOFError, KeyboardInterrupt):
break
if code.lower() in ("q", "quit", "exit"):
break
existing_id = None
ean = ""
if ID_RE.match(code.upper()):
existing_id = code.upper()
if existing_id not in lamps:
print(f" No lamp {existing_id} in the database.")
continue
elif code and code.lower() not in ("n", "none"):
ean = ean_normalize(code)
if not ean:
print(f" Not a valid EAN-13/UPC-A: {code!r} (Enter would skip the EAN)")
continue
try:
measure_one(new_id, ean, lamps, args, data_repo, existing_id=existing_id)
except KeyboardInterrupt:
print("\n Aborted; lamp skipped. (PSU is switched off by the driver.)")
except (subprocess.CalledProcessError, SystemExit) as e:
err = getattr(e, "stderr", "") or e
print(f" ERROR: {err}")
print("Bye.")
if __name__ == "__main__":
main()
-13
View File
@@ -1,13 +0,0 @@
[project]
name = "hpcs6500"
version = "0.1.0"
description = "Open-source driver and CLI for the HPCS 6500 spectrophotometer / integrating sphere"
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"pyserial>=3.5",
"colour-science>=0.4.4",
"qrcode>=8.2",
"pillow>=12.3.0",
"brother-ql-next>=0.12.0",
]
-42
View File
@@ -1,42 +0,0 @@
"""Thin wrapper for external tools — outputs JSON to stdout."""
import argparse
import json
import sys
from hpcs6500 import HPCS6500, find_hpcs_port
def measure():
port = find_hpcs_port()
if not port:
print('{"error": "HPCS 6500 not found"}', file=sys.stderr)
sys.exit(1)
dev = HPCS6500(port)
try:
dev.identify()
reading = dev.take_single_reading()
finally:
dev.close()
if reading is None:
print('{"error": "measurement failed"}', file=sys.stderr)
sys.exit(1)
result = {
"lumen": round(reading["Phi_lm"], 1),
"cct": round(reading["CCT_K"]),
}
print(json.dumps(result))
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="HPCS 6500 JSON interface")
parser.add_argument("--measure", action="store_true", help="Take a measurement and output JSON")
args = parser.parse_args()
if args.measure:
measure()
else:
parser.print_help()
+297
View File
@@ -0,0 +1,297 @@
//! Reverse-engineering tools: capture the vendor software's USB traffic
//! with USBPcap, and dump a capture (timeline, TX/RX byte streams).
use hpcs6500::pcap;
use std::path::{Path, PathBuf};
use std::process::{Child, Command, Stdio};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
fn hex_spaced(b: &[u8]) -> String {
b.iter()
.map(|x| format!("{x:02x}"))
.collect::<Vec<_>>()
.join(" ")
}
fn ascii(b: &[u8]) -> String {
b.iter()
.map(|&c| {
if (32..127).contains(&c) {
c as char
} else {
'.'
}
})
.collect()
}
/// Timeline of the bulk transfers, the TX/RX streams as .bin files and a
/// quick look at both streams.
pub fn dump(path: &Path, limit: usize, write_bin: bool) -> i32 {
let data = match std::fs::read(path) {
Ok(d) => d,
Err(e) => {
eprintln!("ERROR: {}: {e}", path.display());
return 1;
}
};
let (hdr, packets) = match pcap::packets(&data) {
Ok(x) => x,
Err(e) => {
eprintln!("ERROR: {e}");
return 1;
}
};
println!(
"PCAP: v{}.{}, snaplen={}, link_type={}",
hdr.version.0, hdr.version.1, hdr.snaplen, hdr.link_type
);
if hdr.link_type != 249 {
println!(
"Warning: expected link_type 249 (USBPcap), got {}",
hdr.link_type
);
}
let total = packets.last().map(|p| p.num).unwrap_or(0);
let bulk: Vec<&pcap::Packet> = packets.iter().filter(|p| p.is_bulk()).collect();
let is_rx = |p: &pcap::Packet| p.info & 1 == 1 || p.endpoint & 0x80 != 0;
let (mut tx, mut rx) = (vec![], vec![]);
for p in &bulk {
if is_rx(p) {
rx.extend_from_slice(&p.data)
} else {
tx.extend_from_slice(&p.data)
}
}
println!(
"\nParsed {total} total packets, {} bulk transfers with data",
bulk.len()
);
println!("TX (host -> device): {} bytes", tx.len());
println!("RX (device -> host): {} bytes", rx.len());
if bulk.is_empty() {
println!("\nNo bulk transfer data found.");
return 0;
}
println!("\n{}\nTRAFFIC TIMELINE\n{}", "=".repeat(80), "=".repeat(80));
let first = bulk[0].ts;
for p in bulk.iter().take(limit) {
let (colour, label) = if is_rx(p) {
("\x1b[92m", "DEV->PC")
} else {
("\x1b[93m", "PC->DEV")
};
let mut h = hex_spaced(&p.data);
if h.len() > 90 {
h = format!("{} ... (+{}B)", &h[..90], p.data.len().saturating_sub(30));
}
println!(
"{colour}[{:8.4}] {label} EP{:02X} ({:4}B): {h}\x1b[0m",
p.ts - first,
p.endpoint,
p.data.len()
);
let printable = p.data.iter().filter(|c| (32..127).contains(*c)).count();
if printable as f64 > p.data.len() as f64 * 0.4 {
println!(
" ASCII: {:?}",
ascii(&p.data[..p.data.len().min(40)])
);
}
}
if bulk.len() > limit {
println!(" ... ({} more packets)", bulk.len() - limit);
}
if write_bin {
let stem = path.with_extension("");
for (suffix, stream) in [("_TX.bin", &tx), ("_RX.bin", &rx)] {
if stream.is_empty() {
continue;
}
let out = PathBuf::from(format!("{}{suffix}", stem.display()));
match std::fs::write(&out, stream) {
Ok(_) => println!("\n{} data saved to: {}", &suffix[1..3], out.display()),
Err(e) => println!("\nERROR writing {}: {e}", out.display()),
}
}
}
println!("\n{}\nQUICK ANALYSIS\n{}", "=".repeat(80), "=".repeat(80));
for (label, s) in [("TX (PC -> Device)", &tx), ("RX (Device -> PC)", &rx)] {
if s.is_empty() {
continue;
}
println!("\n--- {label}: {} bytes ---", s.len());
let printable = s
.iter()
.filter(|&&c| (32..127).contains(&c) || c == 0x0A || c == 0x0D)
.count();
println!(
" Printable ratio: {:.0}%",
printable as f64 / s.len() as f64 * 100.0
);
let sample = &s[..s.len().min(200)];
println!(" HEX: {}", hex_spaced(sample));
println!(" ASCII: {}", ascii(sample));
if s.len() > 10 {
for (name, d) in [
("\\r\\n", &b"\r\n"[..]),
("\\n", b"\n"),
("0xFF", b"\xff"),
("0xAA", b"\xaa"),
] {
let count = count_non_overlapping(s, d);
if count > 2 {
println!(" Delimiter {name}: appears {count} times");
}
}
}
}
0
}
fn count_non_overlapping(hay: &[u8], needle: &[u8]) -> usize {
let (mut i, mut n) = (0, 0);
while i + needle.len() <= hay.len() {
if &hay[i..i + needle.len()] == needle {
n += 1;
i += needle.len();
} else {
i += 1;
}
}
n
}
/// Start USBPcapCMD on every root hub at once (the instrument's hub is not
/// known in advance), wait for Ctrl+C, keep the largest capture and dump it.
pub fn capture(address: u32, out_dir: &Path, usbpcap: &Path) -> i32 {
if !cfg!(windows) {
eprintln!(
"ERROR: capturing needs USBPcap on Windows; use Wireshark/usbmon elsewhere and `hpcs6500 dump`."
);
return 1;
}
if !usbpcap.exists() {
eprintln!(
"ERROR: {} not found. Install USBPcap (and reboot), then run as administrator.",
usbpcap.display()
);
return 1;
}
if let Err(e) = std::fs::create_dir_all(out_dir) {
eprintln!("ERROR: {}: {e}", out_dir.display());
return 1;
}
println!("HPCS6500 USB Traffic Capture\n{}", "=".repeat(60));
println!("Device address: {address}\n");
let stamp = chrono::Local::now().format("%Y%m%d_%H%M%S");
let mut procs: Vec<(Child, String, PathBuf)> = vec![];
for i in 1..=10 {
let device = format!(r"\\.\USBPcap{i}");
let file = out_dir.join(format!("usb_capture_{stamp}_hub{i}.pcap"));
let child = Command::new(usbpcap)
.args(["-d", &device, "-o"])
.arg(&file)
.args([
"-A",
"--devices",
&address.to_string(),
"-s",
"65535",
"-b",
"1048576",
])
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn();
match child {
Ok(c) => {
println!("Starting capture on {device} -> {}", file.display());
procs.push((c, device, file));
}
Err(e) => println!(" {device}: {e}"),
}
}
if procs.is_empty() {
println!(
"ERROR: no USBPcap capture could be started (run as ADMINISTRATOR, reboot after installing USBPcap)."
);
return 1;
}
println!("\nCapturing on ALL hubs simultaneously.");
println!(">>> Now use the vendor software - take a measurement");
println!(">>> Press Ctrl+C when done\n{}", "-".repeat(60));
let stop = Arc::new(AtomicBool::new(false));
{
let stop = stop.clone();
let _ = ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst));
}
loop {
if stop.load(Ordering::SeqCst) {
println!("\n\nStopping captures...");
break;
}
if procs
.iter_mut()
.all(|(c, _, _)| matches!(c.try_wait(), Ok(Some(_))))
{
println!("\nAll capture processes have exited.");
for (c, device, _) in procs.iter_mut() {
let mut err = String::new();
if let Some(mut s) = c.stderr.take() {
let _ = std::io::Read::read_to_string(&mut s, &mut err);
}
let code = c.try_wait().ok().flatten().and_then(|s| s.code());
println!(
" {device}: exit code {code:?}{}",
if err.trim().is_empty() {
String::new()
} else {
format!(" - {}", err.trim())
}
);
}
break;
}
std::thread::sleep(Duration::from_millis(500));
}
for (c, _, _) in procs.iter_mut() {
let _ = c.kill();
let _ = c.wait();
}
println!();
let mut best: Option<(PathBuf, u64)> = None;
for (_, device, file) in &procs {
match std::fs::metadata(file) {
Ok(m) => {
println!("{device} -> {}: {} bytes", file.display(), m.len());
if best.as_ref().is_none_or(|(_, s)| m.len() > *s) {
best = Some((file.clone(), m.len()));
}
}
Err(_) => println!("{device} -> no file created"),
}
}
match best {
Some((file, size)) if size > 24 => {
println!(
"\nBest capture: {} ({size} bytes)\n\nParsing capture...\n",
file.display()
);
dump(&file, 100, true)
}
_ => {
println!(
"\nNo USB data captured from any hub.\nMake sure you're running as ADMINISTRATOR."
);
1
}
}
}
+427
View File
@@ -0,0 +1,427 @@
//! `hpcs6500`: command line tool for the HPCS 6500.
//!
//! The flags are those of the former Python tool (`hpcs6500.py`), so
//! `uv run hpcs6500.py --continuous --csv out.csv` becomes
//! `hpcs6500 --continuous --csv out.csv`.
mod capture;
use clap::{Parser, Subcommand};
use hpcs6500::format::{csv_header, csv_row, format_quick, format_reading, pcap_report};
use hpcs6500::sim::Simulator;
use hpcs6500::{Error, Hpcs6500, Mode, Reading, Transport, pcap};
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::Duration;
#[derive(Parser)]
#[command(
name = "hpcs6500",
version,
about = "HPCS 6500 Spectrophotometer Reader"
)]
struct Cli {
#[command(subcommand)]
command: Option<Command>,
/// COM port (auto-detect if not specified)
#[arg(long)]
port: Option<String>,
/// Continuous reading mode
#[arg(long)]
continuous: bool,
/// With --continuous: stop after this many readings
#[arg(long, value_name = "N")]
count: Option<usize>,
/// Save readings to CSV file
#[arg(long, value_name = "FILE")]
csv: Option<PathBuf>,
/// Show full spectrum data
#[arg(long)]
spectrum: bool,
/// Passive mode: read data without controlling instrument
#[arg(long)]
passive: bool,
/// Quick test mode: only report lumen and CCT
#[arg(long)]
quick: bool,
/// Show harmonics data (voltage/current THD and per-harmonic)
#[arg(long)]
harmonics: bool,
/// Parse a pcap capture file instead
#[arg(long, value_name = "PCAP")]
parse: Option<PathBuf>,
/// Print each reading as one JSON line on stdout (messages go to stderr)
#[arg(long)]
json: bool,
/// Talk to a simulated instrument instead of the serial port; replays
/// the readings of a USB capture, or a built-in sample without one
#[arg(long, value_name = "PCAP", num_args = 0..=1, default_missing_value = "")]
simulate: Option<String>,
/// Set output mode
#[arg(long, help_heading = "Power supply")]
mode: Option<Mode>,
/// Set output voltage (V)
#[arg(long, help_heading = "Power supply")]
voltage: Option<f64>,
/// Set AC frequency (Hz)
#[arg(long, help_heading = "Power supply")]
frequency: Option<f64>,
/// Set DC current limit (A)
#[arg(long, help_heading = "Power supply")]
current: Option<f64>,
/// Set integration time (ms), 0=auto
#[arg(long, help_heading = "Power supply")]
integration: Option<f64>,
/// Turn PSU output on
#[arg(long, help_heading = "Power supply")]
psu_on: bool,
/// Turn PSU output off
#[arg(long, help_heading = "Power supply")]
psu_off: bool,
/// Don't auto-control PSU during measurements
#[arg(long, help_heading = "Power supply")]
no_psu: bool,
/// Read power supply settings
#[arg(long, help_heading = "Power supply")]
psu_status: bool,
}
#[derive(Subcommand)]
enum Command {
/// List serial ports (the HPCS 6500 is marked)
Ports,
/// Show the USB traffic in a capture and save the TX/RX byte streams
Dump {
pcap: PathBuf,
/// Packets to show in the timeline
#[arg(long, default_value_t = 100)]
limit: usize,
/// Do not write <name>_TX.bin / <name>_RX.bin
#[arg(long)]
no_bin: bool,
},
/// Capture the USB traffic of the vendor software with USBPcap (Windows, run as administrator)
Capture {
/// USB device address of the instrument on its hub
#[arg(long, default_value_t = 4)]
address: u32,
#[arg(long, default_value = "captures")]
out_dir: PathBuf,
#[arg(long, default_value = r"C:\Program Files\USBPcap\USBPcapCMD.exe")]
usbpcap: PathBuf,
},
}
/// Human messages: stdout normally, stderr with --json.
macro_rules! say {
($json:expr, $($t:tt)*) => {
if $json { eprintln!($($t)*) } else { println!($($t)*) }
};
}
fn ok(r: &hpcs6500::Result<()>) -> &'static str {
if r.is_ok() { "OK" } else { "FAILED" }
}
fn main() {
let cli = Cli::parse();
let code = match &cli.command {
Some(Command::Ports) => ports(),
Some(Command::Dump {
pcap,
limit,
no_bin,
}) => capture::dump(pcap, *limit, !*no_bin),
Some(Command::Capture {
address,
out_dir,
usbpcap,
}) => capture::capture(*address, out_dir, usbpcap),
None => run(cli),
};
std::process::exit(code);
}
fn ports() -> i32 {
for p in hpcs6500::list_ports() {
println!(
" {}: {} [{}]{}",
p.name,
p.description,
p.hwid,
if p.is_hpcs { " <- HPCS 6500" } else { "" }
);
}
0
}
fn run(mut cli: Cli) -> i32 {
// --harmonics implies --spectrum for the detailed display
if cli.harmonics {
cli.spectrum = true;
}
if let Some(path) = &cli.parse {
return match pcap::read_messages(path) {
Ok(msgs) if cli.json => {
for r in pcap::readings(&msgs) {
println!("{}", serde_json::to_string(&r).expect("json"));
}
0
}
Ok(msgs) => {
print!("{}", pcap_report(&msgs, cli.quick, cli.spectrum));
0
}
Err(e) => {
eprintln!("ERROR: {e}");
1
}
};
}
match &cli.simulate {
Some(src) => {
let sim = if src.is_empty() {
Ok(Simulator::sample())
} else {
Simulator::from_pcap(src)
};
match sim {
Ok(s) => {
let label = if src.is_empty() {
"simulator (built-in sample)".to_string()
} else {
format!("simulator ({src})")
};
session(&cli, Hpcs6500::with_transport(s, label))
}
Err(e) => {
eprintln!("ERROR: {e}");
1
}
}
}
None => {
let Some(port) = cli.port.clone().or_else(hpcs6500::find_port) else {
println!("ERROR: HPCS 6500 not found. Connect the device or specify --port.");
println!("\nAvailable ports:");
ports();
return 1;
};
match Hpcs6500::open(&port) {
Ok(dev) => session(&cli, dev),
Err(e) => {
eprintln!("ERROR: cannot open {port}: {e}");
1
}
}
}
}
}
fn session<T: Transport>(cli: &Cli, mut dev: Hpcs6500<T>) -> i32 {
let j = cli.json;
let stop = Arc::new(AtomicBool::new(false));
{
let stop = stop.clone();
let _ = ctrlc::set_handler(move || stop.store(true, Ordering::SeqCst));
}
dev.set_cancel_flag(stop.clone());
say!(j, "HPCS 6500 Spectrophotometer Reader");
say!(j, "Port: {}", dev.port());
say!(j, "{}", "=".repeat(60));
match dev.identify() {
Ok(name) => say!(j, "Device: {name}"),
Err(_) => say!(j, "Warning: no identify response (device may be busy)"),
}
let has_psu_cmd = cli.mode.is_some()
|| cli.voltage.is_some()
|| cli.frequency.is_some()
|| cli.current.is_some()
|| cli.integration.is_some()
|| cli.psu_status
|| cli.psu_on
|| cli.psu_off;
if cli.psu_status {
match dev.read_psu_settings() {
Ok(s) => {
say!(j, "\nPower Supply Settings:");
say!(j, " Mode: {}", s.mode);
say!(j, " AC Voltage: {:.0} V", s.ac_voltage);
say!(j, " AC Frequency: {:.0} Hz", s.ac_frequency);
say!(j, " DC Voltage: {:.1} V", s.dc_voltage);
say!(j, " DC Current: {:.1} A", s.dc_current);
}
Err(_) => say!(j, "Failed to read PSU settings"),
}
if !cli.continuous && cli.voltage.is_none() && cli.mode.is_none() {
return 0;
}
}
if let Some(m) = cli.mode {
let r = dev.set_mode(m);
say!(j, "Set mode to {m}: {}", ok(&r));
}
if let Some(v) = cli.voltage {
let mode = cli.mode.unwrap_or(Mode::Ac);
let r = if mode == Mode::Ac {
dev.set_ac_voltage(v as f32)
} else {
dev.set_dc_voltage(v as f32)
};
say!(
j,
"Set {mode} voltage to {} V: {}",
hpcs6500::pyfmt::repr(v),
ok(&r)
);
}
if let Some(f) = cli.frequency {
let r = dev.set_ac_frequency(f as f32);
say!(
j,
"Set AC frequency to {} Hz: {}",
hpcs6500::pyfmt::repr(f),
ok(&r)
);
}
if let Some(a) = cli.current {
let r = dev.set_dc_current(a as f32);
say!(
j,
"Set DC current limit to {} A: {}",
hpcs6500::pyfmt::repr(a),
ok(&r)
);
}
if let Some(ms) = cli.integration {
let r = dev.set_integration_time(ms);
say!(
j,
"Set integration time to {} ms: {}",
hpcs6500::pyfmt::repr(ms),
ok(&r)
);
}
if cli.psu_on {
let r = dev.psu_on();
say!(j, "PSU output ON: {}", ok(&r));
}
if cli.psu_off {
let r = dev.psu_off();
say!(j, "PSU output OFF: {}", ok(&r));
}
// only supply commands, no measurement requested
if has_psu_cmd && !cli.continuous && !cli.passive {
return 0;
}
let mut csv = None;
if let Some(path) = &cli.csv {
match File::create(path) {
Ok(mut f) => {
let _ = f.write_all(csv_header().as_bytes());
say!(j, "Saving to: {}", path.display());
csv = Some(f);
}
Err(e) => {
eprintln!("ERROR: {}: {e}", path.display());
return 1;
}
}
}
// the supply is only controlled in continuous mode
let use_psu = cli.continuous && !cli.no_psu && !cli.passive;
let mut code = 0;
if use_psu {
let _ = dev.psu_on();
std::thread::sleep(Duration::from_millis(200));
}
let mut n = 0;
loop {
if stop.load(Ordering::SeqCst) {
say!(j, "\nStopped.");
break;
}
n += 1;
if !cli.quick {
say!(j, "\n{}", "=".repeat(60));
say!(
j,
"Reading {n} ({})",
chrono::Local::now().format("%H:%M:%S")
);
say!(j, "{}", "=".repeat(60));
}
let result: hpcs6500::Result<Reading> = if cli.passive {
dev.read_current()
} else if cli.continuous {
dev.take_reading(false)
} else {
dev.take_single_reading()
};
let r = match result {
Ok(r) => r,
Err(Error::Cancelled) => {
say!(j, "\nStopped.");
break;
}
Err(e) => {
say!(j, "Failed to get reading ({e})");
if !cli.continuous {
code = 1;
break;
}
let _ = dev.pause(Duration::from_secs(1));
continue;
}
};
if j {
println!("{}", serde_json::to_string(&r).expect("json"));
} else if cli.quick {
println!("{}", format_quick(&r, Some(n)));
} else {
println!("{}", format_reading(&r, cli.spectrum));
}
if let Some(f) = csv.as_mut() {
let now = chrono::Local::now().naive_local();
let ts = if now.and_utc().timestamp_subsec_micros() == 0 {
now.format("%Y-%m-%dT%H:%M:%S").to_string()
} else {
now.format("%Y-%m-%dT%H:%M:%S%.6f").to_string()
};
let _ = f.write_all(csv_row(&ts, &r).as_bytes());
let _ = f.flush();
}
if !cli.continuous || cli.count.is_some_and(|c| n >= c) {
break;
}
if dev.pause(Duration::from_millis(500)).is_err() {
say!(j, "\nStopped.");
break;
}
}
if use_psu {
let _ = dev.psu_off();
}
let _ = dev.reset();
if let Some(path) = &cli.csv {
say!(j, "Data saved to: {}", path.display());
}
code
}
+446
View File
@@ -0,0 +1,446 @@
//! The instrument over a byte transport (the serial port, or the simulator).
use crate::error::{Error, Result, hex};
use crate::protocol::{self, Mode, PollState, PsuSettings, SYNC, cmd};
use crate::reading::Reading;
use std::io::{self, Read, Write};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
/// A byte pipe to the instrument.
pub trait Transport: Send {
/// Write a whole frame.
fn write_all(&mut self, data: &[u8]) -> io::Result<()>;
/// Read what is available, waiting up to `timeout` for the first byte.
/// Returns `Ok(0)` when nothing arrived in time.
fn read_some(&mut self, buf: &mut [u8], timeout: Duration) -> io::Result<usize>;
/// Drop unread input.
fn clear_input(&mut self) -> io::Result<()>;
}
/// The USB virtual COM port.
pub struct SerialTransport {
port: Box<dyn serialport::SerialPort>,
timeout: Duration,
}
impl SerialTransport {
pub fn open(name: &str) -> Result<Self> {
let timeout = Duration::from_millis(100);
let mut port = serialport::new(name, protocol::BAUD)
.data_bits(serialport::DataBits::Eight)
.parity(serialport::Parity::None)
.stop_bits(serialport::StopBits::One)
.flow_control(serialport::FlowControl::None)
.timeout(timeout)
.open()?;
// pyserial raises DTR and RTS on open; the vendor software does too
let _ = port.write_data_terminal_ready(true);
let _ = port.write_request_to_send(true);
Ok(SerialTransport { port, timeout })
}
}
impl Transport for SerialTransport {
fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
self.port.write_all(data)?;
self.port.flush()
}
fn read_some(&mut self, buf: &mut [u8], timeout: Duration) -> io::Result<usize> {
if timeout != self.timeout {
self.port.set_timeout(timeout).map_err(io::Error::other)?;
self.timeout = timeout;
}
match self.port.read(buf) {
Ok(n) => Ok(n),
Err(e) if e.kind() == io::ErrorKind::TimedOut => Ok(0),
Err(e) => Err(e),
}
}
fn clear_input(&mut self) -> io::Result<()> {
self.port
.clear(serialport::ClearBuffer::Input)
.map_err(io::Error::other)
}
}
/// A serial port as listed by [`list_ports`].
#[derive(Debug, Clone)]
pub struct PortInfo {
pub name: String,
pub description: String,
/// `USB VID:PID=0483:5741 SER=...` or the port type.
pub hwid: String,
pub is_hpcs: bool,
}
pub fn list_ports() -> Vec<PortInfo> {
let Ok(ports) = serialport::available_ports() else {
return vec![];
};
ports
.into_iter()
.map(|p| match p.port_type {
serialport::SerialPortType::UsbPort(u) => PortInfo {
description: u
.product
.clone()
.or(u.manufacturer.clone())
.unwrap_or_else(|| "USB serial".into()),
hwid: format!(
"USB VID:PID={:04X}:{:04X}{}",
u.vid,
u.pid,
u.serial_number
.as_ref()
.map(|s| format!(" SER={s}"))
.unwrap_or_default()
),
is_hpcs: u.vid == protocol::USB_VID && u.pid == protocol::USB_PID,
name: p.port_name,
},
t => PortInfo {
description: "n/a".into(),
hwid: format!("{t:?}"),
is_hpcs: false,
name: p.port_name,
},
})
.collect()
}
/// The first serial port with the instrument's USB IDs.
pub fn find_port() -> Option<String> {
list_ports().into_iter().find(|p| p.is_hpcs).map(|p| p.name)
}
const ACK_TIMEOUT: Duration = Duration::from_secs(2);
const HEADER_TIMEOUT: Duration = Duration::from_secs(3);
const BLOCK_TIMEOUT: Duration = Duration::from_secs(5);
/// How long a reading may take before giving up.
pub const DATA_TIMEOUT: Duration = Duration::from_secs(30);
/// The HPCS 6500.
///
/// Every command clears pending input, sends one frame and reads the
/// answer. Methods return `Err` where the Python driver returned
/// `None`/`False`.
pub struct Hpcs6500<T: Transport = SerialTransport> {
io: T,
port: String,
cancel: Option<Arc<AtomicBool>>,
}
impl Hpcs6500<SerialTransport> {
/// Open the instrument on a serial port (e.g. `COM42`, `/dev/ttyACM0`).
pub fn open(port: &str) -> Result<Self> {
Ok(Hpcs6500 {
io: SerialTransport::open(port)?,
port: port.to_string(),
cancel: None,
})
}
}
impl<T: Transport> Hpcs6500<T> {
/// Use any transport, e.g. a [`crate::sim::Simulator`].
pub fn with_transport(io: T, port: impl Into<String>) -> Self {
Hpcs6500 {
io,
port: port.into(),
cancel: None,
}
}
pub fn port(&self) -> &str {
&self.port
}
pub fn transport(&self) -> &T {
&self.io
}
pub fn transport_mut(&mut self) -> &mut T {
&mut self.io
}
pub fn into_transport(self) -> T {
self.io
}
/// Long waits (data polling, [`Hpcs6500::pause`]) end with
/// [`Error::Cancelled`] when this flag goes up, e.g. from a Ctrl+C handler.
pub fn set_cancel_flag(&mut self, flag: Arc<AtomicBool>) {
self.cancel = Some(flag);
}
fn cancelled(&self) -> bool {
self.cancel
.as_ref()
.is_some_and(|f| f.load(Ordering::SeqCst))
}
/// Sleep, but return [`Error::Cancelled`] early when the cancel flag goes up.
pub fn pause(&self, d: Duration) -> Result<()> {
let end = Instant::now() + d;
loop {
if self.cancelled() {
return Err(Error::Cancelled);
}
let now = Instant::now();
if now >= end {
return Ok(());
}
std::thread::sleep((end - now).min(Duration::from_millis(50)));
}
}
// ---- framing ----
fn send(&mut self, frame: &[u8]) -> Result<()> {
self.io.clear_input()?;
self.io.write_all(frame)?;
Ok(())
}
/// Up to `n` bytes, fewer if the deadline passes.
fn recv(&mut self, n: usize, timeout: Duration) -> Result<Vec<u8>> {
let deadline = Instant::now() + timeout;
let mut buf = vec![0u8; n];
let mut got = 0;
while got < n {
let now = Instant::now();
if now >= deadline {
break;
}
got += self.io.read_some(
&mut buf[got..],
(deadline - now).min(Duration::from_millis(100)),
)?;
}
buf.truncate(got);
Ok(buf)
}
/// Send a write command and expect the `8C <cmd>` acknowledge.
fn command(&mut self, frame: &[u8]) -> Result<()> {
self.send(frame)?;
let cmd = frame[1];
let r = self.recv(2, ACK_TIMEOUT)?;
if r == [SYNC, cmd] {
Ok(())
} else {
Err(Error::NoAck { cmd, got: hex(&r) })
}
}
/// Send a read command, check the header, return the whole response.
fn query(&mut self, cmd: u8, len: usize, timeout: Duration) -> Result<Vec<u8>> {
self.send(&protocol::frame(cmd, &[]))?;
let r = self.recv(len, timeout)?;
if r.len() < 2 || r[0] != SYNC || r[1] != cmd {
return Err(Error::BadResponse { cmd, got: hex(&r) });
}
if r.len() < len {
return Err(Error::Short {
what: "response",
got: r.len(),
want: len,
});
}
Ok(r)
}
fn read_block(&mut self, cmd: u8, len: usize, what: &'static str) -> Result<Vec<u8>> {
self.send(&protocol::frame(cmd, &[]))?;
let hdr = self.recv(4, HEADER_TIMEOUT)?;
if hdr.len() < 4 || hdr[0] != SYNC || hdr[1] != cmd {
return Err(Error::BadResponse {
cmd,
got: hex(&hdr),
});
}
let payload = self.recv(len, BLOCK_TIMEOUT)?;
if payload.len() < len {
return Err(Error::Short {
what,
got: payload.len(),
want: len,
});
}
Ok(payload)
}
// ---- commands ----
/// `8C 00`: the device name, e.g. "HPCS6500".
pub fn identify(&mut self) -> Result<String> {
let r = self.query(cmd::IDENTIFY, 16, ACK_TIMEOUT)?;
let name: String = r[2..10]
.iter()
.map(|&b| if b < 0x80 { b as char } else { '\u{FFFD}' })
.collect();
Ok(name.trim_end_matches('\0').to_string())
}
/// `8C 13`: the raw 3904-byte measurement block.
pub fn read_measurement_block(&mut self) -> Result<Vec<u8>> {
self.read_block(
cmd::READ_MEASUREMENT,
protocol::MEASUREMENT_LEN,
"measurement block",
)
}
/// `8C 77`: the raw 1584-byte electrical / harmonics block.
pub fn read_electrical_block(&mut self) -> Result<Vec<u8>> {
self.read_block(
cmd::READ_ELECTRICAL,
protocol::ELECTRICAL_LEN,
"electrical block",
)
}
/// `8C 03`: is a reading ready?
pub fn poll_state(&mut self) -> Result<PollState> {
let r = self.query(cmd::POLL_STATE, 9, ACK_TIMEOUT)?;
Ok(PollState {
data_available: r[2] == 1,
state: r[5],
})
}
/// `8C 2B` sub 00 + sub 01: the vendor's single-test configuration.
/// With `auto_psu` the instrument switches its supply on and off around the test.
pub fn send_test_config(&mut self, auto_psu: bool) -> Result<()> {
for f in protocol::test_config_frames(auto_psu) {
self.command(&f)?;
}
Ok(())
}
/// `8C 0E 01`: start continuous measuring.
pub fn start_measurement(&mut self) -> Result<()> {
self.command(&protocol::frame(cmd::START, &[protocol::START_CONTINUOUS]))
}
/// `8C 0E 02`: stop measuring; on an idle instrument this triggers one reading.
pub fn stop_measurement(&mut self) -> Result<()> {
self.command(&protocol::frame(cmd::START, &[protocol::STOP_OR_SINGLE]))
}
/// `8C 25`.
pub fn reset(&mut self) -> Result<()> {
self.command(&protocol::frame(cmd::RESET, &[]))
}
/// `8C 72 00`: supply output on.
pub fn psu_on(&mut self) -> Result<()> {
self.command(&protocol::frame(cmd::PSU_OUTPUT, &[0x00]))
}
/// `8C 72 01`: supply output off.
pub fn psu_off(&mut self) -> Result<()> {
self.command(&protocol::frame(cmd::PSU_OUTPUT, &[0x01]))
}
/// `8C 7A`: AC or DC output.
pub fn set_mode(&mut self, mode: Mode) -> Result<()> {
self.command(&protocol::frame(cmd::SET_MODE, &[mode.byte()]))
}
/// `8C 78 00`: AC voltage, 100-240 V.
pub fn set_ac_voltage(&mut self, volts: f32) -> Result<()> {
self.command(&protocol::frame_f32(cmd::SET_AC, 0x00, volts))
}
/// `8C 78 01`: AC frequency, 50 or 60 Hz.
pub fn set_ac_frequency(&mut self, hz: f32) -> Result<()> {
self.command(&protocol::frame_f32(cmd::SET_AC, 0x01, hz))
}
/// `8C 73 01`: DC voltage, 1-60 V.
pub fn set_dc_voltage(&mut self, volts: f32) -> Result<()> {
self.command(&protocol::frame_f32(cmd::SET_DC, 0x01, volts))
}
/// `8C 73 00`: DC current limit, 0-5 A.
pub fn set_dc_current(&mut self, amps: f32) -> Result<()> {
self.command(&protocol::frame_f32(cmd::SET_DC, 0x00, amps))
}
/// `8C 01`: integration time in milliseconds, 0 = automatic.
pub fn set_integration_time(&mut self, ms: f64) -> Result<()> {
let us = (ms * 1000.0) as u32;
self.command(&protocol::frame(cmd::INTEGRATION_TIME, &us.to_le_bytes()))
}
/// `8C 79`: the supply settings.
pub fn read_psu_settings(&mut self) -> Result<PsuSettings> {
let r = self.query(cmd::READ_PSU, PsuSettings::RESPONSE_LEN, ACK_TIMEOUT)?;
PsuSettings::parse(&r[2..]).ok_or(Error::Short {
what: "PSU settings",
got: r.len(),
want: 20,
})
}
/// Poll every 100 ms until a reading is ready.
pub fn wait_for_data(&mut self, timeout: Duration) -> Result<()> {
let deadline = Instant::now() + timeout;
while Instant::now() < deadline {
match self.poll_state() {
Ok(s) if s.data_available => return Ok(()),
Ok(_) | Err(Error::BadResponse { .. } | Error::Short { .. }) => {}
Err(e) => return Err(e),
}
self.pause(Duration::from_millis(100))?;
}
Err(Error::Timeout)
}
/// Read both blocks; the electrical block is optional.
fn read_reading(&mut self) -> Result<Reading> {
let meas = self.read_measurement_block()?;
let elec = self.read_electrical_block().ok();
Ok(Reading::parse(&meas, elec.as_deref()).expect("full measurement block"))
}
/// One reading in continuous mode: start, wait, read, stop. With `psu`
/// the supply is switched on first and off afterwards, also on errors.
pub fn take_reading(&mut self, psu: bool) -> Result<Reading> {
if psu {
self.psu_on()?;
std::thread::sleep(Duration::from_millis(200));
}
let r = (|| {
self.start_measurement()?;
let r = self
.wait_for_data(DATA_TIMEOUT)
.and_then(|_| self.read_reading());
let _ = self.stop_measurement();
r
})();
if psu {
let _ = self.psu_off();
}
r
}
/// One reading like the vendor's single test: `8C 0E 02` triggers it,
/// poll, read both blocks, then reset. The supply is not touched (call
/// [`Hpcs6500::send_test_config`] first to match the vendor flow exactly).
pub fn take_single_reading(&mut self) -> Result<Reading> {
let _ = self.stop_measurement();
self.wait_for_data(DATA_TIMEOUT)?;
let r = self.read_reading()?;
let _ = self.reset();
Ok(r)
}
/// Read the latest data without starting or stopping anything (while the
/// vendor software drives the instrument).
pub fn read_current(&mut self) -> Result<Reading> {
self.read_reading()
}
}
+52
View File
@@ -0,0 +1,52 @@
use std::fmt::Write;
/// Everything that can go wrong talking to the instrument.
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("serial port: {0}")]
Serial(#[from] serialport::Error),
#[error("I/O: {0}")]
Io(#[from] std::io::Error),
/// A write command was not acknowledged with `8C <cmd>`.
#[error("no acknowledge for command 0x{cmd:02X} (got {got})")]
NoAck { cmd: u8, got: String },
/// A read command answered with an unexpected header.
#[error("bad response to command 0x{cmd:02X} (got {got})")]
BadResponse { cmd: u8, got: String },
/// A data block ended early.
#[error("{what} short ({got}/{want} bytes)")]
Short {
what: &'static str,
got: usize,
want: usize,
},
#[error("timeout waiting for measurement data")]
Timeout,
/// The cancel flag (Ctrl+C) was raised during a wait.
#[error("cancelled")]
Cancelled,
#[error("HPCS 6500 not found (no serial port with VID 0483 / PID 5741)")]
NotFound,
#[error("pcap: {0}")]
Pcap(String),
}
pub type Result<T, E = Error> = std::result::Result<T, E>;
/// `8c 13 0f` style hex for error messages, `(nothing)` when empty.
pub(crate) fn hex(b: &[u8]) -> String {
if b.is_empty() {
return "nothing".into();
}
let mut s = String::new();
for (i, x) in b.iter().take(24).enumerate() {
if i > 0 {
s.push(' ');
}
let _ = write!(s, "{x:02x}");
}
if b.len() > 24 {
let _ = write!(s, " ... ({} bytes)", b.len());
}
s
}
+178
View File
@@ -0,0 +1,178 @@
//! Text output of the command line tool, character for character the same
//! as the Python driver's (`format_reading`, `format_quick`, the CSV export
//! and the `--parse` report), so scripts reading it keep working.
use crate::pcap::Message;
use crate::pyfmt;
use crate::reading::{ELECTRICAL_FIELDS, FIELDS, Reading};
use std::fmt::{self, Write};
/// A float that formats like a Python number (`nan`, right-aligned).
struct Py(f64);
impl fmt::Display for Py {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let s = match f.precision() {
Some(p) => pyfmt::fixed(self.0, p),
None => pyfmt::repr(self.0),
};
match f.width() {
Some(w) => write!(f, "{s:>w$}"),
None => f.write_str(&s),
}
}
}
/// The full report of one reading. `show_spectrum` adds the harmonics (when
/// present) and every 5th spectrum point as a bar chart.
pub fn format_reading(r: &Reading, show_spectrum: bool) -> String {
let g = |k: &str| Py(r.get(k).unwrap_or(0.0));
let mut l: Vec<String> = vec![];
l.push(format!(" Device: {}", r.device));
l.push(format!(" Date: {} Time: {}", r.test_date, r.test_time));
l.push(String::new());
l.push(" --- Photometric ---".into());
l.push(format!(" Phi (lm): {:10.2}", g("Phi_lm")));
l.push(format!(" eta (lm/W): {:10.2}", g("eta_lm_W")));
l.push(format!(" CCT (K): {:10.0}", g("CCT_K")));
l.push(format!(" Duv: {:10.5}", g("Duv")));
l.push(String::new());
l.push(" --- Chromaticity ---".into());
l.push(format!(" x: {:.4} y: {:.4}", g("x"), g("y")));
l.push(format!(" u: {:.4} v: {:.4}", g("u"), g("v")));
l.push(format!(
" u': {:.4} v': {:.4}",
g("u_prime"),
g("v_prime")
));
l.push(String::new());
l.push(" --- CRI ---".into());
l.push(format!(" Ra: {:.1} SDCM: {:.2}", g("Ra"), g("SDCM")));
let ri: Vec<String> = (1..=15)
.map(|i| format!("R{i}={:.0}", Py(r.r(i))))
.collect();
l.push(format!(" {}", ri[..8].join(", ")));
l.push(format!(" {}", ri[8..].join(", ")));
l.push(String::new());
l.push(" --- CIE 1931 ---".into());
l.push(format!(
" X: {:.3} Y: {:.3} Z: {:.3}",
g("CIE_X"),
g("CIE_Y"),
g("CIE_Z")
));
l.push(format!(" TLCI-2012: {:.0}", g("TLCI")));
l.push(String::new());
l.push(" --- Radiometric ---".into());
l.push(format!(" Phi_e (mW): {:10.3}", g("Phi_e_mW")));
l.push(format!(" Phi_eb (mW): {:10.3}", g("Phi_eb_mW")));
l.push(format!(" Phi_ey (mW): {:10.3}", g("Phi_ey_mW")));
l.push(format!(" Phi_er (mW): {:10.3}", g("Phi_er_mW")));
l.push(format!(" Phi_efr (mW): {:10.3}", g("Phi_efr_mW")));
l.push(String::new());
l.push(" --- Electrical ---".into());
l.push(format!(" Voltage (V): {:10.2}", g("Voltage_V")));
l.push(format!(" Current (A): {:10.4}", g("Current_A")));
l.push(format!(" Power (W): {:10.3}", g("Power_W")));
l.push(format!(" Frequency (Hz):{:10.2}", g("Freq_Hz")));
l.push(format!(" Power Factor: {:10.4}", g("PF")));
let harmonics = r.electrical.as_ref().and_then(|e| e.harmonics.as_ref());
if let Some(h) = harmonics {
l.push(format!(" UThd (%): {:10.4}", Py(h.uthd)));
l.push(format!(" AThd (%): {:10.4}", Py(h.athd)));
}
l.push(String::new());
if let (true, Some(h)) = (show_spectrum, harmonics) {
for (title, values) in [("Voltage", &h.voltage), ("Current", &h.current)] {
l.push(format!(" --- {title} Harmonics (%) ---"));
for (i, v) in values.iter().enumerate() {
if *v > 0.001 || i == 0 {
l.push(format!(" H{:2}: {:8.4}", i + 1, Py(*v)));
}
}
l.push(String::new());
}
}
l.push(" --- Sensor ---".into());
l.push(format!(" Peak Signal: {:10.0}", g("PeakSignal")));
l.push(format!(" Dark Signal: {:10.0}", g("DarkSignal")));
l.push(format!(" Compensate: {:10.0}", g("Compensate")));
if show_spectrum {
l.push(String::new());
l.push(" --- Spectrum (380-1050nm) ---".into());
let peak = r.spectrum.iter().cloned().fold(f64::NEG_INFINITY, f64::max);
let peak = if r.spectrum.is_empty() { 1.0 } else { peak };
for (i, (wl, v)) in r.spectrum_nm().iter().zip(&r.spectrum).enumerate() {
if i % 5 == 0 {
let norm = if peak > 0.0 { v / peak } else { 0.0 };
let bar = "#".repeat((norm * 40.0).max(0.0) as usize);
l.push(format!(" {:7.1}nm: {:8.4} [{bar}]", Py(*wl), Py(*v)));
}
}
}
l.join("\n")
}
/// ` #3 479.6 lm 5653 K` (lumen and CCT only).
pub fn format_quick(r: &Reading, reading_num: Option<usize>) -> String {
let prefix = match reading_num {
Some(n) if n > 0 => format!("#{n} "),
_ => String::new(),
};
format!(
" {prefix}{:.1} lm {:.0} K",
Py(r.flux_lm()),
Py(r.cct_k())
)
}
/// CSV columns: `timestamp`, the measurement fields, the electrical fields.
pub fn csv_columns() -> Vec<&'static str> {
std::iter::once("timestamp")
.chain(FIELDS.iter().map(|f| f.0))
.chain(ELECTRICAL_FIELDS.iter().map(|f| f.0))
.collect()
}
/// The CSV header line (CRLF, like Python's csv module).
pub fn csv_header() -> String {
csv_columns().join(",") + "\r\n"
}
/// One CSV row; numbers as Python writes them, empty cells for values the
/// reading does not have.
pub fn csv_row(timestamp: &str, r: &Reading) -> String {
let mut cells = vec![timestamp.to_string()];
for c in &csv_columns()[1..] {
cells.push(r.get(c).map(pyfmt::repr).unwrap_or_default());
}
cells.join(",") + "\r\n"
}
/// The `--parse` report of a capture, as the Python tool printed it.
pub fn pcap_report(messages: &[Message], quick: bool, show_spectrum: bool) -> String {
let mut out = String::new();
for (i, b) in crate::pcap::blocks(messages).iter().enumerate() {
let Some(r) = Reading::parse(&b.measurement, b.electrical.as_deref()) else {
continue;
};
if quick {
let _ = writeln!(out, "{}", format_quick(&r, Some(i + 1)));
} else {
let _ = writeln!(out, "\n{}", "=".repeat(60));
let _ = writeln!(out, "Reading {} (t={:.2}s)", i + 1, b.ts);
let _ = writeln!(out, "{}", "=".repeat(60));
let _ = writeln!(out, "{}", format_reading(&r, show_spectrum));
}
}
out
}
+38
View File
@@ -0,0 +1,38 @@
//! Driver for the HPCS 6500 spectrophotometer / integrating sphere.
//!
//! The instrument talks a small binary protocol over its USB virtual COM
//! port (STM32 VCP, VID 0483 / PID 5741, 115200 baud). The protocol was
//! reverse-engineered from USB captures of the vendor software and is
//! documented byte by byte in `PROTOCOL.md`.
//!
//! ```no_run
//! use hpcs6500::{Hpcs6500, Mode};
//!
//! let port = hpcs6500::find_port().expect("HPCS 6500 not connected");
//! let mut dev = Hpcs6500::open(&port)?;
//! println!("{}", dev.identify()?);
//! dev.set_mode(Mode::Ac)?;
//! dev.set_ac_voltage(230.0)?;
//! dev.psu_on()?;
//! let r = dev.take_single_reading()?;
//! dev.psu_off()?;
//! println!("{:.0} lm, {:.0} K, Ra {:.1}", r.flux_lm(), r.cct_k(), r.ra());
//! # Ok::<(), hpcs6500::Error>(())
//! ```
//!
//! Without hardware, [`sim::Simulator`] answers like the instrument, either
//! with readings replayed from a USB capture or with built-in sample data.
mod device;
mod error;
pub mod format;
pub mod pcap;
pub mod protocol;
pub mod pyfmt;
pub mod reading;
pub mod sim;
pub use device::{Hpcs6500, PortInfo, SerialTransport, Transport, find_port, list_ports};
pub use error::{Error, Result};
pub use protocol::{Mode, PollState, PsuSettings};
pub use reading::{Electrical, Harmonics, Reading};
+220
View File
@@ -0,0 +1,220 @@
//! USB captures (USBPcap pcap files) of the vendor software talking to the
//! instrument: the source of the protocol, and test data for the parser and
//! the simulator.
use crate::error::{Error, Result};
use crate::protocol::{ELECTRICAL_LEN, MEASUREMENT_LEN};
use crate::reading::Reading;
use std::path::Path;
/// Direction of a USB bulk transfer.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Dir {
/// Host to instrument (OUT endpoint).
Tx,
/// Instrument to host (IN endpoint).
Rx,
}
/// One USB packet with a payload.
#[derive(Debug, Clone)]
pub struct Packet {
/// 1-based index in the file, counting every record.
pub num: usize,
/// Seconds since the epoch.
pub ts: f64,
pub status: u32,
/// USBPcap `info` byte: bit 0 set = IN (device to host).
pub info: u8,
pub device: u16,
/// Endpoint address; bit 7 set = IN.
pub endpoint: u8,
/// 0 isochronous, 1 interrupt, 2 control, 3 bulk.
pub transfer: u8,
pub data: Vec<u8>,
}
impl Packet {
pub fn is_bulk(&self) -> bool {
self.transfer == 3
}
/// Direction from the endpoint address, as the driver's parser uses it.
pub fn dir(&self) -> Dir {
if self.endpoint & 0x80 != 0 {
Dir::Rx
} else {
Dir::Tx
}
}
}
/// File header facts.
#[derive(Debug, Clone, Copy)]
pub struct Header {
pub version: (u16, u16),
pub snaplen: u32,
/// 249 = USBPcap.
pub link_type: u32,
}
/// A bulk transfer payload, timestamp relative to the first one.
#[derive(Debug, Clone)]
pub struct Message {
pub ts: f64,
pub dir: Dir,
pub data: Vec<u8>,
}
/// Every record that carries a payload after its USBPcap header.
pub fn packets(data: &[u8]) -> Result<(Header, Vec<Packet>)> {
if data.len() < 24 {
return Err(Error::Pcap(format!(
"file too small for a pcap header ({} bytes)",
data.len()
)));
}
let magic = u32::from_le_bytes(data[..4].try_into().expect("4 bytes"));
let (big, nanos) = match magic {
0xA1B2_C3D4 => (false, false),
0xA1B2_3C4D => (false, true),
0xD4C3_B2A1 => (true, false),
0x4D3C_B2A1 => (true, true),
m => return Err(Error::Pcap(format!("not a pcap file (magic {m:#010x})"))),
};
let u16_at = |o: usize| {
let b = [data[o], data[o + 1]];
if big {
u16::from_be_bytes(b)
} else {
u16::from_le_bytes(b)
}
};
let u32_at = |o: usize| {
let b: [u8; 4] = data[o..o + 4].try_into().expect("4 bytes");
if big {
u32::from_be_bytes(b)
} else {
u32::from_le_bytes(b)
}
};
let header = Header {
version: (u16_at(4), u16_at(6)),
snaplen: u32_at(16),
link_type: u32_at(20),
};
let mut out = vec![];
let mut off = 24;
let mut num = 0;
while off + 16 <= data.len() {
let (ts_sec, ts_frac, incl) = (u32_at(off), u32_at(off + 4), u32_at(off + 8) as usize);
off += 16;
if off + incl > data.len() {
break;
}
let p = &data[off..off + incl];
off += incl;
num += 1;
if p.len() < 27 {
continue;
}
// the USBPcap header itself is always little-endian
let hdr_len = u16::from_le_bytes([p[0], p[1]]) as usize;
if hdr_len >= p.len() {
continue;
}
out.push(Packet {
num,
ts: ts_sec as f64 + ts_frac as f64 / if nanos { 1e9 } else { 1e6 },
status: u32::from_le_bytes(p[10..14].try_into().expect("4 bytes")),
info: p[16],
device: u16::from_le_bytes([p[19], p[20]]),
endpoint: p[21],
transfer: p[22],
data: p[hdr_len..].to_vec(),
});
}
Ok((header, out))
}
/// The bulk transfers in order, timestamps relative to the first.
pub fn messages(data: &[u8]) -> Result<Vec<Message>> {
let (_, pk) = packets(data)?;
let first = pk.iter().find(|p| p.is_bulk()).map(|p| p.ts).unwrap_or(0.0);
Ok(pk
.into_iter()
.filter(|p| p.is_bulk())
.map(|p| Message {
ts: p.ts - first,
dir: p.dir(),
data: p.data,
})
.collect())
}
pub fn read_messages(path: impl AsRef<Path>) -> Result<Vec<Message>> {
let path = path.as_ref();
let data = std::fs::read(path).map_err(|e| Error::Pcap(format!("{}: {e}", path.display())))?;
messages(&data)
}
/// A measurement block (with its time) and the electrical block at the same index.
#[derive(Debug, Clone)]
pub struct Block {
pub ts: f64,
pub measurement: Vec<u8>,
pub electrical: Option<Vec<u8>>,
}
/// Measurement blocks (3904 bytes starting "HPCS6500") paired by index with
/// electrical blocks (1584 bytes), as the vendor software reads them.
pub fn blocks(messages: &[Message]) -> Vec<Block> {
let rx = messages.iter().filter(|m| m.dir == Dir::Rx);
let meas: Vec<&Message> = rx
.clone()
.filter(|m| m.data.len() == MEASUREMENT_LEN && m.data.starts_with(b"HPCS6500"))
.collect();
let elec: Vec<&Message> = rx.filter(|m| m.data.len() == ELECTRICAL_LEN).collect();
meas.iter()
.enumerate()
.map(|(i, m)| Block {
ts: m.ts,
measurement: m.data.clone(),
electrical: elec.get(i).map(|e| e.data.clone()),
})
.collect()
}
/// The readings in a capture.
pub fn readings(messages: &[Message]) -> Vec<Reading> {
blocks(messages)
.iter()
.filter_map(|b| Reading::parse(&b.measurement, b.electrical.as_deref()))
.collect()
}
/// A pcap file in memory, for tests and tools: one record per payload.
pub fn write(records: &[(Dir, Vec<u8>)]) -> Vec<u8> {
let mut f = vec![];
f.extend_from_slice(&0xA1B2_C3D4u32.to_le_bytes());
f.extend_from_slice(&2u16.to_le_bytes());
f.extend_from_slice(&4u16.to_le_bytes());
f.extend_from_slice(&[0; 8]);
f.extend_from_slice(&65535u32.to_le_bytes());
f.extend_from_slice(&249u32.to_le_bytes());
for (i, (dir, data)) in records.iter().enumerate() {
let mut p = vec![0u8; 27];
p[..2].copy_from_slice(&27u16.to_le_bytes());
p[16] = matches!(dir, Dir::Rx) as u8;
p[19..21].copy_from_slice(&4u16.to_le_bytes());
p[21] = if matches!(dir, Dir::Rx) { 0x81 } else { 0x01 };
p[22] = 3;
p[23..27].copy_from_slice(&(data.len() as u32).to_le_bytes());
p.extend_from_slice(data);
f.extend_from_slice(&1_770_000_000u32.to_le_bytes());
f.extend_from_slice(&(i as u32 * 1000).to_le_bytes());
f.extend_from_slice(&(p.len() as u32).to_le_bytes());
f.extend_from_slice(&(p.len() as u32).to_le_bytes());
f.extend_from_slice(&p);
}
f
}
+226
View File
@@ -0,0 +1,226 @@
//! Frame layout, command bytes and the small fixed-size responses.
//! See `PROTOCOL.md` for the full reference.
use serde::Serialize;
/// Every frame in both directions starts with this byte.
pub const SYNC: u8 = 0x8C;
pub const BAUD: u32 = 115_200;
/// USB IDs of the instrument's virtual COM port (STM32 VCP).
pub const USB_VID: u16 = 0x0483;
pub const USB_PID: u16 = 0x5741;
/// Command bytes (second byte of every frame).
pub mod cmd {
pub const IDENTIFY: u8 = 0x00;
pub const INTEGRATION_TIME: u8 = 0x01;
pub const POLL_STATE: u8 = 0x03;
pub const STATUS: u8 = 0x05;
pub const START: u8 = 0x0E;
pub const READ_MEASUREMENT: u8 = 0x13;
pub const RESET: u8 = 0x25;
pub const CONFIG: u8 = 0x2A;
pub const TEST_CONFIG: u8 = 0x2B;
pub const PSU_OUTPUT: u8 = 0x72;
pub const SET_DC: u8 = 0x73;
pub const READ_ELECTRICAL: u8 = 0x77;
pub const SET_AC: u8 = 0x78;
pub const READ_PSU: u8 = 0x79;
pub const SET_MODE: u8 = 0x7A;
}
/// Payload length of the measurement block (`8C 13`, after a 4-byte header).
pub const MEASUREMENT_LEN: usize = 3904;
/// Payload length of the electrical / harmonics block (`8C 77`, after a 4-byte header).
pub const ELECTRICAL_LEN: usize = 1584;
/// Transport headers the instrument sends in front of the two blocks.
pub const MEASUREMENT_HEADER: [u8; 4] = [SYNC, cmd::READ_MEASUREMENT, 0x0F, 0x40];
pub const ELECTRICAL_HEADER: [u8; 4] = [SYNC, cmd::READ_ELECTRICAL, 0x06, 0x30];
/// Poll response byte 5.
pub const STATE_IDLE: u8 = 0x04;
pub const STATE_MEASURING: u8 = 0x01;
/// `8C 0E` sub commands.
pub const START_CONTINUOUS: u8 = 0x01;
/// Stops a continuous measurement; on an idle instrument it triggers one reading.
pub const STOP_OR_SINGLE: u8 = 0x02;
/// Single-test parameters (`8C 2B` sub 00), captured from the vendor software.
const TEST_CONFIG_SUB0_HEX: &str = concat!(
"00003c00000001008813037c011a04000000484200000208",
"000000000001060000000000803f0000803f0000803f0000",
"803f0000803f00000000003f",
);
/// Single-test config (`8C 2B` sub 01); byte 37 is the auto-PSU flag.
const TEST_CONFIG_SUB1_HEX: &str = concat!(
"017c010c037c010c037c010c030000803f000000000000000000000000",
"00000000000000000001000000400000803f",
);
pub const AUTO_PSU_BYTE: usize = 37;
fn unhex(s: &str) -> Vec<u8> {
(0..s.len())
.step_by(2)
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).expect("valid hex"))
.collect()
}
/// A frame: `8C <cmd> <payload...>`.
pub fn frame(cmd: u8, payload: &[u8]) -> Vec<u8> {
let mut f = Vec::with_capacity(2 + payload.len());
f.push(SYNC);
f.push(cmd);
f.extend_from_slice(payload);
f
}
/// `8C <cmd> <sub> <f32 LE>` (AC and DC parameters).
pub fn frame_f32(cmd: u8, sub: u8, value: f32) -> Vec<u8> {
let mut p = vec![sub];
p.extend_from_slice(&value.to_le_bytes());
frame(cmd, &p)
}
/// The two `8C 2B` frames of the vendor's single-test configuration.
pub fn test_config_frames(auto_psu: bool) -> [Vec<u8>; 2] {
let mut sub1 = unhex(TEST_CONFIG_SUB1_HEX);
if auto_psu {
sub1[AUTO_PSU_BYTE] = 0x01;
}
[
frame(cmd::TEST_CONFIG, &unhex(TEST_CONFIG_SUB0_HEX)),
frame(cmd::TEST_CONFIG, &sub1),
]
}
/// Output mode of the built-in supply.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Mode {
#[serde(rename = "AC")]
Ac,
#[serde(rename = "DC")]
Dc,
}
impl Mode {
pub fn byte(self) -> u8 {
match self {
Mode::Ac => 0x00,
Mode::Dc => 0x01,
}
}
pub fn as_str(self) -> &'static str {
match self {
Mode::Ac => "AC",
Mode::Dc => "DC",
}
}
}
impl std::fmt::Display for Mode {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for Mode {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s.to_ascii_lowercase().as_str() {
"ac" => Ok(Mode::Ac),
"dc" => Ok(Mode::Dc),
_ => Err(format!("mode must be ac or dc, not {s:?}")),
}
}
}
/// Settings of the built-in supply (`8C 79`).
#[derive(Debug, Clone, Copy, PartialEq, Serialize)]
pub struct PsuSettings {
pub ac_voltage: f64,
pub ac_frequency: f64,
pub dc_voltage: f64,
pub dc_current: f64,
pub mode: Mode,
}
impl PsuSettings {
/// Response length including the `8C 79` header.
pub const RESPONSE_LEN: usize = 20;
/// Parse the 18 bytes after the header.
pub fn parse(p: &[u8]) -> Option<Self> {
if p.len() < 17 {
return None;
}
let f = |o: usize| f32::from_le_bytes([p[o], p[o + 1], p[o + 2], p[o + 3]]) as f64;
Some(PsuSettings {
ac_voltage: f(0),
ac_frequency: f(4),
dc_voltage: f(8),
dc_current: f(12),
mode: if p[16] == 1 { Mode::Dc } else { Mode::Ac },
})
}
/// The full response frame the instrument sends.
pub fn to_response(&self) -> Vec<u8> {
let mut v = vec![SYNC, cmd::READ_PSU];
for x in [
self.ac_voltage,
self.ac_frequency,
self.dc_voltage,
self.dc_current,
] {
v.extend_from_slice(&(x as f32).to_le_bytes());
}
v.push(self.mode.byte());
v.push(0xFF);
v
}
}
/// Answer to `8C 03`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub struct PollState {
/// A new reading can be read with `8C 13` / `8C 77`.
pub data_available: bool,
/// [`STATE_IDLE`] or [`STATE_MEASURING`].
pub state: u8,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_config_matches_vendor_capture() {
let [a, b] = test_config_frames(false);
assert_eq!(a.len(), 2 + 60);
assert_eq!(b.len(), 2 + 47);
assert_eq!(&a[..4], &[0x8C, 0x2B, 0x00, 0x00]);
assert_eq!(b[2], 0x01);
assert_eq!(b[2 + AUTO_PSU_BYTE], 0x00);
let [_, b] = test_config_frames(true);
assert_eq!(b[2 + AUTO_PSU_BYTE], 0x01);
}
#[test]
fn psu_settings_round_trip() {
let s = PsuSettings {
ac_voltage: 230.0,
ac_frequency: 50.0,
dc_voltage: 24.0,
dc_current: 5.0,
mode: Mode::Ac,
};
let r = s.to_response();
assert_eq!(r.len(), PsuSettings::RESPONSE_LEN);
assert_eq!(PsuSettings::parse(&r[2..]), Some(s));
assert_eq!(
frame_f32(cmd::SET_AC, 0, 230.0),
vec![0x8C, 0x78, 0x00, 0x00, 0x00, 0x66, 0x43]
);
}
}
+267
View File
@@ -0,0 +1,267 @@
//! Number formatting that matches Python, so files written by the Rust tools
//! are byte-identical to the ones the Python tools wrote (CSV exports, lamp
//! bundles): `repr(float)`, `format(x, ".6g")`, `format(x, ".2f")` and
//! `round(x, n)`.
//!
//! Python rounds the exact binary value half to even (David Gay's dtoa);
//! Rust's shortest formatting breaks ties differently (`93.72439575195313`
//! vs Python's `93.72439575195312`), so the digits are derived here from the
//! exact decimal expansion.
/// Python `repr(x)` / `str(x)` of a float: shortest round-trip digits,
/// exponent form below 1e-4 and from 1e16 (`1e-05`, `1e+16`), always a
/// `.0` on whole numbers.
pub fn repr(x: f64) -> String {
if let Some(s) = special(x) {
return s;
}
if x == 0.0 {
return if x.is_sign_negative() {
"-0.0".into()
} else {
"0.0".into()
};
}
let (neg, digits, exp) = exact(x);
let (mut d, mut e) = (digits.clone(), exp);
for p in 1..=17 {
let (rd, re) = round_sig(&digits, exp, p);
if to_f64(&rd, re) == x.abs() {
(d, e) = (rd, re);
break;
}
}
trim(&mut d);
let mut out = String::with_capacity(24);
if neg {
out.push('-');
}
if (-4..16).contains(&e) {
place(&mut out, &d, e, 0);
if !out.contains('.') {
out.push_str(".0");
}
} else {
sci(&mut out, &d, e);
}
out
}
/// Python `format(x, f".{prec}g")`.
pub fn g(x: f64, prec: usize) -> String {
if let Some(s) = special(x) {
return s;
}
let p = prec.max(1);
if x == 0.0 {
return if x.is_sign_negative() {
"-0".into()
} else {
"0".into()
};
}
let (neg, digits, exp) = exact(x);
let (mut d, e) = round_sig(&digits, exp, p);
trim(&mut d);
let mut out = String::new();
if neg {
out.push('-');
}
if e >= -4 && e < p as i32 {
place(&mut out, &d, e, 0);
} else {
sci(&mut out, &d, e);
}
out
}
/// Python `format(x, f".{prec}f")`.
pub fn fixed(x: f64, prec: usize) -> String {
if let Some(s) = special(x) {
return s;
}
let mut out = String::new();
if x.is_sign_negative() {
out.push('-');
}
if x == 0.0 {
place(&mut out, b"0", 0, prec);
return out;
}
let (_, digits, exp) = exact(x);
// significant digits kept when rounding at the prec-th decimal
let keep = exp + 1 + prec as i32;
let (d, e) = if keep > 0 {
round_sig(&digits, exp, keep as usize)
} else if keep == 0 && round_up_from(&digits, 0, b'0') {
(b"1".to_vec(), -(prec as i32))
} else {
(b"0".to_vec(), 0)
};
place(&mut out, &d, e, prec);
out
}
/// Python `round(x, ndigits)` (half to even on the exact value).
pub fn round(x: f64, ndigits: usize) -> f64 {
if !x.is_finite() {
return x;
}
fixed(x, ndigits).parse().unwrap_or(x)
}
fn special(x: f64) -> Option<String> {
if x.is_nan() {
Some("nan".into())
} else if x.is_infinite() {
Some(if x > 0.0 { "inf".into() } else { "-inf".into() })
} else {
None
}
}
/// Exact decimal expansion of a finite non-zero x: (negative, significant
/// digits without trailing zeros, decimal exponent of the first digit).
fn exact(x: f64) -> (bool, Vec<u8>, i32) {
// every f64 has at most 767 significant decimal digits; Rust prints them exactly
let s = format!("{:.800e}", x.abs());
let (mant, e) = s.split_once('e').expect("exponent");
let mut d: Vec<u8> = mant.bytes().filter(u8::is_ascii_digit).collect();
trim(&mut d);
(x.is_sign_negative(), d, e.parse().expect("exponent"))
}
fn trim(d: &mut Vec<u8>) {
while d.len() > 1 && d.last() == Some(&b'0') {
d.pop();
}
}
/// Does the tail starting at `from` round up: more than half, or exactly
/// half after an odd last kept digit?
fn round_up_from(d: &[u8], from: usize, last_kept: u8) -> bool {
let Some(&first) = d.get(from) else {
return false;
};
if first != b'5' {
return first > b'5';
}
if d[from + 1..].iter().any(|&c| c != b'0') {
return true;
}
(last_kept - b'0') % 2 == 1
}
/// Round to `n` significant digits, half to even.
fn round_sig(d: &[u8], exp: i32, n: usize) -> (Vec<u8>, i32) {
if d.len() <= n {
return (d.to_vec(), exp);
}
let mut k = d[..n].to_vec();
if round_up_from(d, n, k[n - 1]) {
let mut i = n;
loop {
if i == 0 {
// 99.9 -> 100: one more digit in front, one fewer at the end
k.insert(0, b'1');
k.pop();
return (k, exp + 1);
}
i -= 1;
if k[i] == b'9' {
k[i] = b'0';
} else {
k[i] += 1;
break;
}
}
}
(k, exp)
}
fn to_f64(d: &[u8], e: i32) -> f64 {
let s = format!(
"{}e{}",
std::str::from_utf8(d).expect("digits"),
e - d.len() as i32 + 1
);
s.parse().expect("number")
}
/// Positional notation of digits `d` (first digit at 10^e) with at least
/// `min_decimals` decimals.
fn place(out: &mut String, d: &[u8], e: i32, min_decimals: usize) {
// digit at position 10^i
let digit = |i: i32| -> char {
let idx = e - i;
if idx >= 0 && (idx as usize) < d.len() {
d[idx as usize] as char
} else {
'0'
}
};
for i in (0..=e.max(0)).rev() {
out.push(digit(i));
}
let lowest = e - d.len() as i32 + 1;
let decimals = (-lowest).max(min_decimals as i32);
if decimals > 0 {
out.push('.');
for i in 1..=decimals {
out.push(digit(-i));
}
}
}
fn sci(out: &mut String, d: &[u8], e: i32) {
out.push(d[0] as char);
if d.len() > 1 {
out.push('.');
out.push_str(std::str::from_utf8(&d[1..]).expect("digits"));
}
out.push('e');
out.push(if e < 0 { '-' } else { '+' });
let a = e.unsigned_abs();
if a < 10 {
out.push('0');
}
out.push_str(&a.to_string());
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[allow(clippy::excessive_precision)]
fn examples() {
assert_eq!(repr(1.0), "1.0");
assert_eq!(repr(100.0), "100.0");
assert_eq!(repr(0.1 + 0.2), "0.30000000000000004");
assert_eq!(repr(1e-5), "1e-05");
assert_eq!(repr(0.0001), "0.0001");
assert_eq!(repr(1e16), "1e+16");
assert_eq!(repr(1234567890123456.0), "1234567890123456.0");
assert_eq!(repr(93.724395751953125), "93.72439575195312");
assert_eq!(repr(-617.35406494140625), "-617.3540649414062");
assert_eq!(g(0.00191776, 6), "0.00191776");
assert_eq!(g(1.5e-5, 6), "1.5e-05");
assert_eq!(g(123456.5, 6), "123456");
assert_eq!(g(1234567.0, 6), "1.23457e+06");
assert_eq!(g(999999.5, 6), "1e+06");
assert_eq!(g(100.0, 6), "100");
assert_eq!(g(0.0, 6), "0");
assert_eq!(g(f64::NAN, 6), "nan");
assert_eq!(fixed(380.0, 2), "380.00");
assert_eq!(fixed(0.125, 2), "0.12");
assert_eq!(fixed(0.375, 2), "0.38");
assert_eq!(fixed(2.5, 0), "2");
assert_eq!(fixed(0.5, 0), "0");
assert_eq!(fixed(1.5, 0), "2");
assert_eq!(fixed(-0.001, 2), "-0.00");
assert_eq!(fixed(0.006, 2), "0.01");
assert_eq!(fixed(99.995, 2), "100.00");
assert_eq!(fixed(0.004, 2), "0.00");
assert_eq!(round(83.25, 1), 83.2);
}
}
+334
View File
@@ -0,0 +1,334 @@
//! The two data blocks and what is in them.
use crate::protocol::MEASUREMENT_LEN;
use serde::ser::{Serialize, SerializeMap, Serializer};
pub const SPECTRUM_START_NM: f64 = 380.0;
pub const SPECTRUM_END_NM: f64 = 1050.0;
pub const SPECTRUM_OFFSET: usize = 432;
pub const SPECTRUM_COUNT: usize = 350;
/// (1050 - 380) / (350 - 1) = 1.9198 nm.
pub const SPECTRUM_STEP: f64 =
(SPECTRUM_END_NM - SPECTRUM_START_NM) / (SPECTRUM_COUNT as f64 - 1.0);
/// Named float32 fields of the measurement block: (name, offset).
/// The names are the column names of the CSV export and the keys of the
/// lamp bundles, so they stay as they were in the Python driver.
pub const FIELDS: [(&str, usize); 42] = [
("Phi_lm", 36),
("eta_lm_W", 40),
("CCT_K", 44),
("Duv", 48),
("x", 52),
("y", 56),
("u", 60),
("v", 64),
("u_prime", 68),
("v_prime", 72),
("SDCM", 76),
("Ra", 80),
("R1", 84),
("R2", 88),
("R3", 92),
("R4", 96),
("R5", 100),
("R6", 104),
("R7", 108),
("R8", 112),
("R9", 116),
("R10", 120),
("R11", 124),
("R12", 128),
("R13", 132),
("R14", 136),
("R15", 140),
("Phi_e_mW", 144),
("Phi_euv_mW", 148),
("Phi_eb_mW", 152),
("Phi_ey_mW", 156),
("Phi_er_mW", 160),
("Phi_efr_mW", 164),
("Phi_eir_mW", 168),
("Phi_e_total", 172),
("CIE_X", 224),
("CIE_Y", 228),
("CIE_Z", 232),
("TLCI", 236),
("PeakSignal", 244),
("DarkSignal", 248),
("Compensate", 252),
];
pub const FIELD_COUNT: usize = FIELDS.len();
/// Electrical block fields: (name, offset).
pub const ELECTRICAL_FIELDS: [(&str, usize); 5] = [
("Voltage_V", 8),
("Current_A", 12),
("Power_W", 16),
("Freq_Hz", 20),
("PF", 24),
];
pub const HARMONICS_V_OFFSET: usize = 544;
pub const HARMONICS_COUNT: usize = 50;
pub const UTHD_OFFSET: usize = 744;
pub const HARMONICS_I_OFFSET: usize = 800;
pub const ATHD_OFFSET: usize = 1000;
pub const WAVEFORM_V_OFFSET: usize = 30;
pub const WAVEFORM_I_OFFSET: usize = 286;
pub const WAVEFORM_SAMPLES: usize = 128;
const DATE_OFFSET: usize = 272;
const TIME_OFFSET: usize = 283;
fn f32_at(d: &[u8], o: usize) -> f64 {
f32::from_le_bytes([d[o], d[o + 1], d[o + 2], d[o + 3]]) as f64
}
/// ASCII with U+FFFD for bytes above 0x7F and trailing NULs removed,
/// like Python's `decode("ascii", errors="replace").rstrip("\0")`.
fn ascii(d: &[u8]) -> String {
let s: String = d
.iter()
.map(|&b| if b < 0x80 { b as char } else { '\u{FFFD}' })
.collect();
s.trim_end_matches('\0').to_string()
}
/// One reading: the measurement block, plus the electrical block when it was read.
#[derive(Debug, Clone, PartialEq)]
pub struct Reading {
/// Device ID from the block header ("HPCS6500").
pub device: String,
/// Date and time the instrument stamped on the reading.
pub test_date: String,
pub test_time: String,
/// The [`FIELDS`] in order; use [`Reading::get`] by name.
pub values: [f64; FIELD_COUNT],
/// 350 points, 380 to 1050 nm (spectral irradiance, µW/cm²/nm).
pub spectrum: Vec<f64>,
pub electrical: Option<Electrical>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Electrical {
pub voltage_v: f64,
pub current_a: f64,
pub power_w: f64,
pub freq_hz: f64,
pub pf: f64,
/// Present when harmonics analysis is on (H1 of the voltage is 100 %).
pub harmonics: Option<Harmonics>,
}
#[derive(Debug, Clone, PartialEq)]
pub struct Harmonics {
/// H1..H50 of the voltage, % of H1.
pub voltage: Vec<f64>,
pub uthd: f64,
/// H1..H50 of the current, % of H1.
pub current: Vec<f64>,
pub athd: f64,
/// One AC cycle, 128 samples each.
pub v_waveform: Vec<i16>,
pub i_waveform: Vec<i16>,
}
impl Electrical {
/// Parse the 1584-byte electrical block.
pub fn parse(d: &[u8]) -> Option<Electrical> {
if d.len() < 28 {
return None;
}
let harmonics = (d.len() >= ATHD_OFFSET + 4
&& (f32_at(d, HARMONICS_V_OFFSET) - 100.0).abs() < 1.0)
.then(|| {
let i16s = |o: usize| {
(0..WAVEFORM_SAMPLES)
.map(|i| i16::from_le_bytes([d[o + 2 * i], d[o + 2 * i + 1]]))
.collect()
};
Harmonics {
voltage: (0..HARMONICS_COUNT)
.map(|i| f32_at(d, HARMONICS_V_OFFSET + 4 * i))
.collect(),
uthd: f32_at(d, UTHD_OFFSET),
current: (0..HARMONICS_COUNT)
.map(|i| f32_at(d, HARMONICS_I_OFFSET + 4 * i))
.collect(),
athd: f32_at(d, ATHD_OFFSET),
v_waveform: i16s(WAVEFORM_V_OFFSET),
i_waveform: i16s(WAVEFORM_I_OFFSET),
}
});
Some(Electrical {
voltage_v: f32_at(d, 8),
current_a: f32_at(d, 12),
power_w: f32_at(d, 16),
freq_hz: f32_at(d, 20),
pf: f32_at(d, 24),
harmonics,
})
}
fn values(&self) -> [f64; 5] {
[
self.voltage_v,
self.current_a,
self.power_w,
self.freq_hz,
self.pf,
]
}
}
impl Reading {
/// Parse a 3904-byte measurement block and optionally its electrical block.
pub fn parse(meas: &[u8], elec: Option<&[u8]>) -> Option<Reading> {
if meas.len() < MEASUREMENT_LEN {
return None;
}
let mut values = [0.0; FIELD_COUNT];
for (v, (_, off)) in values.iter_mut().zip(FIELDS.iter()) {
*v = f32_at(meas, *off);
}
Some(Reading {
device: ascii(&meas[..10]),
test_date: ascii(&meas[DATE_OFFSET..DATE_OFFSET + 10]),
test_time: ascii(&meas[TIME_OFFSET..TIME_OFFSET + 8]),
values,
spectrum: (0..SPECTRUM_COUNT)
.map(|i| f32_at(meas, SPECTRUM_OFFSET + 4 * i))
.collect(),
electrical: elec.and_then(Electrical::parse),
})
}
/// A value by its CSV / bundle name: the [`FIELDS`], the
/// [`ELECTRICAL_FIELDS`], `UThd` and `AThd`. `None` when absent.
pub fn get(&self, name: &str) -> Option<f64> {
if let Some(i) = FIELDS.iter().position(|(n, _)| *n == name) {
return Some(self.values[i]);
}
let e = self.electrical.as_ref()?;
if let Some(i) = ELECTRICAL_FIELDS.iter().position(|(n, _)| *n == name) {
return Some(e.values()[i]);
}
let h = e.harmonics.as_ref()?;
match name {
"UThd" => Some(h.uthd),
"AThd" => Some(h.athd),
_ => None,
}
}
/// Set a scalar by name (see [`Reading::get`]); false if it does not exist here.
pub fn set(&mut self, name: &str, value: f64) -> bool {
if let Some(i) = FIELDS.iter().position(|(n, _)| *n == name) {
self.values[i] = value;
return true;
}
let Some(e) = self.electrical.as_mut() else {
return false;
};
match name {
"Voltage_V" => e.voltage_v = value,
"Current_A" => e.current_a = value,
"Power_W" => e.power_w = value,
"Freq_Hz" => e.freq_hz = value,
"PF" => e.pf = value,
"UThd" | "AThd" => {
let Some(h) = e.harmonics.as_mut() else {
return false;
};
if name == "UThd" {
h.uthd = value
} else {
h.athd = value
}
}
_ => return false,
}
true
}
/// All scalars present in this reading, in the Python driver's key order.
pub fn scalars(&self) -> Vec<(&'static str, f64)> {
let mut v: Vec<(&'static str, f64)> =
FIELDS.iter().map(|(n, _)| *n).zip(self.values).collect();
if let Some(e) = &self.electrical {
v.extend(ELECTRICAL_FIELDS.iter().map(|(n, _)| *n).zip(e.values()));
if let Some(h) = &e.harmonics {
v.push(("UThd", h.uthd));
v.push(("AThd", h.athd));
}
}
v
}
/// Wavelength of each spectrum point (nm).
pub fn spectrum_nm(&self) -> Vec<f64> {
(0..self.spectrum.len())
.map(|i| SPECTRUM_START_NM + i as f64 * SPECTRUM_STEP)
.collect()
}
fn field(&self, name: &str) -> f64 {
self.get(name).unwrap_or(0.0)
}
/// Luminous flux (lm).
pub fn flux_lm(&self) -> f64 {
self.field("Phi_lm")
}
/// Luminous efficacy (lm/W).
pub fn efficacy_lm_w(&self) -> f64 {
self.field("eta_lm_W")
}
/// Correlated colour temperature (K).
pub fn cct_k(&self) -> f64 {
self.field("CCT_K")
}
pub fn duv(&self) -> f64 {
self.field("Duv")
}
/// CRI Ra.
pub fn ra(&self) -> f64 {
self.field("Ra")
}
/// Special CRI R1..R15.
pub fn r(&self, n: usize) -> f64 {
assert!((1..=15).contains(&n), "R1..R15");
self.values[11 + n]
}
}
/// JSON with the Python driver's keys and order: `device`, the fields,
/// `test_date`, `test_time`, `spectrum`, `spectrum_nm`, then the electrical
/// values and harmonics when present.
impl Serialize for Reading {
fn serialize<S: Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
let mut m = s.serialize_map(None)?;
m.serialize_entry("device", &self.device)?;
for ((n, _), v) in FIELDS.iter().zip(self.values) {
m.serialize_entry(n, &v)?;
}
m.serialize_entry("test_date", &self.test_date)?;
m.serialize_entry("test_time", &self.test_time)?;
m.serialize_entry("spectrum", &self.spectrum)?;
m.serialize_entry("spectrum_nm", &self.spectrum_nm())?;
if let Some(e) = &self.electrical {
for ((n, _), v) in ELECTRICAL_FIELDS.iter().zip(e.values()) {
m.serialize_entry(n, &v)?;
}
if let Some(h) = &e.harmonics {
m.serialize_entry("V_harmonics", &h.voltage)?;
m.serialize_entry("UThd", &h.uthd)?;
m.serialize_entry("I_harmonics", &h.current)?;
m.serialize_entry("AThd", &h.athd)?;
m.serialize_entry("V_waveform", &h.v_waveform)?;
m.serialize_entry("I_waveform", &h.i_waveform)?;
}
}
m.end()
}
}
+284
View File
@@ -0,0 +1,284 @@
//! A simulated instrument: answers every command of the protocol like the
//! HPCS 6500 does, so tools can be developed and tested without hardware.
//!
//! Readings come from a USB capture ([`Simulator::from_pcap`]) or from the
//! built-in sample ([`Simulator::sample`], a real reading of an LED lamp).
//! Every frame the host writes is kept in [`Simulator::log`].
use crate::device::Transport;
use crate::error::Result;
use crate::pcap::{self, Dir};
use crate::protocol::{self, ELECTRICAL_HEADER, MEASUREMENT_HEADER, Mode, PsuSettings, SYNC, cmd};
use std::collections::VecDeque;
use std::io;
use std::path::Path;
use std::time::Duration;
const SAMPLE_MEAS: &[u8] = include_bytes!("../tests/fixtures/r01_meas.bin");
const SAMPLE_ELEC: &[u8] = include_bytes!("../tests/fixtures/r01_elec.bin");
/// The simulated instrument (a [`Transport`] for [`crate::Hpcs6500`]).
#[derive(Debug, Clone)]
pub struct Simulator {
readings: Vec<(Vec<u8>, Vec<u8>)>,
identify: Vec<u8>,
/// Supply settings as set by the host.
pub psu: PsuSettings,
/// Supply output on?
pub output_on: bool,
/// Last integration time set (µs), 0 = auto.
pub integration_us: u32,
/// Polls after a trigger until data is ready.
pub polls_to_ready: u32,
/// Readings served so far.
pub served: usize,
/// Every frame written by the host, in order.
pub log: Vec<Vec<u8>>,
/// Commands the instrument ignores (no answer at all), to test timeouts.
pub mute: Vec<u8>,
countdown: Option<u32>,
ready: bool,
continuous: bool,
out: VecDeque<u8>,
}
impl Simulator {
/// Serve these (measurement, electrical) block pairs in a loop.
pub fn new(readings: Vec<(Vec<u8>, Vec<u8>)>) -> Self {
assert!(!readings.is_empty(), "at least one reading");
let mut identify = vec![SYNC, cmd::IDENTIFY];
identify.extend_from_slice(b"HPCS6500\0\0");
identify.extend_from_slice(&[0; 4]);
Simulator {
readings,
identify,
psu: PsuSettings {
ac_voltage: 230.0,
ac_frequency: 50.0,
dc_voltage: 24.0,
dc_current: 5.0,
mode: Mode::Ac,
},
output_on: false,
integration_us: 0,
polls_to_ready: 2,
served: 0,
log: vec![],
mute: vec![],
countdown: None,
ready: false,
continuous: false,
out: VecDeque::new(),
}
}
/// The built-in sample reading (an LED lamp at 230 V / 50 Hz).
pub fn sample() -> Self {
Simulator::new(vec![(SAMPLE_MEAS.to_vec(), SAMPLE_ELEC.to_vec())])
}
/// Replay the readings of a USB capture; the identify and supply answers
/// are taken from the capture too when it has them.
pub fn from_pcap(path: impl AsRef<Path>) -> Result<Self> {
let msgs = pcap::read_messages(path.as_ref())?;
let blocks = pcap::blocks(&msgs);
if blocks.is_empty() {
return Err(crate::Error::Pcap(format!(
"{}: no measurement blocks",
path.as_ref().display()
)));
}
let mut s = Simulator::new(
blocks
.into_iter()
.map(|b| {
(
b.measurement,
b.electrical
.unwrap_or_else(|| vec![0; protocol::ELECTRICAL_LEN]),
)
})
.collect(),
);
let rx = || msgs.iter().filter(|m| m.dir == Dir::Rx);
if let Some(m) = rx().find(|m| m.data.len() == 16 && m.data[..2] == [SYNC, cmd::IDENTIFY]) {
s.identify = m.data.clone();
}
if let Some(p) = rx()
.find(|m| {
m.data.len() == PsuSettings::RESPONSE_LEN && m.data[..2] == [SYNC, cmd::READ_PSU]
})
.and_then(|m| PsuSettings::parse(&m.data[2..]))
{
s.psu = p;
}
Ok(s)
}
/// Number of distinct readings it serves.
pub fn len(&self) -> usize {
self.readings.len()
}
pub fn is_empty(&self) -> bool {
false
}
/// The frames of the log with this command byte.
pub fn sent(&self, command: u8) -> Vec<&Vec<u8>> {
self.log
.iter()
.filter(|f| f.len() >= 2 && f[1] == command)
.collect()
}
fn reply(&mut self, b: &[u8]) {
self.out.extend(b);
}
fn ack(&mut self, c: u8) {
self.reply(&[SYNC, c]);
}
fn trigger(&mut self, continuous: bool) {
self.continuous = continuous;
self.ready = false;
self.countdown = Some(self.polls_to_ready);
}
fn current(&self) -> &(Vec<u8>, Vec<u8>) {
&self.readings[self.served.saturating_sub(1) % self.readings.len()]
}
fn f32_arg(f: &[u8]) -> Option<(u8, f32)> {
(f.len() >= 7).then(|| (f[2], f32::from_le_bytes([f[3], f[4], f[5], f[6]])))
}
fn handle(&mut self, f: &[u8]) {
if f.len() < 2 || f[0] != SYNC || self.mute.contains(&f[1]) {
return;
}
let c = f[1];
let arg = f.get(2).copied();
match c {
cmd::IDENTIFY => {
let r = self.identify.clone();
self.reply(&r)
}
cmd::INTEGRATION_TIME if f.len() >= 6 => {
self.integration_us = u32::from_le_bytes([f[2], f[3], f[4], f[5]]);
self.ack(c)
}
cmd::POLL_STATE => {
if let Some(n) = self.countdown {
if n == 0 {
self.countdown = None;
self.ready = true;
self.served += 1;
} else {
self.countdown = Some(n - 1);
}
}
let r = self.ready as u8;
let state = if self.continuous || self.countdown.is_some() {
protocol::STATE_MEASURING
} else {
protocol::STATE_IDLE
};
self.reply(&[SYNC, c, r, r, 0, state, 0, 0, 1])
}
cmd::STATUS => self.reply(&[SYNC, c, 0, 0, 0, 0, 0]),
cmd::START => {
match arg {
Some(protocol::START_CONTINUOUS) => self.trigger(true),
// stop ends a continuous run; on an idle instrument it takes one reading
Some(protocol::STOP_OR_SINGLE) if self.continuous => {
self.continuous = false;
self.countdown = None;
}
Some(protocol::STOP_OR_SINGLE) => self.trigger(false),
_ => {}
}
self.ack(c)
}
cmd::READ_MEASUREMENT => {
let mut r = MEASUREMENT_HEADER.to_vec();
r.extend_from_slice(&self.current().0);
self.reply(&r);
self.ready = false;
if self.continuous {
self.countdown = Some(self.polls_to_ready);
}
}
cmd::READ_ELECTRICAL => {
let mut r = ELECTRICAL_HEADER.to_vec();
r.extend_from_slice(&self.current().1);
self.reply(&r)
}
cmd::RESET => {
self.continuous = false;
self.countdown = None;
self.ready = false;
self.ack(c)
}
cmd::TEST_CONFIG => self.ack(c),
cmd::PSU_OUTPUT => {
match arg {
Some(0x00) => self.output_on = true,
Some(0x01) => self.output_on = false,
_ => {}
}
self.ack(c)
}
cmd::SET_DC => {
if let Some((sub, v)) = Self::f32_arg(f) {
if sub == 0 {
self.psu.dc_current = v as f64
} else {
self.psu.dc_voltage = v as f64
}
}
self.ack(c)
}
cmd::SET_AC => {
if let Some((sub, v)) = Self::f32_arg(f) {
if sub == 0 {
self.psu.ac_voltage = v as f64
} else {
self.psu.ac_frequency = v as f64
}
}
self.ack(c)
}
cmd::READ_PSU => {
let r = self.psu.to_response();
self.reply(&r)
}
cmd::SET_MODE => {
self.psu.mode = if arg == Some(1) { Mode::Dc } else { Mode::Ac };
self.ack(c)
}
_ => {}
}
}
}
impl Transport for Simulator {
fn write_all(&mut self, data: &[u8]) -> io::Result<()> {
self.log.push(data.to_vec());
self.handle(data);
Ok(())
}
fn read_some(&mut self, buf: &mut [u8], timeout: Duration) -> io::Result<usize> {
if self.out.is_empty() {
std::thread::sleep(timeout.min(Duration::from_millis(5)));
return Ok(0);
}
let n = buf.len().min(self.out.len());
for (b, x) in buf.iter_mut().zip(self.out.drain(..n)) {
*b = x;
}
Ok(n)
}
fn clear_input(&mut self) -> io::Result<()> {
self.out.clear();
Ok(())
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+189
View File
@@ -0,0 +1,189 @@
//! Golden tests against the output of the former Python driver
//! (`hpcs6500.py`), generated from real USB captures.
use hpcs6500::format::{csv_header, csv_row, format_quick, format_reading, pcap_report};
use hpcs6500::{Reading, pcap, pyfmt};
use serde_json::Value;
use std::path::{Path, PathBuf};
fn dir(sub: &str) -> PathBuf {
Path::new(env!("CARGO_MANIFEST_DIR"))
.join("tests")
.join(sub)
}
fn fixture(name: &str) -> Reading {
let meas = std::fs::read(dir("fixtures").join(format!("{name}_meas.bin"))).unwrap();
let elec = std::fs::read(dir("fixtures").join(format!("{name}_elec.bin"))).ok();
Reading::parse(&meas, elec.as_deref()).unwrap()
}
fn golden() -> Vec<Value> {
serde_json::from_str(&std::fs::read_to_string(dir("golden").join("readings.json")).unwrap())
.unwrap()
}
fn text(name: &str) -> String {
std::fs::read_to_string(dir("golden").join(name))
.unwrap()
.replace("\r\n", "\n")
}
/// Python values in the golden file: floats as {"f": repr}, lists, ints, strings.
fn same(rust: &Value, py: &Value, path: &str) {
match py {
Value::Object(o) if o.contains_key("f") => {
let want = o["f"].as_str().unwrap();
let got = pyfmt::repr(
rust.as_f64()
.unwrap_or_else(|| panic!("{path}: not a number: {rust}")),
);
assert_eq!(got, want, "{path}");
}
Value::Array(a) => {
let r = rust
.as_array()
.unwrap_or_else(|| panic!("{path}: not a list"));
assert_eq!(r.len(), a.len(), "{path}: length");
for (i, (x, y)) in r.iter().zip(a).enumerate() {
same(x, y, &format!("{path}[{i}]"));
}
}
other => assert_eq!(rust, other, "{path}"),
}
}
#[test]
fn parsed_blocks_match_python_exactly() {
for g in golden() {
let name = g["name"].as_str().unwrap();
let r = fixture(name);
let v = serde_json::to_value(&r).unwrap();
let keys: Vec<&str> = v.as_object().unwrap().keys().map(|k| k.as_str()).collect();
let want: Vec<&str> = g["keys"]
.as_array()
.unwrap()
.iter()
.map(|k| k.as_str().unwrap())
.collect();
assert_eq!(keys, want, "{name}: key order");
for k in want {
same(&v[k], &g["values"][k], &format!("{name}.{k}"));
}
assert_eq!(
r.electrical.as_ref().unwrap().harmonics.is_some(),
g["harmonics"].as_bool().unwrap(),
"{name}"
);
}
}
#[test]
fn report_text_matches_python() {
for g in golden() {
let name = g["name"].as_str().unwrap();
let r = fixture(name);
assert_eq!(
format_reading(&r, false),
text(&format!("format_{name}.txt")),
"{name}"
);
assert_eq!(
format_reading(&r, true),
text(&format!("format_{name}_spectrum.txt")),
"{name} spectrum"
);
let n: usize = name[1..].parse().unwrap();
assert_eq!(
format!("{}\n{}", format_quick(&r, Some(n)), format_quick(&r, None)),
text(&format!("quick_{name}.txt"))
);
}
}
#[test]
fn csv_matches_python() {
for g in golden() {
let name = g["name"].as_str().unwrap();
let want = std::fs::read_to_string(dir("golden").join(format!("csv_{name}.txt"))).unwrap();
assert_eq!(csv_header() + &csv_row("T", &fixture(name)), want, "{name}");
}
}
#[test]
fn float_formatting_matches_python() {
let cases: Vec<Value> =
serde_json::from_str(&std::fs::read_to_string(dir("golden").join("pyfmt.json")).unwrap())
.unwrap();
assert!(cases.len() > 1000);
for c in &cases {
let x = f64::from_bits(c["bits"].as_u64().unwrap());
assert_eq!(pyfmt::repr(x), c["repr"].as_str().unwrap(), "repr {x:e}");
assert_eq!(pyfmt::g(x, 6), c["g6"].as_str().unwrap(), "g6 {x:e}");
for (k, p) in [("f2", 2), ("f1", 1), ("f0", 0)] {
if let Some(w) = c[k].as_str() {
assert_eq!(pyfmt::fixed(x, p), w, "{k} {x:e}");
}
}
if let Some(w) = c["round1"].as_str() {
assert_eq!(pyfmt::repr(pyfmt::round(x, 1)), w, "round1 {x:e}");
}
}
}
#[test]
fn pcap_round_trip_and_report() {
// a capture with two readings, written in memory
let (a, b) = (fixture("r01"), fixture("r02"));
let block =
|n: &str, k: &str| std::fs::read(dir("fixtures").join(format!("{n}_{k}.bin"))).unwrap();
let file = pcap::write(&[
(pcap::Dir::Tx, vec![0x8C, 0x13]),
(pcap::Dir::Rx, vec![0x8C, 0x13, 0x0F, 0x40]),
(pcap::Dir::Rx, block("r01", "meas")),
(pcap::Dir::Rx, block("r01", "elec")),
(pcap::Dir::Rx, block("r02", "meas")),
(pcap::Dir::Rx, block("r02", "elec")),
]);
let msgs = pcap::messages(&file).unwrap();
assert_eq!(msgs.len(), 6);
assert_eq!(msgs[0].dir, pcap::Dir::Tx);
assert_eq!(pcap::readings(&msgs), vec![a, b]);
let report = pcap_report(&msgs, true, false);
assert_eq!(report.lines().count(), 2);
assert!(pcap::messages(&[]).is_err());
assert!(pcap::messages(&[0u8; 30]).is_err());
}
/// The `--parse` reports of real captures. The captures are not in git
/// (`captures/` is ignored); the test runs where they are.
#[test]
fn parse_reports_of_real_captures() {
let caps = Path::new(env!("CARGO_MANIFEST_DIR")).join("captures");
let mut checked = 0;
for cap in [
"usb_capture_20260204_123655_hub1",
"usb_capture_20260204_134301_hub1",
] {
let path = caps.join(format!("{cap}.pcap"));
if !path.exists() {
eprintln!("skipped: {} not here", path.display());
continue;
}
let msgs = pcap::read_messages(&path).unwrap();
assert_eq!(
pcap_report(&msgs, false, false),
text(&format!("parse_{cap}.txt"))
);
assert_eq!(
pcap_report(&msgs, true, false),
text(&format!("parse_{cap}_quick.txt"))
);
assert_eq!(
pcap_report(&msgs, false, true),
text(&format!("parse_{cap}_harmonics.txt"))
);
checked += 1;
}
eprintln!("{checked} capture(s) checked");
}
+2
View File
@@ -0,0 +1,2 @@
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
T,483.7375183105469,57.40340042114258,5635.47412109375,0.005606670398265123,0.32931944727897644,0.34943076968193054,0.2015872299671173,0.3208470344543457,0.2015872299671173,0.48127055168151855,4.836654186248779,82.90641784667969,81.94468688964844,93.72439575195312,93.53913116455078,76.66526794433594,81.33158874511719,88.99320983886719,83.17845916748047,63.87458801269531,4.212823390960693,83.55355072021484,76.21843719482422,59.73139953613281,86.1976318359375,96.91849517822266,75.6298828125,1501.3740234375,0.0,471.6053466796875,686.2144775390625,333.9613952636719,11.230633735656738,0.0,1503.01220703125,667.4908447265625,708.2540283203125,651.1345825195312,67.85055541992188,53279.0,2154.0,2834.0,230.29359436035156,0.06537289172410965,8.406862258911133,50.02156448364258,0.5584115386009216
+2
View File
@@ -0,0 +1,2 @@
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
T,479.5904235839844,57.03839111328125,5661.3740234375,0.005495334509760141,0.3286939859390259,0.34864285588264465,0.20145727694034576,0.32052600383758545,0.20145727694034576,0.4807890057563782,4.661009311676025,83.03077697753906,82.11673736572266,93.9566421508789,93.32406616210938,76.64042663574219,81.5961685180664,89.55091094970703,83.03118896484375,64.03005981445312,4.829061031341553,84.0609130859375,76.2067642211914,60.158180236816406,86.46263885498047,96.80621337890625,75.96227264404297,1492.0255126953125,0.0,470.2662658691406,679.6720581054688,330.6041564941406,11.205649375915527,0.0,1491.7479248046875,662.0042114257812,702.18212890625,649.8577880859375,67.88436889648438,53165.0,2310.0,2834.0,230.289794921875,0.06533221900463104,8.404895782470703,50.02182388305664,0.5585951805114746
+2
View File
@@ -0,0 +1,2 @@
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
T,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0,0.0
+2
View File
@@ -0,0 +1,2 @@
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
T,479.8302917480469,57.09168243408203,5655.5185546875,0.005520321428775787,0.32882028818130493,0.3488074243068695,0.20148152112960815,0.3205926716327667,0.20148152112960815,0.4808890223503113,4.696867942810059,83.00994873046875,82.22040557861328,94.05892181396484,93.2469253540039,76.50690460205078,81.42030334472656,89.34086608886719,83.16636657714844,64.1189193725586,4.818240642547607,84.18575286865234,76.09269714355469,60.0699348449707,86.59054565429688,96.74826049804688,76.01618194580078,1491.9422607421875,0.0,469.8638610839844,679.650634765625,330.7580261230469,11.141565322875977,0.0,1491.4139404296875,662.2772216796875,702.5333251953125,649.290283203125,67.87500762939453,53051.0,2228.0,2834.0,230.30111694335938,0.06532319635152817,8.404557228088379,50.02182388305664,0.558664858341217
+2
View File
@@ -0,0 +1,2 @@
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
T,479.61688232421875,57.09785461425781,5655.04931640625,0.005514893215149641,0.32883065938949585,0.34880587458610535,0.20148910582065582,0.3205931782722473,0.20148910582065582,0.48088976740837097,4.689785480499268,83.1019287109375,82.3944320678711,94.21000671386719,93.1120834350586,76.46770477294922,81.47521209716797,89.57075500488281,83.24170684814453,64.343505859375,5.2721099853515625,84.44823455810547,76.06077575683594,60.25636291503906,86.82209777832031,96.66696166992188,76.29528045654297,1491.543701171875,0.0,469.8504333496094,679.9556274414062,331.67364501953125,11.56343936920166,0.0,1493.0426025390625,662.0065307617188,702.2208862304688,648.9866943359375,67.98677825927734,53066.0,2236.0,2834.0,230.29080200195312,0.06529296934604645,8.399910926818848,50.02178955078125,0.5586221218109131
+2
View File
@@ -0,0 +1,2 @@
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
T,479.6141357421875,57.04836654663086,5654.22216796875,0.005528819747269154,0.32884812355041504,0.34884971380233765,0.20148463547229767,0.32060936093330383,0.20148463547229767,0.48091405630111694,4.7090373039245605,82.99382781982422,82.20687103271484,94.0466079711914,93.27873229980469,76.49690246582031,81.38878631591797,89.25926208496094,83.16966247558594,64.1037368774414,4.81308126449585,84.15076446533203,76.0714340209961,60.03883743286133,86.56330108642578,96.76630401611328,75.97416687011719,1491.4090576171875,0.0,470.1175231933594,679.6602783203125,331.024658203125,11.806631088256836,0.0,1492.6082763671875,661.95458984375,702.2168579101562,648.7780151367188,67.90325927734375,53066.0,2246.0,2834.0,230.29782104492188,0.06530531495809555,8.407149314880371,50.02182388305664,0.5589380860328674
+2
View File
@@ -0,0 +1,2 @@
timestamp,Phi_lm,eta_lm_W,CCT_K,Duv,x,y,u,v,u_prime,v_prime,SDCM,Ra,R1,R2,R3,R4,R5,R6,R7,R8,R9,R10,R11,R12,R13,R14,R15,Phi_e_mW,Phi_euv_mW,Phi_eb_mW,Phi_ey_mW,Phi_er_mW,Phi_efr_mW,Phi_eir_mW,Phi_e_total,CIE_X,CIE_Y,CIE_Z,TLCI,PeakSignal,DarkSignal,Compensate,Voltage_V,Current_A,Power_W,Freq_Hz,PF
T,182.61920166015625,0.0,4936.84375,0.002135422546416521,0.3473931849002838,0.35774698853492737,0.21059946715831757,0.32531434297561646,0.21059946715831757,0.4879715144634247,1.5234547853469849,83.53800201416016,81.74681854248047,89.89591979980469,94.66126251220703,81.88838958740234,81.9393081665039,84.4315185546875,86.51227569580078,67.2285385131836,12.525043487548828,75.23241424560547,80.69010925292969,53.97742462158203,84.02236938476562,97.37865447998047,76.29852294921875,558.6363525390625,0.0,152.54922485351562,257.9691162109375,144.27931213378906,5.738129138946533,0.0,560.5353393554688,259.6396484375,267.3780212402344,220.3765106201172,71.05938720703125,55124.0,2518.0,2834.0,0.0,0.0,0.0,0.0,0.0
+41
View File
@@ -0,0 +1,41 @@
Device: HPCS6500
Date: 2026-02-04 Time: 15:38:14
--- Photometric ---
Phi (lm): 483.74
eta (lm/W): 57.40
CCT (K): 5635
Duv: 0.00561
--- Chromaticity ---
x: 0.3293 y: 0.3494
u: 0.2016 v: 0.3208
u': 0.2016 v': 0.4813
--- CRI ---
Ra: 82.9 SDCM: 4.84
R1=82, R2=94, R3=94, R4=77, R5=81, R6=89, R7=83, R8=64
R9=4, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
--- CIE 1931 ---
X: 667.491 Y: 708.254 Z: 651.135
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1501.374
Phi_eb (mW): 471.605
Phi_ey (mW): 686.214
Phi_er (mW): 333.961
Phi_efr (mW): 11.231
--- Electrical ---
Voltage (V): 230.29
Current (A): 0.0654
Power (W): 8.407
Frequency (Hz): 50.02
Power Factor: 0.5584
--- Sensor ---
Peak Signal: 53279
Dark Signal: 2154
Compensate: 2834
+113
View File
@@ -0,0 +1,113 @@
Device: HPCS6500
Date: 2026-02-04 Time: 15:38:14
--- Photometric ---
Phi (lm): 483.74
eta (lm/W): 57.40
CCT (K): 5635
Duv: 0.00561
--- Chromaticity ---
x: 0.3293 y: 0.3494
u: 0.2016 v: 0.3208
u': 0.2016 v': 0.4813
--- CRI ---
Ra: 82.9 SDCM: 4.84
R1=82, R2=94, R3=94, R4=77, R5=81, R6=89, R7=83, R8=64
R9=4, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
--- CIE 1931 ---
X: 667.491 Y: 708.254 Z: 651.135
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1501.374
Phi_eb (mW): 471.605
Phi_ey (mW): 686.214
Phi_er (mW): 333.961
Phi_efr (mW): 11.231
--- Electrical ---
Voltage (V): 230.29
Current (A): 0.0654
Power (W): 8.407
Frequency (Hz): 50.02
Power Factor: 0.5584
--- Sensor ---
Peak Signal: 53279
Dark Signal: 2154
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0005 []
389.6nm: 0.0876 []
399.2nm: 0.3179 []
408.8nm: 0.7661 [##]
418.4nm: 1.6232 [####]
428.0nm: 3.0322 [########]
437.6nm: 5.2464 [###############]
447.2nm: 8.7276 [########################]
456.8nm: 12.7905 [####################################]
466.4nm: 13.6955 [#######################################]
476.0nm: 10.7966 [##############################]
485.6nm: 8.2842 [#######################]
495.2nm: 6.7507 [###################]
504.8nm: 5.4679 [###############]
514.4nm: 4.6912 [#############]
524.0nm: 4.4910 [############]
533.6nm: 4.6211 [#############]
543.2nm: 4.9149 [##############]
552.8nm: 5.3253 [###############]
562.4nm: 5.7463 [################]
572.0nm: 6.0899 [#################]
581.6nm: 6.3225 [##################]
591.2nm: 6.4763 [##################]
600.8nm: 6.5981 [##################]
610.4nm: 6.7011 [###################]
620.0nm: 6.8460 [###################]
629.6nm: 6.9854 [####################]
639.2nm: 7.1199 [####################]
648.8nm: 7.2257 [####################]
658.4nm: 7.2971 [####################]
668.0nm: 7.3692 [#####################]
677.6nm: 7.4499 [#####################]
687.2nm: 7.5612 [#####################]
696.8nm: 7.5899 [#####################]
706.4nm: 7.5444 [#####################]
716.0nm: 7.4506 [#####################]
725.6nm: 7.2796 [####################]
735.2nm: 7.0827 [####################]
744.8nm: 6.7888 [###################]
754.4nm: 6.4305 [##################]
764.0nm: 6.0190 [#################]
773.6nm: 5.6257 [################]
783.2nm: 5.1849 [##############]
792.8nm: 4.7472 [#############]
802.3nm: 4.3186 [############]
811.9nm: 3.9139 [###########]
821.5nm: 3.4785 [#########]
831.1nm: 3.0552 [########]
840.7nm: 2.6780 [#######]
850.3nm: 2.3105 [######]
859.9nm: 2.0011 [#####]
869.5nm: 1.7210 [####]
879.1nm: 1.4606 [####]
888.7nm: 1.2518 [###]
898.3nm: 1.0460 [##]
907.9nm: 0.8661 [##]
917.5nm: 0.7120 [##]
927.1nm: 0.5761 [#]
936.7nm: 0.4697 [#]
946.3nm: 0.3787 [#]
955.9nm: 0.3071 []
965.5nm: 0.2300 []
975.1nm: 0.1782 []
984.7nm: 0.1421 []
994.3nm: 0.1106 []
1003.9nm: 0.0824 []
1013.5nm: 0.0610 []
1023.1nm: 0.0405 []
1032.7nm: 0.0208 []
1042.3nm: 0.0087 []
+43
View File
@@ -0,0 +1,43 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:29:39
--- Photometric ---
Phi (lm): 479.59
eta (lm/W): 57.04
CCT (K): 5661
Duv: 0.00550
--- Chromaticity ---
x: 0.3287 y: 0.3486
u: 0.2015 v: 0.3205
u': 0.2015 v': 0.4808
--- CRI ---
Ra: 83.0 SDCM: 4.66
R1=82, R2=94, R3=93, R4=77, R5=82, R6=90, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
--- CIE 1931 ---
X: 662.004 Y: 702.182 Z: 649.858
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1492.026
Phi_eb (mW): 470.266
Phi_ey (mW): 679.672
Phi_er (mW): 330.604
Phi_efr (mW): 11.206
--- Electrical ---
Voltage (V): 230.29
Current (A): 0.0653
Power (W): 8.405
Frequency (Hz): 50.02
Power Factor: 0.5586
UThd (%): 0.8113
AThd (%): 109.0115
--- Sensor ---
Peak Signal: 53165
Dark Signal: 2310
Compensate: 2834
+218
View File
@@ -0,0 +1,218 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:29:39
--- Photometric ---
Phi (lm): 479.59
eta (lm/W): 57.04
CCT (K): 5661
Duv: 0.00550
--- Chromaticity ---
x: 0.3287 y: 0.3486
u: 0.2015 v: 0.3205
u': 0.2015 v': 0.4808
--- CRI ---
Ra: 83.0 SDCM: 4.66
R1=82, R2=94, R3=93, R4=77, R5=82, R6=90, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
--- CIE 1931 ---
X: 662.004 Y: 702.182 Z: 649.858
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1492.026
Phi_eb (mW): 470.266
Phi_ey (mW): 679.672
Phi_er (mW): 330.604
Phi_efr (mW): 11.206
--- Electrical ---
Voltage (V): 230.29
Current (A): 0.0653
Power (W): 8.405
Frequency (Hz): 50.02
Power Factor: 0.5586
UThd (%): 0.8113
AThd (%): 109.0115
--- Voltage Harmonics (%) ---
H 1: 100.0000
H 2: 0.3370
H 3: 0.4587
H 4: 0.1953
H 5: 0.1626
H 6: 0.1061
H 7: 0.1800
H 8: 0.0513
H 9: 0.2902
H10: 0.0447
H11: 0.2477
H12: 0.0023
H13: 0.1861
H14: 0.0063
H15: 0.1183
H16: 0.0155
H17: 0.1239
H18: 0.0149
H19: 0.0590
H20: 0.0080
H21: 0.0465
H23: 0.0217
H24: 0.0079
H25: 0.0185
H26: 0.0076
H27: 0.0265
H28: 0.0027
H29: 0.0136
H30: 0.0066
H31: 0.0275
H32: 0.0042
H33: 0.0146
H34: 0.0129
H35: 0.0164
H36: 0.0080
H37: 0.0128
H38: 0.0066
H39: 0.0194
H40: 0.0092
H41: 0.0086
H42: 0.0019
H43: 0.0170
H44: 0.0057
H45: 0.0138
H46: 0.0013
H47: 0.0083
H48: 0.0043
H49: 0.0266
H50: 0.0067
--- Current Harmonics (%) ---
H 1: 100.0000
H 2: 1.1245
H 3: 77.3376
H 4: 1.3714
H 5: 54.7943
H 6: 1.2291
H 7: 35.3840
H 8: 1.1566
H 9: 25.6305
H10: 1.3803
H11: 20.6588
H12: 1.3435
H13: 14.4722
H14: 0.8883
H15: 8.2279
H16: 0.4150
H17: 5.8570
H18: 0.4188
H19: 5.5059
H20: 0.6645
H21: 5.3454
H22: 0.5912
H23: 5.7325
H24: 0.5132
H25: 5.0631
H26: 0.5359
H27: 4.0568
H28: 0.6665
H29: 3.8878
H30: 0.6506
H31: 3.9866
H32: 0.6108
H33: 3.3681
H34: 0.5727
H35: 2.7296
H36: 0.6597
H37: 2.9302
H38: 0.6761
H39: 2.9201
H40: 0.5632
H41: 2.7267
H42: 0.5785
H43: 2.7861
H44: 0.6036
H45: 2.7887
H46: 0.6251
H47: 2.5635
H48: 0.5767
H49: 2.3997
H50: 0.5808
--- Sensor ---
Peak Signal: 53165
Dark Signal: 2310
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0001 []
389.6nm: 0.0770 []
399.2nm: 0.3100 []
408.8nm: 0.7661 [##]
418.4nm: 1.6114 [####]
428.0nm: 3.0064 [########]
437.6nm: 5.1990 [###############]
447.2nm: 8.6082 [########################]
456.8nm: 12.6327 [####################################]
466.4nm: 13.6560 [#######################################]
476.0nm: 10.8536 [###############################]
485.6nm: 8.3290 [########################]
495.2nm: 6.7889 [###################]
504.8nm: 5.5147 [###############]
514.4nm: 4.7041 [#############]
524.0nm: 4.4857 [############]
533.6nm: 4.6031 [#############]
543.2nm: 4.8814 [##############]
552.8nm: 5.2789 [###############]
562.4nm: 5.6901 [################]
572.0nm: 6.0276 [#################]
581.6nm: 6.2592 [##################]
591.2nm: 6.4122 [##################]
600.8nm: 6.5277 [##################]
610.4nm: 6.6358 [###################]
620.0nm: 6.7792 [###################]
629.6nm: 6.9254 [###################]
639.2nm: 7.0578 [####################]
648.8nm: 7.1569 [####################]
658.4nm: 7.2319 [####################]
668.0nm: 7.2977 [#####################]
677.6nm: 7.3732 [#####################]
687.2nm: 7.4891 [#####################]
696.8nm: 7.5217 [#####################]
706.4nm: 7.4749 [#####################]
716.0nm: 7.3825 [#####################]
725.6nm: 7.2120 [####################]
735.2nm: 7.0133 [####################]
744.8nm: 6.7207 [###################]
754.4nm: 6.3674 [##################]
764.0nm: 5.9602 [#################]
773.6nm: 5.5667 [################]
783.2nm: 5.1329 [##############]
792.8nm: 4.7013 [#############]
802.3nm: 4.2745 [############]
811.9nm: 3.8716 [###########]
821.5nm: 3.4390 [#########]
831.1nm: 3.0240 [########]
840.7nm: 2.6514 [#######]
850.3nm: 2.2870 [######]
859.9nm: 1.9779 [#####]
869.5nm: 1.7045 [####]
879.1nm: 1.4498 [####]
888.7nm: 1.2401 [###]
898.3nm: 1.0367 [##]
907.9nm: 0.8554 [##]
917.5nm: 0.7071 [##]
927.1nm: 0.5781 [#]
936.7nm: 0.4678 [#]
946.3nm: 0.3798 [#]
955.9nm: 0.3085 []
965.5nm: 0.2315 []
975.1nm: 0.1769 []
984.7nm: 0.1405 []
994.3nm: 0.1102 []
1003.9nm: 0.0810 []
1013.5nm: 0.0582 []
1023.1nm: 0.0389 []
1032.7nm: 0.0225 []
1042.3nm: 0.0079 []
+43
View File
@@ -0,0 +1,43 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:50:38
--- Photometric ---
Phi (lm): 0.00
eta (lm/W): 0.00
CCT (K): 0
Duv: 0.00000
--- Chromaticity ---
x: 0.0000 y: 0.0000
u: 0.0000 v: 0.0000
u': 0.0000 v': 0.0000
--- CRI ---
Ra: 0.0 SDCM: 0.00
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
--- CIE 1931 ---
X: 0.000 Y: 0.000 Z: 0.000
TLCI-2012: 0
--- Radiometric ---
Phi_e (mW): 0.000
Phi_eb (mW): 0.000
Phi_ey (mW): 0.000
Phi_er (mW): 0.000
Phi_efr (mW): 0.000
--- Electrical ---
Voltage (V): 0.00
Current (A): 0.0000
Power (W): 0.000
Frequency (Hz): 0.00
Power Factor: 0.0000
UThd (%): 0.8692
AThd (%): 116.7091
--- Sensor ---
Peak Signal: 0
Dark Signal: 0
Compensate: 0
+219
View File
@@ -0,0 +1,219 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:50:38
--- Photometric ---
Phi (lm): 0.00
eta (lm/W): 0.00
CCT (K): 0
Duv: 0.00000
--- Chromaticity ---
x: 0.0000 y: 0.0000
u: 0.0000 v: 0.0000
u': 0.0000 v': 0.0000
--- CRI ---
Ra: 0.0 SDCM: 0.00
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
--- CIE 1931 ---
X: 0.000 Y: 0.000 Z: 0.000
TLCI-2012: 0
--- Radiometric ---
Phi_e (mW): 0.000
Phi_eb (mW): 0.000
Phi_ey (mW): 0.000
Phi_er (mW): 0.000
Phi_efr (mW): 0.000
--- Electrical ---
Voltage (V): 0.00
Current (A): 0.0000
Power (W): 0.000
Frequency (Hz): 0.00
Power Factor: 0.0000
UThd (%): 0.8692
AThd (%): 116.7091
--- Voltage Harmonics (%) ---
H 1: 100.0000
H 2: 0.3466
H 3: 0.5529
H 4: 0.1935
H 5: 0.1009
H 6: 0.1319
H 7: 0.1455
H 8: 0.0709
H 9: 0.3610
H10: 0.0452
H11: 0.2558
H12: 0.0099
H13: 0.1471
H14: 0.0182
H15: 0.0881
H16: 0.0119
H17: 0.0439
H18: 0.0054
H19: 0.0441
H20: 0.0039
H21: 0.0444
H22: 0.0038
H23: 0.0419
H24: 0.0070
H25: 0.0277
H26: 0.0086
H27: 0.0180
H28: 0.0038
H29: 0.0168
H30: 0.0031
H31: 0.0085
H32: 0.0042
H33: 0.0141
H34: 0.0039
H35: 0.0050
H36: 0.0076
H37: 0.0172
H38: 0.0017
H39: 0.0135
H40: 0.0122
H41: 0.0075
H42: 0.0091
H43: 0.0079
H44: 0.0052
H45: 0.0028
H46: 0.0063
H47: 0.0106
H48: 0.0055
H49: 0.0066
H50: 0.0032
--- Current Harmonics (%) ---
H 1: 100.0000
H 2: 1.3069
H 3: 79.6826
H 4: 1.6782
H 5: 62.0099
H 6: 1.8375
H 7: 43.0251
H 8: 1.4561
H 9: 27.1434
H10: 0.8638
H11: 16.3288
H12: 0.8914
H13: 10.5836
H14: 0.8186
H15: 7.8296
H16: 0.6092
H17: 7.1998
H18: 0.5320
H19: 6.8390
H20: 0.7901
H21: 5.9471
H22: 0.8641
H23: 5.4131
H24: 0.7893
H25: 5.4738
H26: 0.6972
H27: 5.4525
H28: 0.6923
H29: 4.8222
H30: 0.7761
H31: 4.3909
H32: 0.7792
H33: 4.3903
H34: 0.7458
H35: 4.3602
H36: 0.7061
H37: 3.9436
H38: 0.6865
H39: 3.6134
H40: 0.6928
H41: 3.4965
H42: 0.7969
H43: 3.1709
H44: 0.7282
H45: 2.8721
H46: 0.6770
H47: 2.6774
H48: 0.6486
H49: 2.6343
H50: 0.6855
--- Sensor ---
Peak Signal: 0
Dark Signal: 0
Compensate: 0
--- Spectrum (380-1050nm) ---
380.0nm: 0.0000 []
389.6nm: 0.0000 []
399.2nm: 0.0000 []
408.8nm: 0.0000 []
418.4nm: 0.0000 []
428.0nm: 0.0000 []
437.6nm: 0.0000 []
447.2nm: 0.0000 []
456.8nm: 0.0000 []
466.4nm: 0.0000 []
476.0nm: 0.0000 []
485.6nm: 0.0000 []
495.2nm: 0.0000 []
504.8nm: 0.0000 []
514.4nm: 0.0000 []
524.0nm: 0.0000 []
533.6nm: 0.0000 []
543.2nm: 0.0000 []
552.8nm: 0.0000 []
562.4nm: 0.0000 []
572.0nm: 0.0000 []
581.6nm: 0.0000 []
591.2nm: 0.0000 []
600.8nm: 0.0000 []
610.4nm: 0.0000 []
620.0nm: 0.0000 []
629.6nm: 0.0000 []
639.2nm: 0.0000 []
648.8nm: 0.0000 []
658.4nm: 0.0000 []
668.0nm: 0.0000 []
677.6nm: 0.0000 []
687.2nm: 0.0000 []
696.8nm: 0.0000 []
706.4nm: 0.0000 []
716.0nm: 0.0000 []
725.6nm: 0.0000 []
735.2nm: 0.0000 []
744.8nm: 0.0000 []
754.4nm: 0.0000 []
764.0nm: 0.0000 []
773.6nm: 0.0000 []
783.2nm: 0.0000 []
792.8nm: 0.0000 []
802.3nm: 0.0000 []
811.9nm: 0.0000 []
821.5nm: 0.0000 []
831.1nm: 0.0000 []
840.7nm: 0.0000 []
850.3nm: 0.0000 []
859.9nm: 0.0000 []
869.5nm: 0.0000 []
879.1nm: 0.0000 []
888.7nm: 0.0000 []
898.3nm: 0.0000 []
907.9nm: 0.0000 []
917.5nm: 0.0000 []
927.1nm: 0.0000 []
936.7nm: 0.0000 []
946.3nm: 0.0000 []
955.9nm: 0.0000 []
965.5nm: 0.0000 []
975.1nm: 0.0000 []
984.7nm: 0.0000 []
994.3nm: 0.0000 []
1003.9nm: 0.0000 []
1013.5nm: 0.0000 []
1023.1nm: 0.0000 []
1032.7nm: 0.0000 []
1042.3nm: 0.0000 []
+41
View File
@@ -0,0 +1,41 @@
Device: HPCS6500
Date: 2026-02-04 Time: 15:58:53
--- Photometric ---
Phi (lm): 479.83
eta (lm/W): 57.09
CCT (K): 5656
Duv: 0.00552
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.70
R1=82, R2=94, R3=93, R4=77, R5=81, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 662.277 Y: 702.533 Z: 649.290
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1491.942
Phi_eb (mW): 469.864
Phi_ey (mW): 679.651
Phi_er (mW): 330.758
Phi_efr (mW): 11.142
--- Electrical ---
Voltage (V): 230.30
Current (A): 0.0653
Power (W): 8.405
Frequency (Hz): 50.02
Power Factor: 0.5587
--- Sensor ---
Peak Signal: 53051
Dark Signal: 2228
Compensate: 2834
+113
View File
@@ -0,0 +1,113 @@
Device: HPCS6500
Date: 2026-02-04 Time: 15:58:53
--- Photometric ---
Phi (lm): 479.83
eta (lm/W): 57.09
CCT (K): 5656
Duv: 0.00552
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.70
R1=82, R2=94, R3=93, R4=77, R5=81, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 662.277 Y: 702.533 Z: 649.290
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1491.942
Phi_eb (mW): 469.864
Phi_ey (mW): 679.651
Phi_er (mW): 330.758
Phi_efr (mW): 11.142
--- Electrical ---
Voltage (V): 230.30
Current (A): 0.0653
Power (W): 8.405
Frequency (Hz): 50.02
Power Factor: 0.5587
--- Sensor ---
Peak Signal: 53051
Dark Signal: 2228
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0000 []
389.6nm: 0.0838 []
399.2nm: 0.3137 []
408.8nm: 0.7712 [##]
418.4nm: 1.6204 [####]
428.0nm: 3.0103 [########]
437.6nm: 5.1952 [###############]
447.2nm: 8.5932 [########################]
456.8nm: 12.6190 [####################################]
466.4nm: 13.6409 [#######################################]
476.0nm: 10.8421 [###############################]
485.6nm: 8.3131 [########################]
495.2nm: 6.7764 [###################]
504.8nm: 5.5052 [###############]
514.4nm: 4.6985 [#############]
524.0nm: 4.4838 [############]
533.6nm: 4.5985 [#############]
543.2nm: 4.8779 [##############]
552.8nm: 5.2744 [###############]
562.4nm: 5.6922 [################]
572.0nm: 6.0260 [#################]
581.6nm: 6.2605 [##################]
591.2nm: 6.4065 [##################]
600.8nm: 6.5303 [##################]
610.4nm: 6.6307 [###################]
620.0nm: 6.7723 [###################]
629.6nm: 6.9229 [###################]
639.2nm: 7.0529 [####################]
648.8nm: 7.1562 [####################]
658.4nm: 7.2298 [####################]
668.0nm: 7.2981 [#####################]
677.6nm: 7.3778 [#####################]
687.2nm: 7.4896 [#####################]
696.8nm: 7.5259 [#####################]
706.4nm: 7.4780 [#####################]
716.0nm: 7.3868 [#####################]
725.6nm: 7.2144 [####################]
735.2nm: 7.0180 [####################]
744.8nm: 6.7219 [###################]
754.4nm: 6.3660 [##################]
764.0nm: 5.9605 [#################]
773.6nm: 5.5733 [################]
783.2nm: 5.1339 [##############]
792.8nm: 4.7023 [#############]
802.3nm: 4.2764 [############]
811.9nm: 3.8736 [###########]
821.5nm: 3.4459 [#########]
831.1nm: 3.0290 [########]
840.7nm: 2.6555 [#######]
850.3nm: 2.2888 [######]
859.9nm: 1.9765 [#####]
869.5nm: 1.7011 [####]
879.1nm: 1.4485 [####]
888.7nm: 1.2418 [###]
898.3nm: 1.0371 [##]
907.9nm: 0.8565 [##]
917.5nm: 0.7085 [##]
927.1nm: 0.5742 [#]
936.7nm: 0.4662 [#]
946.3nm: 0.3787 [#]
955.9nm: 0.3092 []
965.5nm: 0.2320 []
975.1nm: 0.1777 []
984.7nm: 0.1411 []
994.3nm: 0.1086 []
1003.9nm: 0.0792 []
1013.5nm: 0.0565 []
1023.1nm: 0.0397 []
1032.7nm: 0.0197 []
1042.3nm: 0.0046 []
+41
View File
@@ -0,0 +1,41 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:04:22
--- Photometric ---
Phi (lm): 479.62
eta (lm/W): 57.10
CCT (K): 5655
Duv: 0.00551
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.1 SDCM: 4.69
R1=82, R2=94, R3=93, R4=76, R5=81, R6=90, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 662.007 Y: 702.221 Z: 648.987
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1491.544
Phi_eb (mW): 469.850
Phi_ey (mW): 679.956
Phi_er (mW): 331.674
Phi_efr (mW): 11.563
--- Electrical ---
Voltage (V): 230.29
Current (A): 0.0653
Power (W): 8.400
Frequency (Hz): 50.02
Power Factor: 0.5586
--- Sensor ---
Peak Signal: 53066
Dark Signal: 2236
Compensate: 2834
+113
View File
@@ -0,0 +1,113 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:04:22
--- Photometric ---
Phi (lm): 479.62
eta (lm/W): 57.10
CCT (K): 5655
Duv: 0.00551
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.1 SDCM: 4.69
R1=82, R2=94, R3=93, R4=76, R5=81, R6=90, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 662.007 Y: 702.221 Z: 648.987
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1491.544
Phi_eb (mW): 469.850
Phi_ey (mW): 679.956
Phi_er (mW): 331.674
Phi_efr (mW): 11.563
--- Electrical ---
Voltage (V): 230.29
Current (A): 0.0653
Power (W): 8.400
Frequency (Hz): 50.02
Power Factor: 0.5586
--- Sensor ---
Peak Signal: 53066
Dark Signal: 2236
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0004 []
389.6nm: 0.0896 []
399.2nm: 0.3198 []
408.8nm: 0.7697 [##]
418.4nm: 1.6099 [####]
428.0nm: 2.9921 [########]
437.6nm: 5.1750 [##############]
447.2nm: 8.5520 [########################]
456.8nm: 12.5835 [####################################]
466.4nm: 13.6533 [#######################################]
476.0nm: 10.8713 [###############################]
485.6nm: 8.3388 [########################]
495.2nm: 6.7961 [###################]
504.8nm: 5.5178 [###############]
514.4nm: 4.7100 [#############]
524.0nm: 4.4835 [############]
533.6nm: 4.5955 [#############]
543.2nm: 4.8758 [##############]
552.8nm: 5.2754 [###############]
562.4nm: 5.6932 [################]
572.0nm: 6.0259 [#################]
581.6nm: 6.2639 [##################]
591.2nm: 6.4078 [##################]
600.8nm: 6.5338 [##################]
610.4nm: 6.6373 [###################]
620.0nm: 6.7757 [###################]
629.6nm: 6.9260 [####################]
639.2nm: 7.0568 [####################]
648.8nm: 7.1664 [####################]
658.4nm: 7.2312 [####################]
668.0nm: 7.2992 [#####################]
677.6nm: 7.3760 [#####################]
687.2nm: 7.4918 [#####################]
696.8nm: 7.5267 [#####################]
706.4nm: 7.4803 [#####################]
716.0nm: 7.3913 [#####################]
725.6nm: 7.2224 [####################]
735.2nm: 7.0243 [####################]
744.8nm: 6.7270 [###################]
754.4nm: 6.3784 [##################]
764.0nm: 5.9711 [#################]
773.6nm: 5.5765 [################]
783.2nm: 5.1446 [##############]
792.8nm: 4.7115 [#############]
802.3nm: 4.2878 [############]
811.9nm: 3.8857 [###########]
821.5nm: 3.4575 [#########]
831.1nm: 3.0392 [########]
840.7nm: 2.6646 [#######]
850.3nm: 2.2973 [######]
859.9nm: 1.9856 [#####]
869.5nm: 1.7133 [####]
879.1nm: 1.4529 [####]
888.7nm: 1.2538 [###]
898.3nm: 1.0436 [###]
907.9nm: 0.8680 [##]
917.5nm: 0.7157 [##]
927.1nm: 0.5790 [#]
936.7nm: 0.4731 [#]
946.3nm: 0.3838 [#]
955.9nm: 0.3139 []
965.5nm: 0.2378 []
975.1nm: 0.1854 []
984.7nm: 0.1477 []
994.3nm: 0.1152 []
1003.9nm: 0.0853 []
1013.5nm: 0.0633 []
1023.1nm: 0.0463 []
1032.7nm: 0.0285 []
1042.3nm: 0.0129 []
+41
View File
@@ -0,0 +1,41 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:04:31
--- Photometric ---
Phi (lm): 479.61
eta (lm/W): 57.05
CCT (K): 5654
Duv: 0.00553
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.71
R1=82, R2=94, R3=93, R4=76, R5=81, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 661.955 Y: 702.217 Z: 648.778
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1491.409
Phi_eb (mW): 470.118
Phi_ey (mW): 679.660
Phi_er (mW): 331.025
Phi_efr (mW): 11.807
--- Electrical ---
Voltage (V): 230.30
Current (A): 0.0653
Power (W): 8.407
Frequency (Hz): 50.02
Power Factor: 0.5589
--- Sensor ---
Peak Signal: 53066
Dark Signal: 2246
Compensate: 2834
+113
View File
@@ -0,0 +1,113 @@
Device: HPCS6500
Date: 2026-02-04 Time: 16:04:31
--- Photometric ---
Phi (lm): 479.61
eta (lm/W): 57.05
CCT (K): 5654
Duv: 0.00553
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.71
R1=82, R2=94, R3=93, R4=76, R5=81, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 661.955 Y: 702.217 Z: 648.778
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1491.409
Phi_eb (mW): 470.118
Phi_ey (mW): 679.660
Phi_er (mW): 331.025
Phi_efr (mW): 11.807
--- Electrical ---
Voltage (V): 230.30
Current (A): 0.0653
Power (W): 8.407
Frequency (Hz): 50.02
Power Factor: 0.5589
--- Sensor ---
Peak Signal: 53066
Dark Signal: 2246
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0007 []
389.6nm: 0.1039 []
399.2nm: 0.3310 []
408.8nm: 0.7735 [##]
418.4nm: 1.6222 [####]
428.0nm: 3.0048 [########]
437.6nm: 5.1925 [##############]
447.2nm: 8.5847 [########################]
456.8nm: 12.6066 [####################################]
466.4nm: 13.6462 [#######################################]
476.0nm: 10.8482 [###############################]
485.6nm: 8.3167 [########################]
495.2nm: 6.7854 [###################]
504.8nm: 5.5031 [###############]
514.4nm: 4.7003 [#############]
524.0nm: 4.4902 [############]
533.6nm: 4.6031 [#############]
543.2nm: 4.8753 [##############]
552.8nm: 5.2749 [###############]
562.4nm: 5.6866 [################]
572.0nm: 6.0231 [#################]
581.6nm: 6.2593 [##################]
591.2nm: 6.4088 [##################]
600.8nm: 6.5298 [##################]
610.4nm: 6.6314 [###################]
620.0nm: 6.7770 [###################]
629.6nm: 6.9241 [###################]
639.2nm: 7.0596 [####################]
648.8nm: 7.1581 [####################]
658.4nm: 7.2321 [####################]
668.0nm: 7.2993 [#####################]
677.6nm: 7.3692 [#####################]
687.2nm: 7.4894 [#####################]
696.8nm: 7.5227 [#####################]
706.4nm: 7.4804 [#####################]
716.0nm: 7.3870 [#####################]
725.6nm: 7.2145 [####################]
735.2nm: 7.0141 [####################]
744.8nm: 6.7182 [###################]
754.4nm: 6.3698 [##################]
764.0nm: 5.9647 [#################]
773.6nm: 5.5730 [################]
783.2nm: 5.1334 [##############]
792.8nm: 4.7031 [#############]
802.3nm: 4.2776 [############]
811.9nm: 3.8751 [###########]
821.5nm: 3.4468 [#########]
831.1nm: 3.0270 [########]
840.7nm: 2.6591 [#######]
850.3nm: 2.2925 [######]
859.9nm: 1.9769 [#####]
869.5nm: 1.7079 [####]
879.1nm: 1.4541 [####]
888.7nm: 1.2449 [###]
898.3nm: 1.0430 [###]
907.9nm: 0.8654 [##]
917.5nm: 0.7167 [##]
927.1nm: 0.5787 [#]
936.7nm: 0.4744 [#]
946.3nm: 0.3864 [#]
955.9nm: 0.3180 []
965.5nm: 0.2400 []
975.1nm: 0.1895 []
984.7nm: 0.1487 []
994.3nm: 0.1197 []
1003.9nm: 0.0899 []
1013.5nm: 0.0693 []
1023.1nm: 0.0503 []
1032.7nm: 0.0345 []
1042.3nm: 0.0189 []
+41
View File
@@ -0,0 +1,41 @@
Device: HPCS6500
Date: 2026-02-05 Time: 17:57:56
--- Photometric ---
Phi (lm): 182.62
eta (lm/W): 0.00
CCT (K): 4937
Duv: 0.00214
--- Chromaticity ---
x: 0.3474 y: 0.3577
u: 0.2106 v: 0.3253
u': 0.2106 v': 0.4880
--- CRI ---
Ra: 83.5 SDCM: 1.52
R1=82, R2=90, R3=95, R4=82, R5=82, R6=84, R7=87, R8=67
R9=13, R10=75, R11=81, R12=54, R13=84, R14=97, R15=76
--- CIE 1931 ---
X: 259.640 Y: 267.378 Z: 220.377
TLCI-2012: 71
--- Radiometric ---
Phi_e (mW): 558.636
Phi_eb (mW): 152.549
Phi_ey (mW): 257.969
Phi_er (mW): 144.279
Phi_efr (mW): 5.738
--- Electrical ---
Voltage (V): 0.00
Current (A): 0.0000
Power (W): 0.000
Frequency (Hz): 0.00
Power Factor: 0.0000
--- Sensor ---
Peak Signal: 55124
Dark Signal: 2518
Compensate: 2834
+113
View File
@@ -0,0 +1,113 @@
Device: HPCS6500
Date: 2026-02-05 Time: 17:57:56
--- Photometric ---
Phi (lm): 182.62
eta (lm/W): 0.00
CCT (K): 4937
Duv: 0.00214
--- Chromaticity ---
x: 0.3474 y: 0.3577
u: 0.2106 v: 0.3253
u': 0.2106 v': 0.4880
--- CRI ---
Ra: 83.5 SDCM: 1.52
R1=82, R2=90, R3=95, R4=82, R5=82, R6=84, R7=87, R8=67
R9=13, R10=75, R11=81, R12=54, R13=84, R14=97, R15=76
--- CIE 1931 ---
X: 259.640 Y: 267.378 Z: 220.377
TLCI-2012: 71
--- Radiometric ---
Phi_e (mW): 558.636
Phi_eb (mW): 152.549
Phi_ey (mW): 257.969
Phi_er (mW): 144.279
Phi_efr (mW): 5.738
--- Electrical ---
Voltage (V): 0.00
Current (A): 0.0000
Power (W): 0.000
Frequency (Hz): 0.00
Power Factor: 0.0000
--- Sensor ---
Peak Signal: 55124
Dark Signal: 2518
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0128 []
389.6nm: 0.0556 []
399.2nm: 0.1567 [#]
408.8nm: 0.3618 [##]
418.4nm: 0.7370 [#####]
428.0nm: 1.3826 [##########]
437.6nm: 2.5671 [####################]
447.2nm: 4.3754 [##################################]
456.8nm: 5.0546 [#######################################]
466.4nm: 3.6483 [############################]
476.0nm: 2.4748 [###################]
485.6nm: 1.9566 [###############]
495.2nm: 1.4662 [###########]
504.8nm: 1.1959 [#########]
514.4nm: 1.2093 [#########]
524.0nm: 1.3289 [##########]
533.6nm: 1.5025 [###########]
543.2nm: 1.7276 [#############]
552.8nm: 1.9439 [###############]
562.4nm: 2.1145 [################]
572.0nm: 2.2373 [#################]
581.6nm: 2.3242 [##################]
591.2nm: 2.3852 [##################]
600.8nm: 2.4435 [###################]
610.4nm: 2.5010 [###################]
620.0nm: 2.5665 [####################]
629.6nm: 2.6256 [####################]
639.2nm: 2.6859 [####################]
648.8nm: 2.7312 [#####################]
658.4nm: 2.7605 [#####################]
668.0nm: 2.7875 [#####################]
677.6nm: 2.8117 [#####################]
687.2nm: 2.8452 [######################]
696.8nm: 2.8615 [######################]
706.4nm: 2.8705 [######################]
716.0nm: 2.8641 [######################]
725.6nm: 2.8432 [######################]
735.2nm: 2.8032 [#####################]
744.8nm: 2.7383 [#####################]
754.4nm: 2.6451 [####################]
764.0nm: 2.5241 [###################]
773.6nm: 2.3959 [##################]
783.2nm: 2.2367 [#################]
792.8nm: 2.0679 [################]
802.3nm: 1.8961 [##############]
811.9nm: 1.7246 [#############]
821.5nm: 1.5452 [############]
831.1nm: 1.3706 [##########]
840.7nm: 1.2060 [#########]
850.3nm: 1.0473 [########]
859.9nm: 0.9111 [#######]
869.5nm: 0.7856 [######]
879.1nm: 0.6734 [#####]
888.7nm: 0.5791 [####]
898.3nm: 0.4811 [###]
907.9nm: 0.4019 [###]
917.5nm: 0.3368 [##]
927.1nm: 0.2715 [##]
936.7nm: 0.2226 [#]
946.3nm: 0.1786 [#]
955.9nm: 0.1472 [#]
965.5nm: 0.1139 []
975.1nm: 0.0910 []
984.7nm: 0.0706 []
994.3nm: 0.0585 []
1003.9nm: 0.0451 []
1013.5nm: 0.0379 []
1023.1nm: 0.0298 []
1032.7nm: 0.0207 []
1042.3nm: 0.0151 []
@@ -0,0 +1,90 @@
============================================================
Reading 1 (t=0.01s)
============================================================
Device: HPCS6500
Date: 2026-02-04 Time: 15:58:55
--- Photometric ---
Phi (lm): 479.98
eta (lm/W): 57.09
CCT (K): 5653
Duv: 0.00551
--- Chromaticity ---
x: 0.3289 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.69
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 662.526 Y: 702.748 Z: 649.286
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1492.631
Phi_eb (mW): 470.398
Phi_ey (mW): 680.493
Phi_er (mW): 331.425
Phi_efr (mW): 11.458
--- Electrical ---
Voltage (V): 230.31
Current (A): 0.0653
Power (W): 8.407
Frequency (Hz): 50.02
Power Factor: 0.5591
--- Sensor ---
Peak Signal: 53066
Dark Signal: 2219
Compensate: 2834
============================================================
Reading 2 (t=2.13s)
============================================================
Device: HPCS6500
Date: 2026-02-04 Time: 16:02:06
--- Photometric ---
Phi (lm): 479.89
eta (lm/W): 57.09
CCT (K): 5655
Duv: 0.00550
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.67
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
--- CIE 1931 ---
X: 662.427 Y: 702.620 Z: 649.490
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1492.469
Phi_eb (mW): 470.073
Phi_ey (mW): 679.697
Phi_er (mW): 330.648
Phi_efr (mW): 11.237
--- Electrical ---
Voltage (V): 230.30
Current (A): 0.0653
Power (W): 8.406
Frequency (Hz): 50.02
Power Factor: 0.5592
--- Sensor ---
Peak Signal: 53055
Dark Signal: 2246
Compensate: 2834
@@ -0,0 +1,234 @@
============================================================
Reading 1 (t=0.01s)
============================================================
Device: HPCS6500
Date: 2026-02-04 Time: 15:58:55
--- Photometric ---
Phi (lm): 479.98
eta (lm/W): 57.09
CCT (K): 5653
Duv: 0.00551
--- Chromaticity ---
x: 0.3289 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.69
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=87, R14=97, R15=76
--- CIE 1931 ---
X: 662.526 Y: 702.748 Z: 649.286
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1492.631
Phi_eb (mW): 470.398
Phi_ey (mW): 680.493
Phi_er (mW): 331.425
Phi_efr (mW): 11.458
--- Electrical ---
Voltage (V): 230.31
Current (A): 0.0653
Power (W): 8.407
Frequency (Hz): 50.02
Power Factor: 0.5591
--- Sensor ---
Peak Signal: 53066
Dark Signal: 2219
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0008 []
389.6nm: 0.0890 []
399.2nm: 0.3229 []
408.8nm: 0.7770 [##]
418.4nm: 1.6247 [####]
428.0nm: 3.0114 [########]
437.6nm: 5.1952 [##############]
447.2nm: 8.5884 [########################]
456.8nm: 12.6235 [####################################]
466.4nm: 13.6578 [#######################################]
476.0nm: 10.8532 [###############################]
485.6nm: 8.3235 [########################]
495.2nm: 6.7865 [###################]
504.8nm: 5.5139 [###############]
514.4nm: 4.7091 [#############]
524.0nm: 4.4881 [############]
533.6nm: 4.6034 [#############]
543.2nm: 4.8833 [##############]
552.8nm: 5.2877 [###############]
562.4nm: 5.6993 [################]
572.0nm: 6.0321 [#################]
581.6nm: 6.2673 [##################]
591.2nm: 6.4150 [##################]
600.8nm: 6.5413 [##################]
610.4nm: 6.6433 [###################]
620.0nm: 6.7819 [###################]
629.6nm: 6.9311 [####################]
639.2nm: 7.0607 [####################]
648.8nm: 7.1652 [####################]
658.4nm: 7.2412 [####################]
668.0nm: 7.3020 [#####################]
677.6nm: 7.3772 [#####################]
687.2nm: 7.5026 [#####################]
696.8nm: 7.5342 [#####################]
706.4nm: 7.4841 [#####################]
716.0nm: 7.3912 [#####################]
725.6nm: 7.2247 [####################]
735.2nm: 7.0265 [####################]
744.8nm: 6.7293 [###################]
754.4nm: 6.3794 [##################]
764.0nm: 5.9717 [#################]
773.6nm: 5.5819 [################]
783.2nm: 5.1382 [##############]
792.8nm: 4.7075 [#############]
802.3nm: 4.2856 [############]
811.9nm: 3.8809 [###########]
821.5nm: 3.4503 [#########]
831.1nm: 3.0323 [########]
840.7nm: 2.6561 [#######]
850.3nm: 2.2916 [######]
859.9nm: 1.9863 [#####]
869.5nm: 1.7127 [####]
879.1nm: 1.4511 [####]
888.7nm: 1.2506 [###]
898.3nm: 1.0459 [###]
907.9nm: 0.8602 [##]
917.5nm: 0.7136 [##]
927.1nm: 0.5768 [#]
936.7nm: 0.4717 [#]
946.3nm: 0.3844 [#]
955.9nm: 0.3112 []
965.5nm: 0.2369 []
975.1nm: 0.1834 []
984.7nm: 0.1431 []
994.3nm: 0.1127 []
1003.9nm: 0.0847 []
1013.5nm: 0.0629 []
1023.1nm: 0.0458 []
1032.7nm: 0.0273 []
1042.3nm: 0.0104 []
============================================================
Reading 2 (t=2.13s)
============================================================
Device: HPCS6500
Date: 2026-02-04 Time: 16:02:06
--- Photometric ---
Phi (lm): 479.89
eta (lm/W): 57.09
CCT (K): 5655
Duv: 0.00550
--- Chromaticity ---
x: 0.3288 y: 0.3488
u: 0.2015 v: 0.3206
u': 0.2015 v': 0.4809
--- CRI ---
Ra: 83.0 SDCM: 4.67
R1=82, R2=94, R3=93, R4=77, R5=82, R6=89, R7=83, R8=64
R9=5, R10=84, R11=76, R12=60, R13=86, R14=97, R15=76
--- CIE 1931 ---
X: 662.427 Y: 702.620 Z: 649.490
TLCI-2012: 68
--- Radiometric ---
Phi_e (mW): 1492.469
Phi_eb (mW): 470.073
Phi_ey (mW): 679.697
Phi_er (mW): 330.648
Phi_efr (mW): 11.237
--- Electrical ---
Voltage (V): 230.30
Current (A): 0.0653
Power (W): 8.406
Frequency (Hz): 50.02
Power Factor: 0.5592
--- Sensor ---
Peak Signal: 53055
Dark Signal: 2246
Compensate: 2834
--- Spectrum (380-1050nm) ---
380.0nm: 0.0003 []
389.6nm: 0.0894 []
399.2nm: 0.3190 []
408.8nm: 0.7735 [##]
418.4nm: 1.6266 [####]
428.0nm: 3.0219 [########]
437.6nm: 5.2066 [###############]
447.2nm: 8.5984 [########################]
456.8nm: 12.6300 [####################################]
466.4nm: 13.6373 [#######################################]
476.0nm: 10.8302 [###############################]
485.6nm: 8.3115 [########################]
495.2nm: 6.7760 [###################]
504.8nm: 5.5051 [###############]
514.4nm: 4.7003 [#############]
524.0nm: 4.4846 [############]
533.6nm: 4.5981 [#############]
543.2nm: 4.8781 [##############]
552.8nm: 5.2836 [###############]
562.4nm: 5.6906 [################]
572.0nm: 6.0287 [#################]
581.6nm: 6.2648 [##################]
591.2nm: 6.4112 [##################]
600.8nm: 6.5259 [##################]
610.4nm: 6.6311 [###################]
620.0nm: 6.7794 [###################]
629.6nm: 6.9210 [###################]
639.2nm: 7.0479 [####################]
648.8nm: 7.1559 [####################]
658.4nm: 7.2319 [####################]
668.0nm: 7.2973 [#####################]
677.6nm: 7.3794 [#####################]
687.2nm: 7.4932 [#####################]
696.8nm: 7.5221 [#####################]
706.4nm: 7.4767 [#####################]
716.0nm: 7.3786 [#####################]
725.6nm: 7.2110 [####################]
735.2nm: 7.0181 [####################]
744.8nm: 6.7234 [###################]
754.4nm: 6.3685 [##################]
764.0nm: 5.9592 [#################]
773.6nm: 5.5700 [################]
783.2nm: 5.1347 [##############]
792.8nm: 4.6961 [#############]
802.3nm: 4.2714 [############]
811.9nm: 3.8696 [###########]
821.5nm: 3.4402 [#########]
831.1nm: 3.0220 [########]
840.7nm: 2.6562 [#######]
850.3nm: 2.2910 [######]
859.9nm: 1.9745 [#####]
869.5nm: 1.7021 [####]
879.1nm: 1.4475 [####]
888.7nm: 1.2388 [###]
898.3nm: 1.0408 [###]
907.9nm: 0.8597 [##]
917.5nm: 0.7104 [##]
927.1nm: 0.5750 [#]
936.7nm: 0.4669 [#]
946.3nm: 0.3804 [#]
955.9nm: 0.3071 []
965.5nm: 0.2300 []
975.1nm: 0.1785 []
984.7nm: 0.1403 []
994.3nm: 0.1103 []
1003.9nm: 0.0812 []
1013.5nm: 0.0610 []
1023.1nm: 0.0417 []
1032.7nm: 0.0264 []
1042.3nm: 0.0066 []
@@ -0,0 +1,2 @@
#1 480.0 lm 5653 K
#2 479.9 lm 5655 K
@@ -0,0 +1,47 @@
============================================================
Reading 1 (t=0.01s)
============================================================
Device: HPCS6500
Date: 2026-02-04 Time: 17:07:33
--- Photometric ---
Phi (lm): 0.00
eta (lm/W): 0.00
CCT (K): 0
Duv: 0.00000
--- Chromaticity ---
x: 0.0000 y: 0.0000
u: 0.0000 v: 0.0000
u': 0.0000 v': 0.0000
--- CRI ---
Ra: 0.0 SDCM: 0.00
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
--- CIE 1931 ---
X: 0.000 Y: 0.000 Z: 0.000
TLCI-2012: 0
--- Radiometric ---
Phi_e (mW): 0.000
Phi_eb (mW): 0.000
Phi_ey (mW): 0.000
Phi_er (mW): 0.000
Phi_efr (mW): 0.000
--- Electrical ---
Voltage (V): 0.00
Current (A): 0.0000
Power (W): 0.000
Frequency (Hz): 60.04
Power Factor: 0.0000
UThd (%): 0.8692
AThd (%): 116.7091
--- Sensor ---
Peak Signal: 3801
Dark Signal: 3670
Compensate: 2836
@@ -0,0 +1,223 @@
============================================================
Reading 1 (t=0.01s)
============================================================
Device: HPCS6500
Date: 2026-02-04 Time: 17:07:33
--- Photometric ---
Phi (lm): 0.00
eta (lm/W): 0.00
CCT (K): 0
Duv: 0.00000
--- Chromaticity ---
x: 0.0000 y: 0.0000
u: 0.0000 v: 0.0000
u': 0.0000 v': 0.0000
--- CRI ---
Ra: 0.0 SDCM: 0.00
R1=0, R2=0, R3=0, R4=0, R5=0, R6=0, R7=0, R8=0
R9=0, R10=0, R11=0, R12=0, R13=0, R14=0, R15=0
--- CIE 1931 ---
X: 0.000 Y: 0.000 Z: 0.000
TLCI-2012: 0
--- Radiometric ---
Phi_e (mW): 0.000
Phi_eb (mW): 0.000
Phi_ey (mW): 0.000
Phi_er (mW): 0.000
Phi_efr (mW): 0.000
--- Electrical ---
Voltage (V): 0.00
Current (A): 0.0000
Power (W): 0.000
Frequency (Hz): 60.04
Power Factor: 0.0000
UThd (%): 0.8692
AThd (%): 116.7091
--- Voltage Harmonics (%) ---
H 1: 100.0000
H 2: 0.3466
H 3: 0.5529
H 4: 0.1935
H 5: 0.1009
H 6: 0.1319
H 7: 0.1455
H 8: 0.0709
H 9: 0.3610
H10: 0.0452
H11: 0.2558
H12: 0.0099
H13: 0.1471
H14: 0.0182
H15: 0.0881
H16: 0.0119
H17: 0.0439
H18: 0.0054
H19: 0.0441
H20: 0.0039
H21: 0.0444
H22: 0.0038
H23: 0.0419
H24: 0.0070
H25: 0.0277
H26: 0.0086
H27: 0.0180
H28: 0.0038
H29: 0.0168
H30: 0.0031
H31: 0.0085
H32: 0.0042
H33: 0.0141
H34: 0.0039
H35: 0.0050
H36: 0.0076
H37: 0.0172
H38: 0.0017
H39: 0.0135
H40: 0.0122
H41: 0.0075
H42: 0.0091
H43: 0.0079
H44: 0.0052
H45: 0.0028
H46: 0.0063
H47: 0.0106
H48: 0.0055
H49: 0.0066
H50: 0.0032
--- Current Harmonics (%) ---
H 1: 100.0000
H 2: 1.3069
H 3: 79.6826
H 4: 1.6782
H 5: 62.0099
H 6: 1.8375
H 7: 43.0251
H 8: 1.4561
H 9: 27.1434
H10: 0.8638
H11: 16.3288
H12: 0.8914
H13: 10.5836
H14: 0.8186
H15: 7.8296
H16: 0.6092
H17: 7.1998
H18: 0.5320
H19: 6.8390
H20: 0.7901
H21: 5.9471
H22: 0.8641
H23: 5.4131
H24: 0.7893
H25: 5.4738
H26: 0.6972
H27: 5.4525
H28: 0.6923
H29: 4.8222
H30: 0.7761
H31: 4.3909
H32: 0.7792
H33: 4.3903
H34: 0.7458
H35: 4.3602
H36: 0.7061
H37: 3.9436
H38: 0.6865
H39: 3.6134
H40: 0.6928
H41: 3.4965
H42: 0.7969
H43: 3.1709
H44: 0.7282
H45: 2.8721
H46: 0.6770
H47: 2.6774
H48: 0.6486
H49: 2.6343
H50: 0.6855
--- Sensor ---
Peak Signal: 3801
Dark Signal: 3670
Compensate: 2836
--- Spectrum (380-1050nm) ---
380.0nm: 0.0000 []
389.6nm: 0.0000 []
399.2nm: 0.0000 []
408.8nm: 0.0000 []
418.4nm: 0.0000 []
428.0nm: 0.0000 []
437.6nm: 0.0000 []
447.2nm: 0.0000 []
456.8nm: 0.0000 []
466.4nm: 0.0000 []
476.0nm: 0.0000 []
485.6nm: 0.0000 []
495.2nm: 0.0000 []
504.8nm: 0.0000 []
514.4nm: 0.0000 []
524.0nm: 0.0000 []
533.6nm: 0.0000 []
543.2nm: 0.0000 []
552.8nm: 0.0000 []
562.4nm: 0.0000 []
572.0nm: 0.0000 []
581.6nm: 0.0000 []
591.2nm: 0.0000 []
600.8nm: 0.0000 []
610.4nm: 0.0000 []
620.0nm: 0.0000 []
629.6nm: 0.0000 []
639.2nm: 0.0000 []
648.8nm: 0.0000 []
658.4nm: 0.0000 []
668.0nm: 0.0000 []
677.6nm: 0.0000 []
687.2nm: 0.0000 []
696.8nm: 0.0000 []
706.4nm: 0.0000 []
716.0nm: 0.0000 []
725.6nm: 0.0000 []
735.2nm: 0.0000 []
744.8nm: 0.0000 []
754.4nm: 0.0000 []
764.0nm: 0.0000 []
773.6nm: 0.0000 []
783.2nm: 0.0000 []
792.8nm: 0.0000 []
802.3nm: 0.0000 []
811.9nm: 0.0000 []
821.5nm: 0.0000 []
831.1nm: 0.0000 []
840.7nm: 0.0000 []
850.3nm: 0.0000 []
859.9nm: 0.0000 []
869.5nm: 0.0000 []
879.1nm: 0.0000 []
888.7nm: 0.0000 []
898.3nm: 0.0000 []
907.9nm: 0.0000 []
917.5nm: 0.0000 []
927.1nm: 0.0000 []
936.7nm: 0.0000 []
946.3nm: 0.0000 []
955.9nm: 0.0000 []
965.5nm: 0.0000 []
975.1nm: 0.0000 []
984.7nm: 0.0000 []
994.3nm: 0.0000 []
1003.9nm: 0.0000 []
1013.5nm: 0.0000 []
1023.1nm: 0.0000 []
1032.7nm: 0.0000 []
1042.3nm: 0.0000 []
@@ -0,0 +1 @@
#1 0.0 lm 0 K
+1
View File
@@ -0,0 +1 @@
[{"bits":0,"repr":"0.0","g6":"0","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":9223372036854775808,"repr":"-0.0","g6":"-0","f2":"-0.00","f0":"-0","f1":"-0.0","round1":"-0.0"},{"bits":4607182418800017408,"repr":"1.0","g6":"1","f2":"1.00","f0":"1","f1":"1.0","round1":"1.0"},{"bits":13830554455654793216,"repr":"-1.0","g6":"-1","f2":"-1.00","f0":"-1","f1":"-1.0","round1":"-1.0"},{"bits":4591870180066957722,"repr":"0.1","g6":"0.1","f2":"0.10","f0":"0","f1":"0.1","round1":"0.1"},{"bits":4602678819172646912,"repr":"0.5","g6":"0.5","f2":"0.50","f0":"0","f1":"0.5","round1":"0.5"},{"bits":4609434218613702656,"repr":"1.5","g6":"1.5","f2":"1.50","f0":"2","f1":"1.5","round1":"1.5"},{"bits":4612811918334230528,"repr":"2.5","g6":"2.5","f2":"2.50","f0":"2","f1":"2.5","round1":"2.5"},{"bits":4593671619917905920,"repr":"0.125","g6":"0.125","f2":"0.12","f0":"0","f1":"0.1","round1":"0.1"},{"bits":4600427019358961664,"repr":"0.375","g6":"0.375","f2":"0.38","f0":"0","f1":"0.4","round1":"0.4"},{"bits":4532020583610935537,"repr":"1e-05","g6":"1e-05","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4547007122018943789,"repr":"0.0001","g6":"0.0001","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4547007085125455641,"repr":"9.99995e-05","g6":"9.99995e-05","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4683220279290232832,"repr":"123456.5","g6":"123456","f2":"123456.50","f0":"123456","f1":"123456.5","round1":"123456.5"},{"bits":4696837142389719040,"repr":"999999.5","g6":"1e+06","f2":"999999.50","f0":"1000000","f1":"999999.5","round1":"999999.5"},{"bits":4831355200913801216,"repr":"1000000000000000.0","g6":"1e+15","f2":"1000000000000000.00","f0":"1000000000000000","f1":"1000000000000000.0","round1":"1000000000000000.0"},{"bits":4846369599423283200,"repr":"1e+16","g6":"1e+16","f2":"10000000000000000.00","f0":"10000000000000000","f1":"10000000000000000.0","round1":"1e+16"},{"bits":4847542438873900484,"repr":"1.2345678901234568e+16","g6":"1.23457e+16","f2":"12345678901234568.00","f0":"12345678901234568","f1":"12345678901234568.0","round1":"1.2345678901234568e+16"},{"bits":4936209963552724370,"repr":"1e+22","g6":"1e+22","f2":"10000000000000000000000.00","f0":"10000000000000000000000","f1":"10000000000000000000000.0","round1":"1e+22"},{"bits":4502148214488346440,"repr":"1e-07","g6":"1e-07","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":1,"repr":"5e-324","g6":"4.94066e-324","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":9218868437227405311,"repr":"1.7976931348623157e+308","g6":"1.79769e+308","f2":null,"f0":null,"f1":null,"round1":"1.7976931348623157e+308"},{"bits":4636737291354636288,"repr":"100.0","g6":"100","f2":"100.00","f0":"100","f1":"100.0","round1":"100.0"},{"bits":4645392646888488960,"repr":"380.0","g6":"380","f2":"380.00","f0":"380","f1":"380.0","round1":"380.0"},{"bits":9221120237041090560,"repr":"nan","g6":"nan","f2":"nan","f0":"nan","f1":"nan","round1":null},{"bits":9218868437227405312,"repr":"inf","g6":"inf","f2":"inf","f0":"inf","f1":"inf","round1":null},{"bits":18442240474082181120,"repr":"-inf","g6":"-inf","f2":"-inf","f0":"-inf","f1":"-inf","round1":null},{"bits":4548686775598520385,"repr":"0.000123456789","g6":"0.000123457","f2":"0.00","f0":"0","f1":"0.0","round1":"0.0"},{"bits":4712889646492470477,"repr":"12345678.9","g6":"1.23457e+07","f2":"12345678.90","f0":"12345679","f1":"12345678.9","round1":"12345678.9"},{"bits":4607182409792818153,"repr":"0.999999","g6":"0.999999","f2":"1.00","f0":"1","f1":"1.0","round1":"1.0"},{"bits":4607182414296417781,"repr":"0.9999995","g6":"1","f2":"1.00","f0":"1","f1":"1.0","round1":"1.0"},{"bits":4621819117307496543,"repr":"9.9999995","g6":"10","f2":"10.00","f0":"10","f1":"10.0","round1":"10.0"},{"bits":4681608357448201011,"repr":"99999.95","g6":"99999.9","f2":"99999.95","f0":"100000","f1":"99999.9","round1":"99999.9"},{"bits":4698053236609777664,"repr":"1234567.0","g6":"1.23457e+06","f2":"1234567.00","f0":"1234567","f1":"1234567.0","round1":"1234567.0"},{"bits":4599075939470750516,"repr":"0.30000000000000004","g6":"0.3","f2":"0.30","f0":"0","f1":"0.3","round1":"0.3"},{"bits":4645392646888488960,"repr":"380.0","g6":"380","f2":"380.00","f0":"380","f1":"380.0","round1":"380.0"},{"bits":4645426419853101449,"repr":"381.91977077363896","g6":"381.92","f2":"381.92","f0":"382","f1":"381.9","round1":"381.9"},{"bits":4645460192817713938,"repr":"383.8395415472779","g6":"383.84","f2":"383.84","f0":"384","f1":"383.8","round1":"383.8"},{"bits":4645493965782326428,"repr":"385.75931232091693","g6":"385.759","f2":"385.76","f0":"386","f1":"385.8","round1":"385.8"},{"bits":4645527738746938917,"repr":"387.6790830945559","g6":"387.679","f2":"387.68","f0":"388","f1":"387.7","round1":"387.7"},{"bits":4645561511711551406,"repr":"389.59885386819485","g6":"389.599","f2":"389.60","f0":"390","f1":"389.6","round1":"389.6"},{"bits":4645595284676163895,"repr":"391.5186246418338","g6":"391.519","f2":"391.52","f0":"392","f1":"391.5","round1":"391.5"},{"bits":4645629057640Line truncated
+2
View File
@@ -0,0 +1,2 @@
#1 483.7 lm 5635 K
483.7 lm 5635 K
+2
View File
@@ -0,0 +1,2 @@
#2 479.6 lm 5661 K
479.6 lm 5661 K
+2
View File
@@ -0,0 +1,2 @@
#3 0.0 lm 0 K
0.0 lm 0 K
+2
View File
@@ -0,0 +1,2 @@
#4 479.8 lm 5656 K
479.8 lm 5656 K
+2
View File
@@ -0,0 +1,2 @@
#5 479.6 lm 5655 K
479.6 lm 5655 K
+2
View File
@@ -0,0 +1,2 @@
#6 479.6 lm 5654 K
479.6 lm 5654 K
+2
View File
@@ -0,0 +1,2 @@
#7 182.6 lm 4937 K
182.6 lm 4937 K
File diff suppressed because it is too large. Load diff
+143
View File
@@ -0,0 +1,143 @@
//! The driver against the simulated instrument: command bytes, measurement
//! sequences, supply handling, timeouts and cancellation.
use hpcs6500::protocol::{self, cmd};
use hpcs6500::sim::Simulator;
use hpcs6500::{Error, Hpcs6500, Mode};
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
use std::time::{Duration, Instant};
fn dev() -> Hpcs6500<Simulator> {
Hpcs6500::with_transport(Simulator::sample(), "sim")
}
fn cmds(d: &Hpcs6500<Simulator>) -> Vec<u8> {
d.transport().log.iter().map(|f| f[1]).collect()
}
#[test]
fn identify_and_supply_commands() {
let mut d = dev();
assert_eq!(d.identify().unwrap(), "HPCS6500");
d.set_mode(Mode::Dc).unwrap();
d.set_dc_voltage(12.0).unwrap();
d.set_dc_current(1.5).unwrap();
d.set_ac_voltage(230.0).unwrap();
d.set_ac_frequency(60.0).unwrap();
d.set_integration_time(500.0).unwrap();
let s = d.read_psu_settings().unwrap();
assert_eq!(
(
s.mode,
s.dc_voltage,
s.dc_current,
s.ac_voltage,
s.ac_frequency
),
(Mode::Dc, 12.0, 1.5, 230.0, 60.0)
);
let log = &d.transport().log;
assert_eq!(log[1], vec![0x8C, 0x7A, 0x01]);
assert_eq!(log[2], protocol::frame_f32(cmd::SET_DC, 1, 12.0));
assert_eq!(log[3], protocol::frame_f32(cmd::SET_DC, 0, 1.5));
assert_eq!(log[6], vec![0x8C, 0x01, 0x20, 0xA1, 0x07, 0x00]); // 500 000 us
d.psu_on().unwrap();
assert!(d.transport().output_on);
d.psu_off().unwrap();
assert!(!d.transport().output_on);
}
#[test]
fn single_reading_follows_the_vendor_sequence() {
let mut d = dev();
d.send_test_config(false).unwrap();
let r = d.take_single_reading().unwrap();
assert_eq!(r.device, "HPCS6500");
assert!((r.flux_lm() - 483.7375).abs() < 1e-3);
assert!(r.electrical.is_some());
// 2B 2B, trigger (0E 02), polls, 13, 77, reset
let c = cmds(&d);
assert_eq!(&c[..3], &[cmd::TEST_CONFIG, cmd::TEST_CONFIG, cmd::START]);
assert_eq!(d.transport().log[2], vec![0x8C, 0x0E, 0x02]);
assert!(c[3..c.len() - 3].iter().all(|&x| x == cmd::POLL_STATE));
assert_eq!(
&c[c.len() - 3..],
&[cmd::READ_MEASUREMENT, cmd::READ_ELECTRICAL, cmd::RESET]
);
assert!(!d.transport().output_on, "the supply is not touched");
}
#[test]
fn continuous_reading_switches_the_supply_around_it() {
let mut d = dev();
let r = d.take_reading(true).unwrap();
assert_eq!(r.spectrum.len(), 350);
let c = cmds(&d);
assert_eq!(c[0], cmd::PSU_OUTPUT);
assert_eq!(d.transport().log[0], vec![0x8C, 0x72, 0x00]);
assert_eq!(d.transport().log[1], vec![0x8C, 0x0E, 0x01]);
assert_eq!(
&c[c.len() - 4..],
&[
cmd::READ_MEASUREMENT,
cmd::READ_ELECTRICAL,
cmd::START,
cmd::PSU_OUTPUT
]
);
assert_eq!(d.transport().log.last().unwrap(), &vec![0x8C, 0x72, 0x01]);
assert!(!d.transport().output_on);
}
#[test]
fn silent_instrument_times_out_and_the_supply_still_goes_off() {
let mut d = dev();
d.transport_mut().mute = vec![cmd::POLL_STATE];
let mut sim = Simulator::sample();
sim.mute = vec![cmd::IDENTIFY];
let mut quiet = Hpcs6500::with_transport(sim, "sim");
let t = Instant::now();
assert!(matches!(
quiet.identify(),
Err(Error::BadResponse { cmd: 0, .. })
));
assert!(t.elapsed() >= Duration::from_secs(2) && t.elapsed() < Duration::from_secs(4));
// a reading whose data never arrives: cancel it instead of waiting 30 s
let stop = Arc::new(AtomicBool::new(false));
d.set_cancel_flag(stop.clone());
let s2 = stop.clone();
std::thread::spawn(move || {
std::thread::sleep(Duration::from_millis(300));
s2.store(true, Ordering::SeqCst);
});
let t = Instant::now();
let r = d.take_reading(true);
assert!(matches!(r, Err(Error::Cancelled)), "{r:?}");
assert!(t.elapsed() < Duration::from_secs(8));
assert!(
!d.transport().output_on,
"supply off after the failed reading"
);
assert_eq!(d.transport().log.last().unwrap(), &vec![0x8C, 0x72, 0x01]);
}
#[test]
fn missing_ack_is_an_error() {
let mut sim = Simulator::sample();
sim.mute = vec![cmd::PSU_OUTPUT];
let mut d = Hpcs6500::with_transport(sim, "sim");
assert!(matches!(d.psu_on(), Err(Error::NoAck { cmd: 0x72, .. })));
}
#[test]
fn passive_read_and_replay_cycle_through_readings() {
let mut sim = Simulator::sample();
sim.polls_to_ready = 0;
let mut d = Hpcs6500::with_transport(sim, "sim");
let a = d.read_current().unwrap();
let b = d.take_single_reading().unwrap();
assert_eq!(a, b);
assert_eq!(d.transport().served, 1);
}
-125
View File
@@ -1,125 +0,0 @@
"""
ANSI/IES TM-30-18 computation from an HPCS 6500 spectrum.
Wraps colour-science to derive the fidelity index Rf, gamut index Rg, and the
per-hue-bin data needed to draw a TM-30 color vector graphic (CVG): the test
and reference chromaticity averages in CAM02-UCS a'b' (raw and normalized so
the reference gamut is the unit circle), plus per-bin chroma shift, hue shift,
and local fidelity.
The instrument spectrum is relative; TM-30 is scale-invariant, so no absolute
calibration is required. Only the 380-780 nm range is used.
"""
import warnings
import numpy as np
try:
# colour warns at import time about optional scipy/matplotlib features;
# the TM-30 path used here needs neither.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
import colour
from colour.quality import colour_fidelity_index_ANSIIESTM3018
except ImportError as e: # pragma: no cover
raise ImportError(
"TM-30 computation requires colour-science; run `uv sync`"
) from e
VISIBLE_MIN_NM = 380
VISIBLE_MAX_NM = 780
N_BINS = 16
def compute_tm30(wavelengths_nm, values):
"""Compute TM-30-18 quantities from a measured spectrum.
Args:
wavelengths_nm: sequence of wavelengths in nm (any step; 380-1050
from the HPCS 6500 is fine, the IR tail is discarded).
values: spectral power at each wavelength (relative units).
Returns dict:
Rf, Rg: floats
CCT_K, Duv: floats (as derived by the TM-30 reference selection)
bins: list of 16 dicts, hue bin 1..16, each with
ref_a, ref_b, test_a, test_b raw CAM02-UCS bin averages
ref_a_norm ... test_b_norm normalized (reference = unit circle)
Rcs_pct chroma shift, percent
Rhs hue shift (rad, CAM02-UCS)
Rf_h local fidelity for the bin
"""
wl = np.asarray(wavelengths_nm, dtype=float)
vals = np.asarray(values, dtype=float)
mask = (wl >= VISIBLE_MIN_NM) & (wl <= VISIBLE_MAX_NM)
if mask.sum() < 10:
raise ValueError("spectrum does not cover the visible range")
# Resample onto a uniform 1 nm grid: the instrument's ~1.92 nm grid is
# non-uniform in colour's eyes and triggers interpolator paths that need
# scipy; a uniform grid keeps the pipeline on plain numpy.
grid = np.arange(VISIBLE_MIN_NM, VISIBLE_MAX_NM + 1, 1, dtype=float)
resampled = np.interp(grid, wl[mask], vals[mask])
sd = colour.SpectralDistribution(dict(zip(grid, resampled)))
# colour emits informational "aligning shape" runtime warnings while it
# adapts observers/illuminants to our 1 nm grid; not actionable.
with warnings.catch_warnings():
warnings.simplefilter("ignore")
spec = colour_fidelity_index_ANSIIESTM3018(sd, additional_data=True)
averages_test = np.asarray(spec.averages_test)
averages_reference = np.asarray(spec.averages_reference)
average_norms = np.asarray(spec.average_norms)
R_cs = np.asarray(spec.R_cs)
R_hs = np.asarray(spec.R_hs)
# Local fidelity per hue bin: mean of the per-CES R_f over the samples
# assigned to the bin (spec.bins holds the bin index of each CES).
sample_bins = np.asarray(spec.bins)
R_s = np.asarray(spec.R_s)
Rf_h = np.full(N_BINS, np.nan)
for j in range(N_BINS):
members = R_s[sample_bins == j]
if members.size:
Rf_h[j] = members.mean()
bins = []
for j in range(N_BINS):
norm = average_norms[j] if average_norms[j] else 1.0
bins.append(
{
"bin": j + 1,
"ref_a": float(averages_reference[j, 0]),
"ref_b": float(averages_reference[j, 1]),
"test_a": float(averages_test[j, 0]),
"test_b": float(averages_test[j, 1]),
"ref_a_norm": float(averages_reference[j, 0] / norm),
"ref_b_norm": float(averages_reference[j, 1] / norm),
"test_a_norm": float(averages_test[j, 0] / norm),
"test_b_norm": float(averages_test[j, 1] / norm),
"Rcs_pct": float(R_cs[j]),
"Rhs": float(R_hs[j]),
"Rf_h": float(Rf_h[j]),
}
)
return {
"Rf": float(spec.R_f),
"Rg": float(spec.R_g),
"CCT_K": float(spec.CCT),
"Duv": float(spec.D_uv),
"bins": bins,
}
if __name__ == "__main__":
# Sanity check against a known illuminant.
sd = colour.SDS_ILLUMINANTS["FL2"]
wl = sd.wavelengths
result = compute_tm30(wl, sd.values)
print(f"FL2: Rf={result['Rf']:.1f} Rg={result['Rg']:.1f} "
f"CCT={result['CCT_K']:.0f} K Duv={result['Duv']:.4f}")
for b in result["bins"][:4]:
print(b)
+142
View File
@@ -0,0 +1,142 @@
"""Golden fixtures for the Rust hpcs6500 driver, produced by the legacy Python driver.
Run it next to hpcs6500.py from tag python-final, with the USB captures in
C:\dev\HPCS\captures (not in git):
git show python-final:hpcs6500.py > /tmp/py/hpcs6500.py; cp tools/make_fixtures.py /tmp/py/
python /tmp/py/make_fixtures.py <out> # then copy <out>/fixtures and <out>/golden to tests/
pyfmt.json was trimmed to ~2000 cases afterwards.
Writes into <out>/:
fixtures/rNN_meas.bin, rNN_elec.bin raw blocks from real USB captures
golden/readings.json Python dict per fixture reading (floats as repr strings)
golden/format_*.txt format_reading / format_quick output
golden/csv_row.txt CSV row (without timestamp) as DictWriter writes it
golden/pyfmt.json repr / .6g / .2f / .0f / .1f of many floats
golden/parse_<capture>.txt `hpcs6500.py --parse` output, full and --quick --spectrum
"""
import contextlib
import csv
import io
import json
import math
import random
import struct
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent))
import hpcs6500 as h # noqa: E402
out = Path(sys.argv[1])
captures = Path(r"C:\dev\HPCS\captures")
(out / "fixtures").mkdir(parents=True, exist_ok=True)
(out / "golden").mkdir(parents=True, exist_ok=True)
pairs = [] # (name, meas, elec)
for f in sorted(captures.glob("*.pcap")):
if f.stat().st_size < 100:
continue
m = h.parse_pcap_messages(str(f))
b = [x for x in m if x["dir"] == "RX" and len(x["data"]) == 3904 and x["data"][:8] == b"HPCS6500"]
e = [x for x in m if x["dir"] == "RX" and len(x["data"]) == 1584]
for i, blk in enumerate(b):
el = e[i]["data"] if i < len(e) else None
harm = el is not None and abs(struct.unpack_from("<f", el, 544)[0] - 100) < 1
pairs.append((f.stem, i, blk["data"], el, harm))
print(len(pairs), "readings,", sum(p[4] for p in pairs), "with harmonics")
# pick: first, one with harmonics, one with zero electrical, and a few spread out
chosen = []
seen = set()
def pick(p):
key = (p[0], p[1])
if key not in seen:
seen.add(key)
chosen.append(p)
pick(pairs[0])
for p in pairs:
if p[4]:
pick(p)
break
for p in pairs:
if p[3] is not None and struct.unpack_from("<f", p[3], 8)[0] == 0:
pick(p)
break
for k in range(0, len(pairs), max(1, len(pairs) // 4)):
pick(pairs[k])
pick(pairs[-1])
dev = h.HPCS6500.__new__(h.HPCS6500)
def jsonable(v):
if isinstance(v, float):
return {"f": repr(v)}
if isinstance(v, list):
return [jsonable(x) for x in v]
return v
readings = []
for n, (cap, i, meas, elec, harm) in enumerate(chosen, 1):
name = f"r{n:02d}"
(out / "fixtures" / f"{name}_meas.bin").write_bytes(meas)
if elec is not None:
(out / "fixtures" / f"{name}_elec.bin").write_bytes(elec)
r = dev._parse_measurement(meas)
if elec is not None:
r.update(dev._parse_electrical(elec))
readings.append({"name": name, "source": f"{cap}#{i}", "harmonics": harm,
"keys": list(r.keys()), "values": {k: jsonable(v) for k, v in r.items()}})
(out / "golden" / f"format_{name}.txt").write_text(h.format_reading(r), encoding="utf-8", newline="\n")
(out / "golden" / f"format_{name}_spectrum.txt").write_text(h.format_reading(r, show_spectrum=True), encoding="utf-8", newline="\n")
(out / "golden" / f"quick_{name}.txt").write_text(h.format_quick(r, n) + "\n" + h.format_quick(r), encoding="utf-8", newline="\n")
buf = io.StringIO()
fieldnames = ["timestamp"] + list(h.FIELDS.keys()) + list(h.ELECTRICAL_FIELDS.keys())
w = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
w.writeheader()
row = {"timestamp": "T"}
row.update({k: v for k, v in r.items() if isinstance(v, (int, float))})
w.writerow(row)
(out / "golden" / f"csv_{name}.txt").write_bytes(buf.getvalue().encode())
(out / "golden" / "readings.json").write_text(json.dumps(readings, indent=1))
print("chosen:", [(c[0], c[1], c[4]) for c in chosen])
# --parse output for two real captures
for cap in ["usb_capture_20260204_123655_hub1", "usb_capture_20260204_134301_hub1"]:
for flags, suffix in (([], ""), (["--quick"], "_quick"), (["--harmonics"], "_harmonics")):
sys.argv = ["hpcs6500.py", *flags, "--parse", str(captures / f"{cap}.pcap")]
buf = io.StringIO()
with contextlib.redirect_stdout(buf):
h.main()
(out / "golden" / f"parse_{cap}{suffix}.txt").write_text(buf.getvalue(), encoding="utf-8", newline="\n")
# float formatting golden
rng = random.Random(6500)
vals = [0.0, -0.0, 1.0, -1.0, 0.1, 0.5, 1.5, 2.5, 0.125, 0.375, 1e-5, 1e-4, 9.99995e-5, 123456.5, 999999.5,
1e15, 1e16, 1.2345678901234567e16, 1e22, 1e-7, 5e-324, 1.7976931348623157e308, 100.0, 380.0,
float("nan"), float("inf"), -float("inf"), 0.000123456789, 12345678.9, 0.999999, 0.9999995,
9.9999995, 99999.95, 1234567.0, 0.1 + 0.2]
vals += [380 + i * h.SPECTRUM_STEP for i in range(350)]
for meas_vals in readings:
for k, v in meas_vals["values"].items():
if isinstance(v, dict):
vals.append(float(v["f"]))
elif isinstance(v, list) and v and isinstance(v[0], dict):
vals.extend(float(x["f"]) for x in v[:60])
for _ in range(3000):
e = rng.uniform(-12, 18)
x = rng.choice([-1, 1]) * 10 ** e
vals.append(x)
vals.append(struct.unpack("<f", struct.pack("<f", x if abs(x) < 3e38 else 1.0))[0])
for _ in range(500):
vals.append(rng.randint(0, 10**7) / rng.choice([2, 4, 8, 10, 100, 1000, 16, 1024]))
rows = []
for v in vals:
rows.append({"bits": struct.unpack("<Q", struct.pack("<d", v))[0], "repr": repr(v), "g6": format(v, ".6g"),
"f2": format(v, ".2f") if abs(v) < 1e30 or math.isnan(v) or math.isinf(v) else None,
"f0": format(v, ".0f") if abs(v) < 1e30 or math.isnan(v) or math.isinf(v) else None,
"f1": format(v, ".1f") if abs(v) < 1e30 or math.isnan(v) or math.isinf(v) else None,
"round1": repr(round(v, 1)) if not (math.isnan(v) or math.isinf(v)) else None})
(out / "golden" / "pyfmt.json").write_text(json.dumps(rows))
print(len(rows), "float cases")
-341
View File
@@ -1,341 +0,0 @@
"""
HPCS6500 USB Traffic Capture & Parser
1. Starts USBPcap to capture USB traffic from the HPCS6500 device
2. You use the vendor software normally while this runs
3. Press Ctrl+C to stop
4. Parses the .pcap file and extracts serial data (USB bulk transfers)
"""
import subprocess
import struct
import sys
import os
import time
from datetime import datetime
USBPCAP_CMD = r"C:\Program Files\USBPcap\USBPcapCMD.exe"
CAPTURE_DIR = "captures"
DEVICE_ADDRESS = 4 # From registry: Address = 4
def find_usbpcap_devices():
"""Return all USBPcap devices (USBPcap1 through USBPcap10)."""
devices = []
for i in range(1, 11):
device = rf"\\.\USBPcap{i}"
# Check if the device path exists by trying a quick capture to NUL
try:
result = subprocess.run(
[USBPCAP_CMD, "-d", device, "-o", "-", "-A"],
capture_output=True, timeout=2,
)
devices.append(device)
print(f" {device} - available")
except subprocess.TimeoutExpired:
devices.append(device)
print(f" {device} - available (active)")
except Exception:
pass
return devices
def start_capture(device, output_file):
"""Start USBPcap capture in background."""
cmd = [
USBPCAP_CMD,
"-d", device,
"-o", output_file,
"-A", # Capture from all devices on this hub (safe, we filter later)
"--devices", str(DEVICE_ADDRESS), # Only our device
"-s", "65535", # Max snapshot length
"-b", "1048576", # 1MB buffer
]
print(f"Starting capture: {' '.join(cmd)}")
proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
return proc
def parse_pcap(filepath):
"""Parse a pcap file with USBPcap headers and extract serial data."""
with open(filepath, "rb") as f:
data = f.read()
if len(data) < 24:
print("Capture file too small or empty.")
return
# Parse pcap global header
magic = struct.unpack_from("<I", data, 0)[0]
if magic == 0xA1B2C3D4:
endian = "<"
elif magic == 0xD4C3B2A1:
endian = ">"
else:
print(f"Unknown pcap magic: {hex(magic)}")
return
ver_major, ver_minor = struct.unpack_from(f"{endian}HH", data, 4)
snaplen = struct.unpack_from(f"{endian}I", data, 16)[0]
link_type = struct.unpack_from(f"{endian}I", data, 20)[0]
print(f"PCAP: v{ver_major}.{ver_minor}, snaplen={snaplen}, link_type={link_type}")
# link_type 249 = USBPcap
if link_type != 249:
print(f"Warning: expected link_type 249 (USBPcap), got {link_type}")
offset = 24 # Start of first packet
packets = []
tx_data = bytearray() # Host -> Device (OUT)
rx_data = bytearray() # Device -> Host (IN)
pkt_num = 0
while offset + 16 <= len(data):
# pcap packet header: ts_sec(4), ts_usec(4), incl_len(4), orig_len(4)
ts_sec, ts_usec, incl_len, orig_len = struct.unpack_from(f"{endian}IIII", data, offset)
offset += 16
if offset + incl_len > len(data):
break
pkt_data = data[offset:offset + incl_len]
offset += incl_len
pkt_num += 1
# Parse USBPcap packet header (minimum 27 bytes)
if len(pkt_data) < 27:
continue
# USBPcap header:
# headerLen (2 bytes LE)
# irpId (8 bytes)
# status (4 bytes)
# function (2 bytes) - URB function
# info (1 byte) - direction: bit 0: 0=OUT(host->dev), 1=IN(dev->host)
# bus (2 bytes)
# device (2 bytes)
# endpoint (1 byte) - bit 7 = direction, bits 0-3 = endpoint number
# transfer (1 byte) - 0=isoch, 1=interrupt, 2=control, 3=bulk
# dataLength (4 bytes)
hdr_len = struct.unpack_from("<H", pkt_data, 0)[0]
irp_id = struct.unpack_from("<Q", pkt_data, 2)[0]
status = struct.unpack_from("<I", pkt_data, 10)[0]
function = struct.unpack_from("<H", pkt_data, 14)[0]
info = pkt_data[16]
bus = struct.unpack_from("<H", pkt_data, 17)[0]
device = struct.unpack_from("<H", pkt_data, 19)[0]
endpoint = pkt_data[21]
transfer = pkt_data[22]
data_length = struct.unpack_from("<I", pkt_data, 23)[0]
# Payload starts after the USBPcap header
payload = pkt_data[hdr_len:]
if len(payload) == 0:
continue
# We care about bulk transfers (transfer type 3) with actual data
transfer_types = {0: "ISOCH", 1: "INT", 2: "CTRL", 3: "BULK"}
transfer_name = transfer_types.get(transfer, f"UNK({transfer})")
direction = "IN" if (info & 1) else "OUT"
ep_num = endpoint & 0x0F
ep_dir = "IN" if (endpoint & 0x80) else "OUT"
ts = ts_sec + ts_usec / 1_000_000
# Log all packets with data
if transfer == 3 and len(payload) > 0: # Bulk transfers
packets.append({
"num": pkt_num,
"ts": ts,
"direction": direction,
"ep": endpoint,
"ep_dir": ep_dir,
"transfer": transfer_name,
"data": payload,
"status": status,
"device": device,
})
if direction == "IN" or ep_dir == "IN":
rx_data.extend(payload)
else:
tx_data.extend(payload)
print(f"\nParsed {pkt_num} total packets, {len(packets)} bulk transfers with data")
print(f"TX (host -> device): {len(tx_data)} bytes")
print(f"RX (device -> host): {len(rx_data)} bytes")
if not packets:
print("\nNo bulk transfer data found.")
return
# Print timeline
print(f"\n{'='*80}")
print("TRAFFIC TIMELINE")
print(f"{'='*80}")
first_ts = packets[0]["ts"] if packets else 0
for pkt in packets[:100]: # First 100 packets
rel_ts = pkt["ts"] - first_ts
d = pkt["direction"]
payload = pkt["data"]
if d == "IN" or pkt["ep_dir"] == "IN":
color = "\033[92m" # green for device -> host
label = "DEV->PC"
else:
color = "\033[93m" # yellow for host -> device
label = "PC->DEV"
reset = "\033[0m"
hex_str = payload.hex(" ")
if len(hex_str) > 90:
hex_str = hex_str[:90] + f" ... (+{len(payload) - 30}B)"
ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in payload[:40])
print(f"{color}[{rel_ts:8.4f}] {label} EP{pkt['ep']:02X} ({len(payload):4d}B): {hex_str}{reset}")
# Show ASCII if useful
printable = sum(1 for b in payload if 32 <= b < 127)
if printable > len(payload) * 0.4:
print(f" ASCII: {ascii_str!r}")
if len(packets) > 100:
print(f" ... ({len(packets) - 100} more packets)")
# Save extracted serial data
if tx_data:
tx_file = filepath.replace(".pcap", "_TX.bin")
with open(tx_file, "wb") as f:
f.write(tx_data)
print(f"\nTX data saved to: {tx_file}")
if rx_data:
rx_file = filepath.replace(".pcap", "_RX.bin")
with open(rx_file, "wb") as f:
f.write(rx_data)
print(f"\nRX data saved to: {rx_file}")
# Quick analysis of the data
print(f"\n{'='*80}")
print("QUICK ANALYSIS")
print(f"{'='*80}")
for label, stream in [("TX (PC -> Device)", tx_data), ("RX (Device -> PC)", rx_data)]:
if not stream:
continue
print(f"\n--- {label}: {len(stream)} bytes ---")
printable = sum(1 for b in stream if 32 <= b < 127 or b in (0x0A, 0x0D))
print(f" Printable ratio: {printable/len(stream):.0%}")
# Show first 200 bytes
sample = bytes(stream[:200])
print(f" HEX: {sample.hex(' ')}")
print(f" ASCII: {''.join(chr(b) if 32 <= b < 127 else '.' for b in sample)}")
# Try to find repeating patterns
if len(stream) > 10:
# Look for common delimiters
for delim_name, delim in [("\\r\\n", b"\r\n"), ("\\n", b"\n"), ("0xFF", b"\xff"), ("0xAA", b"\xaa")]:
count = bytes(stream).count(delim)
if count > 2:
print(f" Delimiter {delim_name}: appears {count} times")
def main():
os.makedirs(CAPTURE_DIR, exist_ok=True)
if len(sys.argv) > 1 and sys.argv[1] == "--parse":
# Parse mode: just parse an existing pcap file
if len(sys.argv) < 3:
print("Usage: usb_capture.py --parse <file.pcap>")
sys.exit(1)
parse_pcap(sys.argv[2])
return
print("HPCS6500 USB Traffic Capture")
print("=" * 60)
print(f"Device address: {DEVICE_ADDRESS}")
print()
# Find all USBPcap devices
print("Looking for USBPcap filter devices...")
devices = find_usbpcap_devices()
if not devices:
print("ERROR: No USBPcap devices found.")
print(" - You may need to REBOOT after installing USBPcap")
print(" - You must run this script as ADMINISTRATOR")
sys.exit(1)
print(f"\nFound {len(devices)} USBPcap hub(s)")
print()
# Try ALL hubs — capture from each one simultaneously
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
procs = []
for i, device in enumerate(devices):
pcap_file = os.path.join(CAPTURE_DIR, f"usb_capture_{timestamp}_hub{i+1}.pcap")
print(f"Starting capture on {device} -> {pcap_file}")
proc = start_capture(device, pcap_file)
procs.append((proc, device, pcap_file))
print()
print("Capturing on ALL hubs simultaneously.")
print(">>> Now use the vendor software — take a measurement")
print(">>> Press Ctrl+C when done")
print("-" * 60)
try:
while True:
# Check if any process died
all_dead = True
for proc, device, _ in procs:
if proc.poll() is None:
all_dead = False
if all_dead:
print("\nAll capture processes have exited.")
for proc, device, _ in procs:
stderr = proc.stderr.read().decode(errors="replace").strip()
print(f" {device}: exit code {proc.returncode}" +
(f" - {stderr}" if stderr else ""))
break
time.sleep(0.5)
except KeyboardInterrupt:
print("\n\nStopping captures...")
for proc, _, _ in procs:
proc.terminate()
for proc, _, _ in procs:
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
# Check which captures got data
print()
best_file = None
best_size = 0
for proc, device, pcap_file in procs:
if os.path.exists(pcap_file):
size = os.path.getsize(pcap_file)
print(f"{device} -> {pcap_file}: {size} bytes")
if size > best_size:
best_size = size
best_file = pcap_file
else:
print(f"{device} -> no file created")
if best_file and best_size > 24:
print(f"\nBest capture: {best_file} ({best_size} bytes)")
print("\nParsing capture...\n")
parse_pcap(best_file)
else:
print("\nNo USB data captured from any hub.")
print("Make sure you're running as ADMINISTRATOR.")
if __name__ == "__main__":
main()