diff --git a/.github/workflows/ci-scripts.yml b/.github/workflows/ci-scripts.yml index 62dedbc9b..958809979 100644 --- a/.github/workflows/ci-scripts.yml +++ b/.github/workflows/ci-scripts.yml @@ -189,6 +189,7 @@ jobs: WITH_BITSHUFFLE=YES BITSHUFFLE_EXTERNAL=NO HDF5_STATIC_BUILD=\$(STATIC_BUILD) + WITH_JSON=YES EOF - name: Build main module diff --git a/.gitignore b/.gitignore index fcf8682c7..748703d23 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,5 @@ test_dim2.h5 test1_001.h5 testing_0.5 valid_points.xml +test_badpixel_*.json +*.info \ No newline at end of file diff --git a/ADApp/Db/NDBadPixel.template b/ADApp/Db/NDBadPixel.template index 400df4988..bd07f131e 100644 --- a/ADApp/Db/NDBadPixel.template +++ b/ADApp/Db/NDBadPixel.template @@ -13,6 +13,6 @@ record(waveform, "$(P)$(R)FileName") field(PINI, "YES") field(INP, "@asyn($(PORT),$(ADDR=0),$(TIMEOUT=1))BAD_PIXEL_FILE_NAME") field(FTVL, "CHAR") - field(NELM, "256") + field(NELM, "$(JSONSIZE=256)") info(Q:form, "String") } diff --git a/ADApp/pluginSrc/NDPluginBadPixel.cpp b/ADApp/pluginSrc/NDPluginBadPixel.cpp index 12f50985a..c68d51cc4 100644 --- a/ADApp/pluginSrc/NDPluginBadPixel.cpp +++ b/ADApp/pluginSrc/NDPluginBadPixel.cpp @@ -11,8 +11,7 @@ #include #include #include -#include -using nlohmann::json; + #include @@ -89,6 +88,7 @@ epicsInt64 NDPluginBadPixel::computePixelOffset(pixelCoordinate coord, badPixDim } return offset; } + template void NDPluginBadPixel::fixBadPixelsT(NDArray *pArray, badPixelList_t &badPixels, NDArrayInfo_t *pArrayInfo) { @@ -219,7 +219,6 @@ void NDPluginBadPixel::processCallbacks(NDArray *pArray) * structures don't need to be protected. */ NDArray *pArrayOut = NULL; - static const char* functionName = "processCallbacks"; /* Call the base class method */ NDPluginDriver::beginProcessCallbacks(pArray); @@ -237,7 +236,7 @@ void NDPluginBadPixel::processCallbacks(NDArray *pArray) if (NULL == pArrayOut) { asynPrint(this->pasynUserSelf, ASYN_TRACE_ERROR, "%s:%s Processing aborted; cannot allocate an NDArray for storage of temporary data.\n", - driverName, functionName); + driverName, __func__); goto doCallbacks; } fixBadPixels(pArrayOut, badPixels, &arrayInfo); @@ -253,47 +252,76 @@ void NDPluginBadPixel::processCallbacks(NDArray *pArray) callParamCallbacks(); } -asynStatus NDPluginBadPixel::readBadPixelFile(const char *fileName) +badPixelList_t NDPluginBadPixel::parseBadPixelList(json j){ + badPixelList_t badPixels; + + // Support both "Bad pixels" and "bad_pixels" as top-level key + auto topIt = findJsonKey(j, {"Bad pixels", "BadPixels", "bad_pixels"}); + if (topIt == j.end()) return badPixels; + auto badPixelsJSON = *topIt; + + pixelCoordinate coord; + for (auto pixel : badPixelsJSON) { + // Support "Pixel" or "pixel" + auto pixelIt = findJsonKey(pixel, {"Pixel", "pixel"}); + if (pixelIt == pixel.end()) continue; + coord.x = (*pixelIt)[0]; + coord.y = (*pixelIt)[1]; + badPixel bp(coord); + + // Support "Median" or "median" + auto medianIt = findJsonKey(pixel, {"Median", "median"}); + if (medianIt != pixel.end()) { + bp.mode = badPixelModeMedian; + bp.medianCoordinate.x = (*medianIt)[0]; + bp.medianCoordinate.y = (*medianIt)[1]; + } + + // Support "Set" or "set" + auto setIt = findJsonKey(pixel, {"Set", "set"}); + if (setIt != pixel.end()) { + bp.mode = badPixelModeSet; + bp.setValue = *setIt; + } + + // Support "Replace" or "replace" + auto replaceIt = findJsonKey(pixel, {"Replace", "replace"}); + if (replaceIt != pixel.end()) { + bp.mode = badPixelModeReplace; + bp.replaceCoordinate.x = (*replaceIt)[0]; + bp.replaceCoordinate.y = (*replaceIt)[1]; + } + badPixels.insert(bp); + } + return badPixels; +} + +asynStatus NDPluginBadPixel::handleBadPixelFileUpdate(const char* fileName) { json j; - static const char *functionName = "readBadPixelFile"; + std::ifstream file(fileName); try { - std::ifstream file(fileName); - file >> j; - auto badPixels = j["Bad pixels"]; - badPixelList.clear(); - pixelCoordinate coord; - for (auto pixel : badPixels) { - coord.x = pixel["Pixel"][0]; - coord.y = pixel["Pixel"][1]; - badPixel bp(coord); - if (pixel.find("Median") != pixel.end()) { - bp.mode = badPixelModeMedian; - bp.medianCoordinate.x = pixel["Median"][0]; - bp.medianCoordinate.y = pixel["Median"][1]; - } - if (pixel.find("Set") != pixel.end()) { - bp.mode = badPixelModeSet; - bp.setValue = pixel["Set"]; - } - if (pixel.find("Replace") != pixel.end()) { - bp.mode = badPixelModeReplace; - bp.replaceCoordinate.x = pixel["Replace"][0]; - bp.replaceCoordinate.y = pixel["Replace"][1]; - } - badPixelList.insert(bp); + if (!file.is_open()) { + asynPrint(this->pasynUserSelf, ASYN_TRACE_WARNING, + "%s:%s: Bad pixel file not found. Checking if the value is valid JSON: %s\n", + driverName, __func__, fileName); + j = json::parse(fileName); + } else { + file >> j; } - } - catch (const json::parse_error& e) { - asynPrint(pasynUserSelf, ASYN_TRACE_ERROR, - "%s::%s JSON error parsing bad pixel file: %s\n", driverName, functionName, e.what()); + } catch (json::parse_error& e) { + asynPrint(this->pasynUserSelf, ASYN_TRACE_ERROR, + "%s:%s: error parsing bad pixel information file %s: %s\n", + driverName, __func__, fileName, e.what()); return asynError; - } - catch (std::exception& e) { - asynPrint(pasynUserSelf, ASYN_TRACE_ERROR, - "%s::%s other error parsing bad pixel file: %s\n", driverName, functionName, e.what()); + } catch (std::exception& e) { + asynPrint(this->pasynUserSelf, ASYN_TRACE_ERROR, + "%s:%s: error parsing bad pixel information file %s: %s\n", + driverName, __func__, fileName, e.what()); return asynError; } + this->badPixelList.clear(); + this->badPixelList = this->parseBadPixelList(j); return asynSuccess; } @@ -309,7 +337,6 @@ asynStatus NDPluginBadPixel::writeOctet(asynUser *pasynUser, const char *value, int addr=0; int function = pasynUser->reason; asynStatus status = asynSuccess; - const char *functionName = "writeOctet"; status = getAddress(pasynUser, &addr); if (status != asynSuccess) return(status); // Set the parameter in the parameter library. @@ -318,7 +345,7 @@ asynStatus NDPluginBadPixel::writeOctet(asynUser *pasynUser, const char *value, if (function == NDPluginBadPixelFileName) { if ((nChars > 0) && (value[0] != 0)) { - status = this->readBadPixelFile(value); + status = handleBadPixelFileUpdate(value); } } @@ -333,11 +360,11 @@ asynStatus NDPluginBadPixel::writeOctet(asynUser *pasynUser, const char *value, if (status){ epicsSnprintf(pasynUser->errorMessage, pasynUser->errorMessageSize, "%s:%s: status=%d, function=%d, value=%s", - driverName, functionName, status, function, value); + driverName, __func__, status, function, value); } else { asynPrint(pasynUser, ASYN_TRACEIO_DRIVER, "%s:%s: function=%d, value=%s\n", - driverName, functionName, function, value); + driverName, __func__, function, value); } *nActual = nChars; return status; diff --git a/ADApp/pluginSrc/NDPluginBadPixel.h b/ADApp/pluginSrc/NDPluginBadPixel.h index 2e92a5115..91569723d 100644 --- a/ADApp/pluginSrc/NDPluginBadPixel.h +++ b/ADApp/pluginSrc/NDPluginBadPixel.h @@ -2,9 +2,23 @@ #define NDPluginProcess_H #include - +#include +#include +#include +using nlohmann::json; #include "NDPluginDriver.h" +/** Search for a key in a JSON object, trying multiple alternative names. + * Returns an iterator to the first matching key, or obj.end() if none match. */ +inline json::const_iterator findJsonKey(const json& obj, std::initializer_list keys) +{ + for (auto key : keys) { + auto it = obj.find(key); + if (it != obj.end()) return it; + } + return obj.end(); +} + // We use epicsInt64 rather than size_t in these structs because we need to do signed arithmetic on these values typedef struct { epicsInt64 x; @@ -47,6 +61,7 @@ typedef struct { } badPixDimInfo_t; typedef std::set badPixelList_t; + /* Bad pixel file*/ #define NDPluginBadPixelFileNameString "BAD_PIXEL_FILE_NAME" /* (asynOctet, r/w) Name of the bad pixel file */ @@ -60,18 +75,17 @@ class NDPLUGIN_API NDPluginBadPixel : public NDPluginDriver { void processCallbacks(NDArray *pArray); asynStatus writeOctet(asynUser *pasynUser, const char *value, size_t nChars, size_t *nActual); void report(FILE *fp, int details); + template void fixBadPixelsT(NDArray *pArray, badPixelList_t &badPixels, NDArrayInfo_t *pArrayInfo); + int fixBadPixels(NDArray *pArray, badPixelList_t &badPixels, NDArrayInfo_t *pArrayInfo); + badPixelList_t parseBadPixelList(json j); + asynStatus handleBadPixelFileUpdate(const char* fileName); + epicsInt64 computePixelOffset(pixelCoordinate coord, badPixDimInfo_t& dimInfo, NDArrayInfo_t *pArrayInfo); + badPixelList_t badPixelList; protected: /* Background array subtraction */ int NDPluginBadPixelFileName; #define FIRST_NDPLUGIN_BAD_PIXEL_PARAM NDPluginBadPixelFileName - -private: - template void fixBadPixelsT(NDArray *pArray, badPixelList_t &badPixels, NDArrayInfo_t *pArrayInfo); - int fixBadPixels(NDArray *pArray, badPixelList_t &badPixels, NDArrayInfo_t *pArrayInfo); - asynStatus readBadPixelFile(const char* fileName); - epicsInt64 computePixelOffset(pixelCoordinate coord, badPixDimInfo_t& dimInfo, NDArrayInfo_t *pArrayInfo); - badPixelList_t badPixelList; }; #endif diff --git a/ADApp/pluginTests/BadPixelPluginWrapper.cpp b/ADApp/pluginTests/BadPixelPluginWrapper.cpp new file mode 100644 index 000000000..c908d4367 --- /dev/null +++ b/ADApp/pluginTests/BadPixelPluginWrapper.cpp @@ -0,0 +1,45 @@ +/* + * BadPixelPluginWrapper.cpp + * + * Created on: 20 Apr 2026 + * Author: Jakub Wlodek + */ + +#include +using nlohmann::json; + +#include "BadPixelPluginWrapper.h" + +BadPixelPluginWrapper::BadPixelPluginWrapper(const std::string& port, const std::string& detectorPort) + : NDPluginBadPixel(port.c_str(), 50, 0, detectorPort.c_str(), 0, 0, 0, 0, 0, 1), + AsynPortClientContainer(port) +{ +} + +BadPixelPluginWrapper::BadPixelPluginWrapper(const std::string& port, + int queueSize, + int blocking, + const std::string& detectorPort, + int address, + size_t maxMemory, + int priority, + int stackSize, + int maxThreads) + : NDPluginBadPixel(port.c_str(), queueSize, blocking, + detectorPort.c_str(), address, + 0, maxMemory, priority, stackSize, maxThreads), + AsynPortClientContainer(port) +{ +} + +badPixelList_t BadPixelPluginWrapper::testParseBadPixelList(const std::string& jsonStr) +{ + json j = json::parse(jsonStr); + return parseBadPixelList(j); +} + +BadPixelPluginWrapper::~BadPixelPluginWrapper () +{ + cleanup(); +} + diff --git a/ADApp/pluginTests/BadPixelPluginWrapper.h b/ADApp/pluginTests/BadPixelPluginWrapper.h new file mode 100644 index 000000000..e13ba6371 --- /dev/null +++ b/ADApp/pluginTests/BadPixelPluginWrapper.h @@ -0,0 +1,35 @@ +/* + * BadPixelPluginWrapper.h + * + * Created on: 06 Jul 2026 + * Author: Jakub Wlodek + */ + +#ifndef ADAPP_PLUGINTESTS_BADPIXELPLUGINWRAPPER_H_ +#define ADAPP_PLUGINTESTS_BADPIXELPLUGINWRAPPER_H_ + +#include +#include "AsynPortClientContainer.h" + +class BadPixelPluginWrapper : public NDPluginBadPixel, public AsynPortClientContainer +{ +public: + BadPixelPluginWrapper(const std::string& port, const std::string& detectorPort); + BadPixelPluginWrapper(const std::string& port, + int queueSize, + int blocking, + const std::string& detectorPort, + int address, + size_t maxMemory, + int priority, + int stackSize, + int maxThreads); + virtual ~BadPixelPluginWrapper (); + + // Expose private members for testing + badPixelList_t& getBadPixelList() { return badPixelList; } + badPixelList_t testParseBadPixelList(const std::string& jsonStr); + asynStatus testHandleBadPixelFileUpdate(const char* fileName) { return handleBadPixelFileUpdate(fileName); } +}; + +#endif /* ADAPP_PLUGINTESTS_BADPIXELPLUGINWRAPPER_H_ */ diff --git a/ADApp/pluginTests/Makefile b/ADApp/pluginTests/Makefile index b3c33b085..6e598c6c9 100644 --- a/ADApp/pluginTests/Makefile +++ b/ADApp/pluginTests/Makefile @@ -42,6 +42,9 @@ ifeq ($(WITH_BOOST),YES) ADTestUtility_SRCS += CodecPluginWrapper.cpp ADTestUtility_SRCS += AttributePluginWrapper.cpp ADTestUtility_SRCS += ColorConvertPluginWrapper.cpp + ifeq ($(WITH_JSON), YES) + ADTestUtility_SRCS += BadPixelPluginWrapper.cpp + endif PROD_IOC_Linux += plugin-test PROD_IOC_Darwin += plugin-test @@ -62,6 +65,9 @@ ifeq ($(WITH_BOOST),YES) plugin-test_SRCS += test_NDPluginAttrPlot.cpp plugin-test_SRCS += test_NDPluginROI.cpp plugin-test_SRCS += test_NDPluginOverlay.cpp + ifeq ($(WITH_JSON), YES) + plugin-test_SRCS += test_NDPluginBadPixel.cpp + endif plugin-test_SRCS += test_NDArrayPool.cpp # Add tests for new plugins like this: diff --git a/ADApp/pluginTests/test_NDPluginBadPixel.cpp b/ADApp/pluginTests/test_NDPluginBadPixel.cpp new file mode 100644 index 000000000..0714e5d7d --- /dev/null +++ b/ADApp/pluginTests/test_NDPluginBadPixel.cpp @@ -0,0 +1,725 @@ +/* + * test_NDPluginBadPixel.cpp + * + * Created on: 06 Jul 2026 + * Author: Jakub Wlodek + */ + +#include + +#include "boost/test/unit_test.hpp" + +// AD dependencies +#include +#include +#include +#include + +#include +#include + +#include +#include +#include +#include +#include +using namespace std; + +#include "testingutilities.h" +#include "BadPixelPluginWrapper.h" +#include "AsynException.h" + + +static int callbackCount = 0; +static void *cbPtr = 0; + +void BadPixel_callback(void *userPvt, asynUser *pasynUser, void *pointer) +{ + cbPtr = pointer; + callbackCount++; +} + +struct BadPixelPluginTestFixture +{ + boost::shared_ptr driver; + boost::shared_ptr badPixel; + boost::shared_ptr client; + TestingPlugin* downstream_plugin; + NDArrayPool *arrayPool; + int expectedArrayCounter; + std::vector tmpFiles; + int fileCounter; + + static int testCase; + + BadPixelPluginTestFixture() + { + expectedArrayCounter = 0; + callbackCount = 0; + cbPtr = 0; + fileCounter = 0; + + std::string simport("simBP"), testport("BP"); + uniqueAsynPortName(simport); + uniqueAsynPortName(testport); + + driver = boost::shared_ptr(new asynNDArrayDriver(simport.c_str(), + 1, 0, 0, + asynGenericPointerMask, + asynGenericPointerMask, + 0, 0, 0, 0)); + arrayPool = driver->pNDArrayPool; + + badPixel = boost::shared_ptr(new BadPixelPluginWrapper(testport, + 50, 1, simport, + 0, 0, 0, 0, 1)); + + downstream_plugin = new TestingPlugin(testport.c_str(), 0); + + badPixel->start(); + badPixel->write(NDPluginDriverEnableCallbacksString, 1); + badPixel->write(NDPluginDriverBlockingCallbacksString, 1); + + client = boost::shared_ptr(new asynGenericPointerClient(testport.c_str(), 0, NDArrayDataString)); + client->registerInterruptUser(&BadPixel_callback); + } + + ~BadPixelPluginTestFixture() + { + client.reset(); + badPixel.reset(); + driver.reset(); + for (auto it = tmpFiles.rbegin(); it != tmpFiles.rend(); ++it) { + remove(it->c_str()); + } + } + + std::string writeJsonToFile(const std::string& jsonStr) + { + std::string filename = "test_badpixel_" + std::to_string(fileCounter++) + ".json"; + std::ofstream file(filename.c_str()); + file << jsonStr; + file.close(); + tmpFiles.push_back(filename); + return filename; + } + + void loadFromString(const std::string& jsonStr) { + badPixel->write(NDPluginBadPixelFileNameString, jsonStr); + } + + void loadFromFile(const std::string& jsonStr) { + std::string filePath = writeJsonToFile(jsonStr); + badPixel->write(NDPluginBadPixelFileNameString, filePath); + } + + typedef void (BadPixelPluginTestFixture::*LoadMethod)(const std::string&); + + // Parametrize test to run with both loadFromString and loadFromFile methods + void forEachLoadMethod(std::function body) + { + LoadMethod methods[] = { + &BadPixelPluginTestFixture::loadFromString, + &BadPixelPluginTestFixture::loadFromFile + }; + for (int i = 0; i < 2; i++) { + body(methods[i]); + } + } + + epicsFloat64* processAndGetOutput(NDArray *pArray) + { + badPixel->lock(); + badPixel->processCallbacks(pArray); + badPixel->unlock(); + return (epicsFloat64 *)downstream_plugin->arrays.back()->pData; + } + + NDArray* createSequentialArray(size_t xSize, size_t ySize) + { + size_t dims[2] = {xSize, ySize}; + NDArray *pArray = arrayPool->alloc(2, dims, NDFloat64, 0, 0); + epicsFloat64 *pData = (epicsFloat64 *)pArray->pData; + for (size_t i = 0; i < xSize * ySize; i++) pData[i] = (epicsFloat64)i; + return pArray; + } + + NDArray* createUniformArray(size_t xSize, size_t ySize, double value) + { + size_t dims[2] = {xSize, ySize}; + NDArray *pArray = arrayPool->alloc(2, dims, NDFloat64, 0, 0); + epicsFloat64 *pData = (epicsFloat64 *)pArray->pData; + for (size_t i = 0; i < xSize * ySize; i++) pData[i] = (epicsFloat64)value; + return pArray; + } + + // Template helpers for data type parametrized tests + template + void verifySetMode(NDDataType_t dtype, double setValue) + { + loadFromString("{\"Bad pixels\": [{\"Pixel\": [3, 2], \"Set\": " + std::to_string(setValue) + "}]}"); + size_t dims[2] = {10, 10}; + NDArray *p = arrayPool->alloc(2, dims, dtype, 0, 0); + T *d = (T *)p->pData; + for (int i = 0; i < 100; i++) d[i] = (T)i; + badPixel->lock(); badPixel->processCallbacks(p); badPixel->unlock(); + BOOST_CHECK_EQUAL(((T *)downstream_plugin->arrays.back()->pData)[23], (T)setValue); + p->release(); + } + + template + void verifyReplaceMode(NDDataType_t dtype) + { + loadFromString("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Replace\": [1, 0]}]}"); + size_t dims[2] = {10, 10}; + NDArray *p = arrayPool->alloc(2, dims, dtype, 0, 0); + T *d = (T *)p->pData; + for (int i = 0; i < 100; i++) d[i] = (T)(i * 10); + badPixel->lock(); badPixel->processCallbacks(p); badPixel->unlock(); + // (5,5)->offset=55, replaced by (6,5)->offset=56, value=560 + BOOST_CHECK_EQUAL(((T *)downstream_plugin->arrays.back()->pData)[55], (T)560); + p->release(); + } + + template + void verifyMedianMode(NDDataType_t dtype) + { + loadFromString("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Median\": [1, 1]}]}"); + size_t dims[2] = {10, 10}; + NDArray *p = arrayPool->alloc(2, dims, dtype, 0, 0); + T *d = (T *)p->pData; + for (int i = 0; i < 100; i++) d[i] = (T)i; + d[55] = (T)9999; + badPixel->lock(); badPixel->processCallbacks(p); badPixel->unlock(); + // Neighbors: 44,45,46,54,56,64,65,66 -> median=(54+56)/2=55 + BOOST_CHECK_EQUAL(((T *)downstream_plugin->arrays.back()->pData)[55], (T)55); + p->release(); + } +}; + + +// Test helper function to get value from json object given multiple +// possible keys, returning the first match found. +BOOST_AUTO_TEST_SUITE(FindJsonKeyTests) + +BOOST_AUTO_TEST_CASE(finds_first_matching_key) +{ + json obj = {{"Alpha", 1}, {"beta", 2}}; + auto it = findJsonKey(obj, {"Alpha"}); + BOOST_REQUIRE(it != obj.end()); + BOOST_CHECK_EQUAL(*it, 1); +} + +BOOST_AUTO_TEST_CASE(finds_second_option_when_first_missing) +{ + json obj = {{"beta", 42}}; + auto it = findJsonKey(obj, {"Alpha", "beta"}); + BOOST_REQUIRE(it != obj.end()); + BOOST_CHECK_EQUAL(*it, 42); +} + +BOOST_AUTO_TEST_CASE(finds_third_option) +{ + json obj = {{"gamma", 99}}; + auto it = findJsonKey(obj, {"Alpha", "beta", "gamma"}); + BOOST_REQUIRE(it != obj.end()); + BOOST_CHECK_EQUAL(*it, 99); +} + +BOOST_AUTO_TEST_CASE(returns_end_when_no_match) +{ + json obj = {{"foo", 1}, {"bar", 2}}; + BOOST_CHECK(findJsonKey(obj, {"baz", "qux"}) == obj.end()); +} + +BOOST_AUTO_TEST_CASE(returns_end_for_empty_keys_list) +{ + json obj = {{"foo", 1}}; + BOOST_CHECK(findJsonKey(obj, {}) == obj.end()); +} + +BOOST_AUTO_TEST_CASE(priority_returns_first_match) +{ + json obj = {{"A", 10}, {"a", 20}}; + auto it = findJsonKey(obj, {"A", "a"}); + BOOST_REQUIRE(it != obj.end()); + BOOST_CHECK_EQUAL(*it, 10); +} + +BOOST_AUTO_TEST_SUITE_END() + + +// Tests parsing bad pixel json into badPixelList_t objects +BOOST_FIXTURE_TEST_SUITE(BadPixelParsingTests, BadPixelPluginTestFixture) + +BOOST_AUTO_TEST_CASE(parse_set_mode) +{ + badPixelList_t result = badPixel->testParseBadPixelList("{\"Bad pixels\": [{\"Pixel\": [3, 2], \"Set\": 42.0}]}"); + BOOST_REQUIRE_EQUAL(result.size(), (size_t)1); + auto it = result.begin(); + BOOST_CHECK_EQUAL(it->coordinate.x, 3); + BOOST_CHECK_EQUAL(it->coordinate.y, 2); + BOOST_CHECK_EQUAL(it->mode, badPixelModeSet); + BOOST_CHECK_EQUAL(it->setValue, 42.0); +} + +BOOST_AUTO_TEST_CASE(parse_replace_mode) +{ + badPixelList_t result = badPixel->testParseBadPixelList("{\"Bad pixels\": [{\"Pixel\": [5, 7], \"Replace\": [1, -1]}]}"); + BOOST_REQUIRE_EQUAL(result.size(), (size_t)1); + auto it = result.begin(); + BOOST_CHECK_EQUAL(it->mode, badPixelModeReplace); + BOOST_CHECK_EQUAL(it->replaceCoordinate.x, 1); + BOOST_CHECK_EQUAL(it->replaceCoordinate.y, -1); +} + +BOOST_AUTO_TEST_CASE(parse_median_mode) +{ + badPixelList_t result = badPixel->testParseBadPixelList("{\"Bad pixels\": [{\"Pixel\": [4, 4], \"Median\": [2, 3]}]}"); + BOOST_REQUIRE_EQUAL(result.size(), (size_t)1); + BOOST_CHECK_EQUAL(result.begin()->mode, badPixelModeMedian); + BOOST_CHECK_EQUAL(result.begin()->medianCoordinate.x, 2); + BOOST_CHECK_EQUAL(result.begin()->medianCoordinate.y, 3); +} + +BOOST_AUTO_TEST_CASE(parse_multiple_mixed_modes) +{ + badPixelList_t result = badPixel->testParseBadPixelList( + "{\"Bad pixels\": [" + "{\"Pixel\": [1, 1], \"Set\": 0.0}," + "{\"Pixel\": [2, 3], \"Replace\": [-1, 0]}," + "{\"Pixel\": [5, 5], \"Median\": [1, 1]}]}"); + BOOST_REQUIRE_EQUAL(result.size(), (size_t)3); + auto it = result.begin(); + BOOST_CHECK_EQUAL(it->mode, badPixelModeSet); ++it; + BOOST_CHECK_EQUAL(it->mode, badPixelModeReplace); ++it; + BOOST_CHECK_EQUAL(it->mode, badPixelModeMedian); +} + +BOOST_AUTO_TEST_CASE(parse_empty_list) +{ + BOOST_CHECK_EQUAL(badPixel->testParseBadPixelList("{\"Bad pixels\": []}").size(), (size_t)0); +} + +BOOST_AUTO_TEST_CASE(parse_duplicate_coordinates_deduplicates) +{ + badPixelList_t result = badPixel->testParseBadPixelList( + "{\"Bad pixels\": [{\"Pixel\": [3, 3], \"Set\": 1.0}, {\"Pixel\": [3, 3], \"Set\": 2.0}]}"); + BOOST_CHECK_EQUAL(result.size(), (size_t)1); +} + +BOOST_AUTO_TEST_CASE(parse_ordering_by_y_then_x) +{ + badPixelList_t result = badPixel->testParseBadPixelList( + "{\"Bad pixels\": [" + "{\"Pixel\": [9, 0], \"Set\": 0}," + "{\"Pixel\": [0, 9], \"Set\": 0}," + "{\"Pixel\": [5, 5], \"Set\": 0}," + "{\"Pixel\": [0, 0], \"Set\": 0}]}"); + BOOST_REQUIRE_EQUAL(result.size(), (size_t)4); + auto it = result.begin(); + BOOST_CHECK_EQUAL(it->coordinate.y, 0); BOOST_CHECK_EQUAL(it->coordinate.x, 0); ++it; + BOOST_CHECK_EQUAL(it->coordinate.y, 0); BOOST_CHECK_EQUAL(it->coordinate.x, 9); ++it; + BOOST_CHECK_EQUAL(it->coordinate.y, 5); ++it; + BOOST_CHECK_EQUAL(it->coordinate.y, 9); +} + +BOOST_AUTO_TEST_CASE(parse_mode_priority_last_wins) +{ + badPixelList_t result = badPixel->testParseBadPixelList( + "{\"Bad pixels\": [{\"Pixel\": [1, 1], \"Median\": [1, 1], \"Set\": 5.0, \"Replace\": [0, 1]}]}"); + BOOST_CHECK_EQUAL(result.begin()->mode, badPixelModeReplace); +} + +BOOST_AUTO_TEST_CASE(parse_lowercase_top_level_key) +{ + badPixelList_t result = badPixel->testParseBadPixelList("{\"bad_pixels\": [{\"Pixel\": [2, 3], \"Set\": 10.0}]}"); + BOOST_REQUIRE_EQUAL(result.size(), (size_t)1); + BOOST_CHECK_EQUAL(result.begin()->setValue, 10.0); +} + +BOOST_AUTO_TEST_CASE(parse_all_lowercase_keys) +{ + badPixelList_t result = badPixel->testParseBadPixelList("{\"bad_pixels\": [{\"pixel\": [4, 5], \"replace\": [-1, 1]}]}"); + BOOST_REQUIRE_EQUAL(result.size(), (size_t)1); + BOOST_CHECK_EQUAL(result.begin()->mode, badPixelModeReplace); + BOOST_CHECK_EQUAL(result.begin()->coordinate.x, 4); +} + +BOOST_AUTO_TEST_CASE(parse_lowercase_individual_keys) +{ + BOOST_CHECK_EQUAL( + badPixel->testParseBadPixelList("{\"Bad pixels\": [{\"pixel\": [1, 1], \"set\": 5.0}]}").begin()->mode, + badPixelModeSet); + BOOST_CHECK_EQUAL( + badPixel->testParseBadPixelList("{\"Bad pixels\": [{\"pixel\": [1, 1], \"median\": [1, 1]}]}").begin()->mode, + badPixelModeMedian); + BOOST_CHECK_EQUAL( + badPixel->testParseBadPixelList("{\"Bad pixels\": [{\"pixel\": [1, 1], \"replace\": [0, 1]}]}").begin()->mode, + badPixelModeReplace); +} + +BOOST_AUTO_TEST_CASE(parse_missing_top_level_key_returns_empty) +{ + BOOST_CHECK_EQUAL( + badPixel->testParseBadPixelList("{\"wrong_key\": [{\"Pixel\": [1, 1], \"Set\": 0.0}]}").size(), (size_t)0); +} + +BOOST_AUTO_TEST_CASE(handle_file_update_from_file) +{ + std::string filePath = writeJsonToFile("{\"Bad pixels\": [{\"Pixel\": [3, 4], \"Set\": 7.0}]}"); + BOOST_CHECK_EQUAL(badPixel->testHandleBadPixelFileUpdate(filePath.c_str()), asynSuccess); + BOOST_CHECK_EQUAL(badPixel->getBadPixelList().begin()->setValue, 7.0); +} + +BOOST_AUTO_TEST_CASE(handle_file_update_from_json_string) +{ + BOOST_CHECK_EQUAL( + badPixel->testHandleBadPixelFileUpdate("{\"Bad pixels\": [{\"Pixel\": [6, 2], \"Median\": [2, 2]}]}"), + asynSuccess); + BOOST_CHECK_EQUAL(badPixel->getBadPixelList().begin()->mode, badPixelModeMedian); +} + +BOOST_AUTO_TEST_CASE(handle_file_update_invalid_json) +{ + BOOST_CHECK_EQUAL(badPixel->testHandleBadPixelFileUpdate("not valid json"), asynError); +} + +BOOST_AUTO_TEST_CASE(handle_file_update_nonexistent_file) +{ + BOOST_CHECK_EQUAL(badPixel->testHandleBadPixelFileUpdate("/nonexistent/path/file.json"), asynError); +} + +BOOST_AUTO_TEST_CASE(handle_file_update_replaces_previous_list) +{ + badPixel->testHandleBadPixelFileUpdate("{\"Bad pixels\": [{\"Pixel\": [1, 1], \"Set\": 1.0}, {\"Pixel\": [2, 2], \"Set\": 2.0}]}"); + BOOST_CHECK_EQUAL(badPixel->getBadPixelList().size(), (size_t)2); + badPixel->testHandleBadPixelFileUpdate("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Set\": 5.0}]}"); + BOOST_CHECK_EQUAL(badPixel->getBadPixelList().size(), (size_t)1); +} + +BOOST_AUTO_TEST_SUITE_END() + + +// Tests for end-to-end processing of arrays with bad pixels, using both loadFromString and loadFromFile methods +BOOST_FIXTURE_TEST_SUITE(BadPixelPluginEndToEndTests, BadPixelPluginTestFixture) + +BOOST_AUTO_TEST_CASE(passthrough_no_bad_pixels) +{ + NDArray *pArray = createSequentialArray(10, 10); + epicsFloat64 *out = processAndGetOutput(pArray); + for (size_t i = 0; i < 100; i++) BOOST_CHECK_EQUAL(out[i], (epicsFloat64)i); + pArray->release(); +} + +BOOST_AUTO_TEST_CASE(set_mode) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [3, 2], \"Set\": 0.0}]}"); + NDArray *p = createSequentialArray(10, 10); + epicsFloat64 *out = processAndGetOutput(p); + BOOST_CHECK_EQUAL(out[23], 0.0); + BOOST_CHECK_EQUAL(out[22], 22.0); + BOOST_CHECK_EQUAL(out[24], 24.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(set_mode_specific_value) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Set\": 999.0}]}"); + NDArray *p = createSequentialArray(10, 10); + BOOST_CHECK_EQUAL(processAndGetOutput(p)[55], 999.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(replace_mode) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [3, 2], \"Replace\": [1, 0]}]}"); + NDArray *p = createSequentialArray(10, 10); + BOOST_CHECK_EQUAL(processAndGetOutput(p)[23], 24.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(replace_mode_negative_offset) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Replace\": [-1, -1]}]}"); + NDArray *p = createSequentialArray(10, 10); + BOOST_CHECK_EQUAL(processAndGetOutput(p)[55], 44.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(median_mode_uniform) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Median\": [1, 1]}]}"); + NDArray *p = createUniformArray(10, 10, 100.0); + ((epicsFloat64 *)p->pData)[55] = 9999.0; + BOOST_CHECK_EQUAL(processAndGetOutput(p)[55], 100.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(median_mode_sequential) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Median\": [1, 1]}]}"); + NDArray *p = createSequentialArray(10, 10); + BOOST_CHECK_EQUAL(processAndGetOutput(p)[55], 55.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(median_5x5_kernel) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Median\": [2, 2]}]}"); + NDArray *p = createUniformArray(10, 10, 50.0); + ((epicsFloat64 *)p->pData)[55] = 9999.0; + BOOST_CHECK_EQUAL(processAndGetOutput(p)[55], 50.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(multiple_bad_pixels) +{ + std::string j = "{\"Bad pixels\": [" + "{\"Pixel\": [0, 0], \"Set\": -1.0}," + "{\"Pixel\": [9, 9], \"Set\": -2.0}," + "{\"Pixel\": [5, 3], \"Set\": -3.0}]}"; + forEachLoadMethod([&](LoadMethod load) { + (this->*load)(j); + NDArray *p = createSequentialArray(10, 10); + epicsFloat64 *out = processAndGetOutput(p); + BOOST_CHECK_EQUAL(out[0], -1.0); + BOOST_CHECK_EQUAL(out[99], -2.0); + BOOST_CHECK_EQUAL(out[35], -3.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(out_of_bounds_pixel_ignored) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [20, 20], \"Set\": 0.0}]}"); + NDArray *p = createSequentialArray(10, 10); + epicsFloat64 *out = processAndGetOutput(p); + for (size_t i = 0; i < 100; i++) BOOST_CHECK_EQUAL(out[i], (epicsFloat64)i); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(replace_with_bad_pixel_skipped) +{ + std::string j = "{\"Bad pixels\": [" + "{\"Pixel\": [3, 2], \"Replace\": [1, 0]}," + "{\"Pixel\": [4, 2], \"Set\": 0.0}]}"; + forEachLoadMethod([&](LoadMethod load) { + (this->*load)(j); + NDArray *p = createSequentialArray(10, 10); + epicsFloat64 *out = processAndGetOutput(p); + BOOST_CHECK_EQUAL(out[23], 23.0); + BOOST_CHECK_EQUAL(out[24], 0.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(update_list_replaces_old) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [0, 0], \"Set\": -1.0}]}"); + NDArray *p1 = createSequentialArray(10, 10); + BOOST_CHECK_EQUAL(processAndGetOutput(p1)[0], -1.0); + + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [1, 1], \"Set\": -5.0}]}"); + NDArray *p2 = createSequentialArray(10, 10); + epicsFloat64 *out2 = processAndGetOutput(p2); + BOOST_CHECK_EQUAL(out2[0], 0.0); + BOOST_CHECK_EQUAL(out2[11], -5.0); + p1->release(); + p2->release(); + }); +} + +BOOST_AUTO_TEST_CASE(invalid_json_throws) +{ + BOOST_CHECK_THROW(loadFromString("not valid json"), AsynException); + std::string filePath = writeJsonToFile("not valid json"); + BOOST_CHECK_THROW(badPixel->write(NDPluginBadPixelFileNameString, filePath), AsynException); +} + +BOOST_AUTO_TEST_CASE(binning) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [4, 4], \"Set\": 0.0}]}"); + size_t dims[2] = {5, 5}; + NDArray *p = arrayPool->alloc(2, dims, NDFloat64, 0, 0); + epicsFloat64 *d = (epicsFloat64 *)p->pData; + for (size_t i = 0; i < 25; i++) d[i] = (epicsFloat64)(i + 1); + p->dims[0].binning = 2; + p->dims[1].binning = 2; + BOOST_CHECK_EQUAL(processAndGetOutput(p)[12], 0.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(offset) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [5, 5], \"Set\": 0.0}]}"); + size_t dims[2] = {5, 5}; + NDArray *p = arrayPool->alloc(2, dims, NDFloat64, 0, 0); + epicsFloat64 *d = (epicsFloat64 *)p->pData; + for (size_t i = 0; i < 25; i++) d[i] = (epicsFloat64)(i + 100); + p->dims[0].offset = 3; + p->dims[1].offset = 3; + BOOST_CHECK_EQUAL(processAndGetOutput(p)[12], 0.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(one_dimensional_array) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [5, 0], \"Set\": -1.0}]}"); + size_t dims[1] = {10}; + NDArray *p = arrayPool->alloc(1, dims, NDFloat64, 0, 0); + epicsFloat64 *d = (epicsFloat64 *)p->pData; + for (size_t i = 0; i < 10; i++) d[i] = (epicsFloat64)i; + epicsFloat64 *out = processAndGetOutput(p); + BOOST_CHECK_EQUAL(out[5], -1.0); + BOOST_CHECK_EQUAL(out[4], 4.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(mixed_modes) +{ + std::string j = "{\"Bad pixels\": [" + "{\"Pixel\": [1, 1], \"Set\": 0.0}," + "{\"Pixel\": [3, 3], \"Replace\": [1, 0]}," + "{\"Pixel\": [7, 7], \"Median\": [1, 1]}]}"; + forEachLoadMethod([&](LoadMethod load) { + (this->*load)(j); + NDArray *p = createSequentialArray(10, 10); + epicsFloat64 *out = processAndGetOutput(p); + BOOST_CHECK_EQUAL(out[11], 0.0); + BOOST_CHECK_EQUAL(out[33], 34.0); + BOOST_CHECK_EQUAL(out[77], 77.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(corner_pixel_median) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [0, 0], \"Median\": [1, 1]}]}"); + NDArray *p = createSequentialArray(10, 10); + BOOST_CHECK_EQUAL(processAndGetOutput(p)[0], 10.0); + p->release(); + }); +} + +BOOST_AUTO_TEST_CASE(replace_out_of_bounds_target_skipped) +{ + forEachLoadMethod([&](LoadMethod load) { + (this->*load)("{\"Bad pixels\": [{\"Pixel\": [0, 0], \"Replace\": [-1, 0]}]}"); + NDArray *p = createSequentialArray(10, 10); + BOOST_CHECK_EQUAL(processAndGetOutput(p)[0], 0.0); + p->release(); + }); +} + + +// Check for report output +BOOST_AUTO_TEST_CASE(report_output) +{ + loadFromString("{\"Bad pixels\": [" + "{\"Pixel\": [1, 0], \"Set\": 42.0}," + "{\"Pixel\": [2, 1], \"Replace\": [1, 0]}," + "{\"Pixel\": [3, 2], \"Median\": [2, 3]}]}"); + + char buffer[2048]; + FILE *fp = fmemopen(buffer, sizeof(buffer), "w"); + badPixel->report(fp, 1); + fclose(fp); + std::string output(buffer); + BOOST_CHECK(output.find("Set, value=42") != std::string::npos); + BOOST_CHECK(output.find("Replace, relative coordinates=[1,0]") != std::string::npos); + BOOST_CHECK(output.find("Median, size=[2,3]") != std::string::npos); +} + +BOOST_AUTO_TEST_CASE(report_no_details_omits_pixels) +{ + loadFromString("{\"Bad pixels\": [{\"Pixel\": [1, 1], \"Set\": 0.0}]}"); + char buffer[2048]; + memset(buffer, 0, sizeof(buffer)); + FILE *fp = fmemopen(buffer, sizeof(buffer), "w"); + badPixel->report(fp, 0); + fclose(fp); + BOOST_CHECK(std::string(buffer).find("Bad pixel") == std::string::npos); +} + +BOOST_AUTO_TEST_CASE(median_skips_bad_neighbor) +{ + loadFromString("{\"Bad pixels\": [" + "{\"Pixel\": [5, 5], \"Median\": [1, 1]}," + "{\"Pixel\": [4, 4], \"Set\": 0.0}]}"); + NDArray *p = createSequentialArray(10, 10); + epicsFloat64 *out = processAndGetOutput(p); + BOOST_CHECK_EQUAL(out[44], 0.0); + // Excluding (4,4): 45,46,54,56,64,65,66 -> median=56 + BOOST_CHECK_EQUAL(out[55], 56.0); + p->release(); +} + +// Data type parametrized tests - all types exercised in one test each +BOOST_AUTO_TEST_CASE(set_mode_all_data_types) +{ + verifySetMode (NDInt8, 0.0); + verifySetMode (NDUInt8, 100.0); + verifySetMode (NDInt16, -100.0); + verifySetMode (NDUInt16, 1000.0); + verifySetMode (NDInt32, -50000.0); + verifySetMode (NDUInt32, 99999.0); + verifySetMode (NDInt64, -123456789.0); + verifySetMode (NDUInt64, 987654321.0); + verifySetMode(NDFloat32, 3.0); + verifySetMode(NDFloat64, 999.0); +} + +BOOST_AUTO_TEST_CASE(replace_mode_all_data_types) +{ + verifyReplaceMode (NDInt8); + verifyReplaceMode (NDUInt8); + verifyReplaceMode (NDInt16); + verifyReplaceMode (NDUInt16); + verifyReplaceMode (NDInt32); + verifyReplaceMode (NDUInt32); + verifyReplaceMode (NDInt64); + verifyReplaceMode (NDUInt64); + verifyReplaceMode(NDFloat32); + verifyReplaceMode(NDFloat64); +} + +BOOST_AUTO_TEST_CASE(median_mode_all_data_types) +{ + verifyMedianMode (NDInt8); + verifyMedianMode (NDUInt8); + verifyMedianMode (NDInt16); + verifyMedianMode (NDUInt16); + verifyMedianMode (NDInt32); + verifyMedianMode (NDUInt32); + verifyMedianMode (NDInt64); + verifyMedianMode (NDUInt64); + verifyMedianMode(NDFloat32); + verifyMedianMode(NDFloat64); +} + +BOOST_AUTO_TEST_SUITE_END() diff --git a/iocBoot/EXAMPLE_commonPlugins.cmd b/iocBoot/EXAMPLE_commonPlugins.cmd index 56383cba1..5039748c1 100644 --- a/iocBoot/EXAMPLE_commonPlugins.cmd +++ b/iocBoot/EXAMPLE_commonPlugins.cmd @@ -160,7 +160,7 @@ dbLoadRecords("NDCodec.template", "P=$(PREFIX), R=Codec2:, PORT=CODEC2, ADDR=0, # Create a bad pixel plugin NDBadPixelConfigure("BADPIX1", $(QSIZE), 0, "$(PORT)", 0, 0, 0, 0, 0, 5) -dbLoadRecords("NDBadPixel.template", "P=$(PREFIX), R=BadPix1:, PORT=BADPIX1, ADDR=0, TIMEOUT=1, NDARRAY_PORT=$(PORT)") +dbLoadRecords("NDBadPixel.template", "P=$(PREFIX), R=BadPix1:, PORT=BADPIX1, ADDR=0, TIMEOUT=1, JSONSIZE=512, NDARRAY_PORT=$(PORT)") set_requestfile_path("./") set_requestfile_path("$(ADCORE)/db")