From ffe679eb9874f6c02d91172583a978fa2bcc477f Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 09:59:41 +0200
Subject: [PATCH 01/15] Handle JSON null in numeric All datapoint queries
---
.../query/AssetDatapointAllQuery.java | 3 +-
.../assets/AssetDatapointNullQueryTest.groovy | 93 +++++++++++++++++++
2 files changed, 95 insertions(+), 1 deletion(-)
create mode 100644 test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
index 26943b55b8..03c6188903 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
@@ -50,7 +50,8 @@ public String getSQLQuery(String tableName, Class> attributeType) throws Illeg
String conditionSuffix =
" where ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? order by timestamp desc";
if (isNumber) {
- return "select timestamp as X, cast(value as numeric) as Y from "
+ // JSON null must become SQL NULL before it can be cast to a number.
+ return "select timestamp as X, cast(nullif(value, 'null'::jsonb) as numeric) as Y from "
+ tableName
+ conditionSuffix;
} else if (isBoolean) {
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
new file mode 100644
index 0000000000..07ad616960
--- /dev/null
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -0,0 +1,93 @@
+/*
+ * Copyright 2026, OpenRemote Inc.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU Affero General Public License as
+ * published by the Free Software Foundation, either version 3 of the
+ * License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU Affero General Public License for more details.
+ *
+ * You should have received a copy of the GNU Affero General Public License
+ * along with this program. If not, see .
+ *
+ * SPDX-License-Identifier: AGPL-3.0-or-later
+ */
+package org.openremote.test.assets
+
+import org.openremote.manager.asset.AssetStorageService
+import org.openremote.manager.datapoint.AssetDatapointService
+import org.openremote.manager.datapoint.AssetPredictedDatapointService
+import org.openremote.manager.setup.SetupService
+import org.openremote.model.asset.impl.ThingAsset
+import org.openremote.model.attribute.Attribute
+import org.openremote.model.datapoint.ValueDatapoint
+import org.openremote.model.datapoint.query.AssetDatapointAllQuery
+import org.openremote.model.value.ValueType
+import org.openremote.setup.integration.KeycloakTestSetup
+import org.openremote.test.ManagerContainerTrait
+import spock.lang.Specification
+
+import java.time.temporal.ChronoUnit
+
+class AssetDatapointNullQueryTest extends Specification implements ManagerContainerTrait {
+
+ AssetStorageService assetStorageService
+ AssetDatapointService historicalService
+ AssetPredictedDatapointService predictedService
+ ThingAsset asset
+ String realm
+ long start
+
+ def setup() {
+ def container = startContainer(defaultConfig(), defaultServices())
+ stopPseudoClock()
+ assetStorageService = container.getService(AssetStorageService.class)
+ historicalService = container.getService(AssetDatapointService.class)
+ predictedService = container.getService(AssetPredictedDatapointService.class)
+ realm = container.getService(SetupService.class).getTaskOfType(KeycloakTestSetup.class).realmBuilding.name
+ start = getInstantTimeOf(container).truncatedTo(ChronoUnit.SECONDS).toEpochMilli()
+ }
+
+ def cleanup() {
+ if (asset?.id) {
+ assert assetStorageService.delete([asset.id])
+ }
+ }
+
+ def "numeric All query preserves #description for #storage #valueType.name datapoints"() {
+ given: "numeric datapoints containing explicitly stored JSON nulls"
+ asset = assetStorageService.merge(new ThingAsset("Null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def timestamps = (0..
+ new ValueDatapoint<>(timestamps[index], value)
+ }
+ // The query must include both boundary timestamps and exclude their neighbours.
+ datapoints.add(new ValueDatapoint<>(timestamps.first() - 60_000L, 111G))
+ datapoints.add(new ValueDatapoint<>(timestamps.last() + 60_000L, 222G))
+ service.upsertValues(asset.id, "value", datapoints)
+
+ when: "the inclusive range is queried without aggregation"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointAllQuery(timestamps.first(), timestamps.last()))
+
+ then: "every timestamp is returned newest first, retaining unavailable values"
+ result.collect { it.timestamp } == timestamps.reverse()
+ result.collect { it.value } == values.reverse()
+
+ where:
+ [storage, valueType, description, values] << ["historical", "predicted"].collectMany { storage ->
+ [ValueType.NUMBER, ValueType.BIG_NUMBER].collectMany { type ->
+ [
+ [storage, type, "mixed values", [-5G, 0G, null, 90.125G]],
+ [storage, type, "an entirely null range", [null, null, null]]
+ ]
+ }
+ }
+ }
+}
From 3b1352222d916b7f329d1aab8abbd1b43d1f8e8e Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 10:36:56 +0200
Subject: [PATCH 02/15] Handle JSON null in nearest datapoint queries
---
.../query/AssetDatapointNearestQuery.java | 6 ++-
.../assets/AssetDatapointNullQueryTest.groovy | 38 +++++++++++++++++++
2 files changed, 42 insertions(+), 2 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointNearestQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointNearestQuery.java
index 4842794682..41042f919c 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointNearestQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointNearestQuery.java
@@ -31,7 +31,8 @@
description =
"Returns at most one numeric datapoint: the closest value at or before the requested "
+ "time. Use `fromTimestamp` as a Unix-seconds timestamp at the HTTP API boundary. `toTimestamp`, "
- + "`fromTime`, and `toTime` are ignored. The equality boundary is inclusive.")
+ + "`fromTime`, and `toTime` are ignored. The equality boundary is inclusive. "
+ + "If the nearest datapoint has a null value, its timestamp and null value are returned.")
public class AssetDatapointNearestQuery extends AssetDatapointQuery {
public AssetDatapointNearestQuery() {}
@@ -43,7 +44,8 @@ public AssetDatapointNearestQuery(long timestamp) {
@Override
public String getSQLQuery(String tableName, Class> attributeType) throws IllegalStateException {
- return "SELECT timestamp as X, cast(value as numeric) as Y "
+ // Preserve an unavailable nearest value instead of rejecting its JSON null.
+ return "SELECT timestamp as X, cast(nullif(value, 'null'::jsonb) as numeric) as Y "
+ " FROM "
+ tableName
+ " "
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 07ad616960..466e85d0df 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -26,6 +26,7 @@ import org.openremote.model.asset.impl.ThingAsset
import org.openremote.model.attribute.Attribute
import org.openremote.model.datapoint.ValueDatapoint
import org.openremote.model.datapoint.query.AssetDatapointAllQuery
+import org.openremote.model.datapoint.query.AssetDatapointNearestQuery
import org.openremote.model.value.ValueType
import org.openremote.setup.integration.KeycloakTestSetup
import org.openremote.test.ManagerContainerTrait
@@ -43,6 +44,8 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
long start
def setup() {
+ // Match the pseudo clock's UTC zone before JDBC connections capture the JVM time zone.
+ TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
def container = startContainer(defaultConfig(), defaultServices())
stopPseudoClock()
assetStorageService = container.getService(AssetStorageService.class)
@@ -90,4 +93,39 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
}
}
}
+
+ def "numeric Nearest query preserves unavailable values for #storage #valueType.name datapoints"() {
+ given: "an unavailable datapoint between older and newer available prices"
+ asset = assetStorageService.merge(new ThingAsset("Nearest null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ service.upsertValues(asset.id, "value", [
+ new ValueDatapoint<>(start, -5G),
+ new ValueDatapoint<>(start + 60_000L, null),
+ new ValueDatapoint<>(start + 120_000L, 0G),
+ new ValueDatapoint<>(start + 180_000L, 90.125G)
+ ])
+ def queryAt = { long offset ->
+ service.queryDatapoints(asset.id, "value", new AssetDatapointNearestQuery(start + offset)).collect {
+ [it.timestamp, it.value]
+ }
+ }
+
+ expect: "an exact match retains the null value and its timestamp"
+ queryAt(60_000L) == [[start + 60_000L, null]]
+
+ and: "a query between observations returns the null row, not an older or newer price"
+ queryAt(90_000L) == [[start + 60_000L, null]]
+
+ and: "a query before the first observation returns no datapoint"
+ queryAt(-1_000L).isEmpty()
+
+ and: "available observations remain selectable at and after their timestamps"
+ queryAt(0) == [[start, -5G]]
+ queryAt(120_000L) == [[start + 120_000L, 0G]]
+ queryAt(240_000L) == [[start + 180_000L, 90.125G]]
+
+ where:
+ [storage, valueType] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER]].combinations()
+ }
}
From 446f09d594581225be5b0eb58dd7e1f208488553 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 10:46:10 +0200
Subject: [PATCH 03/15] Handle JSON null in standard numeric datapoint
aggregates
---
.../query/AssetDatapointIntervalQuery.java | 9 ++--
.../assets/AssetDatapointNullQueryTest.groovy | 50 +++++++++++++++++++
2 files changed, 55 insertions(+), 4 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index de10cb4ee2..2a4fb05732 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -57,7 +57,8 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
+ "value; `DIFFERENCE` calculates the change between the last values of consecutive buckets; "
+ "`COUNT` counts datapoints; `SUM` totals numeric values (or true values for booleans); `MODE` "
+ "selects the most frequent value; and `MEDIAN` selects the middle value. `DIFFERENCE` is numeric "
- + "only.",
+ + "only. Numeric MIN, AVG, MAX, SUM and MEDIAN ignore null values and return null "
+ + "for buckets with no available values.",
requiredMode = Schema.RequiredMode.REQUIRED)
public Formula formula;
@@ -137,7 +138,7 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + "SUM(cast(value as numeric)) AS total_sum "
+ + "SUM(cast(nullif(value, 'null'::jsonb) as numeric)) AS total_sum "
+ "FROM "
+ tableName
+ " "
@@ -170,7 +171,7 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + "PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY cast(value as numeric)) as median_value "
+ + "PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY cast(nullif(value, 'null'::jsonb) as numeric)) as median_value "
+ "FROM "
+ tableName
+ " "
@@ -181,7 +182,7 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
+ function
+ "(cast(? as interval), timestamp) AS x, "
+ this.formula.toString().toLowerCase()
- + "(cast(value as numeric)) FROM "
+ + "(cast(nullif(value, 'null'::jsonb) as numeric)) FROM "
+ tableName
+ " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? GROUP BY x ORDER by x ASC";
}
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 466e85d0df..d53e53d57f 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -26,12 +26,15 @@ import org.openremote.model.asset.impl.ThingAsset
import org.openremote.model.attribute.Attribute
import org.openremote.model.datapoint.ValueDatapoint
import org.openremote.model.datapoint.query.AssetDatapointAllQuery
+import org.openremote.model.datapoint.query.AssetDatapointIntervalQuery
+import org.openremote.model.datapoint.query.AssetDatapointIntervalQuery.Formula
import org.openremote.model.datapoint.query.AssetDatapointNearestQuery
import org.openremote.model.value.ValueType
import org.openremote.setup.integration.KeycloakTestSetup
import org.openremote.test.ManagerContainerTrait
import spock.lang.Specification
+import java.time.Instant
import java.time.temporal.ChronoUnit
class AssetDatapointNullQueryTest extends Specification implements ManagerContainerTrait {
@@ -128,4 +131,51 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
where:
[storage, valueType] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER]].combinations()
}
+
+ def "numeric #formula handles unavailable values for #storage #valueType.name with gapFill=#gapFill"() {
+ given: "mixed, entirely null, empty, and zero-valued minute buckets"
+ asset = assetStorageService.merge(new ThingAsset("Interval null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def bucketStart = Instant.ofEpochMilli(start).truncatedTo(ChronoUnit.MINUTES).toEpochMilli()
+ service.upsertValues(asset.id, "value", [
+ [-1_000L, 999G],
+ [0L, -6G],
+ [10_000L, 0G],
+ [20_000L, null],
+ [30_000L, 6G],
+ [40_000L, 12G],
+ [70_000L, null],
+ [100_000L, null],
+ [190_000L, 0G],
+ [240_000L, 999G]
+ ].collect { new ValueDatapoint<>(bucketStart + it[0], it[1]) })
+
+ when: "the four-minute range is aggregated"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart, bucketStart + 239_999L,
+ "1 minute", formula, gapFill))
+
+ then: "null-only buckets remain present and empty buckets follow the gap-fill setting"
+ result.collect {
+ it.timestamp
+ } == (gapFill ? [0L, 60_000L, 120_000L, 180_000L] : [0L, 60_000L, 180_000L])
+ .collect { bucketStart + it }
+ result.collect { it.value } == (gapFill
+ ? [mixedValue, nullBucketValue, null, zeroBucketValue]
+ : [mixedValue, nullBucketValue, zeroBucketValue])
+
+ where:
+ [storage, valueType, gapFill, formula, mixedValue, nullBucketValue, zeroBucketValue] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER], [false, true]].combinations().collectMany { storage, type, gapFill ->
+ [
+ [Formula.MIN, -6G, null, 0G],
+ [Formula.MAX, 12G, null, 0G],
+ [Formula.AVG, 3G, null, 0G],
+ [Formula.SUM, 12G, null, 0G],
+ [Formula.MEDIAN, 3G, null, 0G],
+ // COUNT retains row-count semantics, including stored nulls.
+ [Formula.COUNT, 5G, 2G, 1G]
+ ].collect { [storage, type, gapFill, *it] }
+ }
+ }
}
From 923b46d1e1a1f220550cae3d85a205efa6d58288 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 10:53:39 +0200
Subject: [PATCH 04/15] Fix null handling in boolean datapoint All queries
---
.../query/AssetDatapointAllQuery.java | 6 ++--
.../assets/AssetDatapointNullQueryTest.groovy | 30 +++++++++++++++++++
2 files changed, 34 insertions(+), 2 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
index 03c6188903..e034286592 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
@@ -29,7 +29,8 @@
description =
"Returns every stored datapoint in the inclusive range, ordered from newest to oldest. "
+ "Use this when the complete series is needed without aggregation or downsampling. Supports numeric, "
- + "boolean, text, and JSON-compatible complex attribute values.")
+ + "boolean, text, and JSON-compatible complex attribute values. "
+ + "Boolean values are returned as 1 for true, 0 for false, and null for unavailable values.")
public class AssetDatapointAllQuery extends AssetDatapointQuery {
public AssetDatapointAllQuery() {}
@@ -55,7 +56,8 @@ public String getSQLQuery(String tableName, Class> attributeType) throws Illeg
+ tableName
+ conditionSuffix;
} else if (isBoolean) {
- return "select timestamp as X, (case when cast(cast(value as text) as boolean) is true then 1 else 0 end) as Y from "
+ // Preserve unavailable values instead of treating SQL NULL as false.
+ return "select timestamp as X, (case cast(cast(nullif(value, 'null'::jsonb) as text) as boolean) when true then 1 when false then 0 else null end) as Y from "
+ tableName
+ conditionSuffix;
} else {
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index d53e53d57f..418472ca38 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -97,6 +97,36 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
}
}
+ def "boolean All query preserves #description for #storage datapoints"() {
+ given: "boolean datapoints containing explicitly stored JSON nulls"
+ asset = assetStorageService.merge(new ThingAsset("Boolean null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", ValueType.BOOLEAN)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def timestamps = (0..
+ new ValueDatapoint<>(timestamps[index], value)
+ }
+ datapoints.add(new ValueDatapoint<>(timestamps.first() - 60_000L, true))
+ datapoints.add(new ValueDatapoint<>(timestamps.last() + 60_000L, false))
+ service.upsertValues(asset.id, "value", datapoints)
+
+ when: "the inclusive range is queried without aggregation"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointAllQuery(timestamps.first(), timestamps.last()))
+
+ then: "true, false, and unavailable remain distinct at their original timestamps"
+ result.collect { it.timestamp } == timestamps.reverse()
+ result.collect { it.value } == expectedValues.reverse()
+
+ where:
+ [storage, description, values, expectedValues] << ["historical", "predicted"].collectMany { storage ->
+ [
+ [storage, "mixed values", [true, false, null], [1D, 0D, null]],
+ [storage, "an entirely null range", [null, null, null], [null, null, null]]
+ ]
+ }
+ }
+
def "numeric Nearest query preserves unavailable values for #storage #valueType.name datapoints"() {
given: "an unavailable datapoint between older and newer available prices"
asset = assetStorageService.merge(new ThingAsset("Nearest null query test")
From fedca78a63cfabb294fa3b12d9d89449a27ed425 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 11:03:12 +0200
Subject: [PATCH 05/15] Fix null handling in boolean interval aggregates
---
.../query/AssetDatapointIntervalQuery.java | 18 +++++--
.../assets/AssetDatapointNullQueryTest.groovy | 50 +++++++++++++++++++
2 files changed, 63 insertions(+), 5 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index 2a4fb05732..75f2057139 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -57,7 +57,7 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
+ "value; `DIFFERENCE` calculates the change between the last values of consecutive buckets; "
+ "`COUNT` counts datapoints; `SUM` totals numeric values (or true values for booleans); `MODE` "
+ "selects the most frequent value; and `MEDIAN` selects the middle value. `DIFFERENCE` is numeric "
- + "only. Numeric MIN, AVG, MAX, SUM and MEDIAN ignore null values and return null "
+ + "only. For numeric and boolean attributes, MIN, AVG, MAX, SUM and MEDIAN ignore null values and return null "
+ "for buckets with no available values.",
requiredMode = Schema.RequiredMode.REQUIRED)
public Formula formula;
@@ -189,6 +189,9 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
}
protected String getSQLQueryForBooleans(String tableName, String function) {
+ // Keep unavailable values null so aggregates ignore them instead of counting them as false.
+ String booleanAsNumber =
+ "CASE cast(nullif(value, 'null'::jsonb) as text)::boolean WHEN true THEN 1 WHEN false THEN 0 ELSE NULL END";
switch (this.formula) {
case DIFFERENCE:
throw new IllegalStateException(
@@ -207,7 +210,9 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + "SUM(CASE WHEN cast(value as text)::boolean THEN 1 ELSE 0 END) AS true_count_sum "
+ + "SUM("
+ + booleanAsNumber
+ + ") AS true_count_sum "
+ "FROM "
+ tableName
+ " "
@@ -240,8 +245,9 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + "CASE WHEN PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY (CASE WHEN cast(value as text)::boolean THEN 1 ELSE 0 END)) >= 0.5 "
- + "THEN true ELSE false END AS median_value "
+ + "(PERCENTILE_CONT(0.5) WITHIN GROUP (ORDER BY ("
+ + booleanAsNumber
+ + ")) >= 0.5)::integer AS median_value "
+ "FROM "
+ tableName
+ " "
@@ -252,7 +258,9 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
+ function
+ "(cast(? as interval), timestamp) AS x, "
+ this.formula.toString().toLowerCase()
- + "(case when cast(cast(value as text) as boolean) is true then 1 else 0 end) FROM "
+ + "("
+ + booleanAsNumber
+ + ") FROM "
+ tableName
+ " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? GROUP BY x ORDER by x ASC";
}
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 418472ca38..7b70b404bf 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -208,4 +208,54 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
].collect { [storage, type, gapFill, *it] }
}
}
+
+ def "boolean #formula handles unavailable values for #storage with gapFill=#gapFill"() {
+ given: "mixed, entirely null, empty, false, and true minute buckets"
+ asset = assetStorageService.merge(new ThingAsset("Boolean interval null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", ValueType.BOOLEAN)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def bucketStart = Instant.ofEpochMilli(start).truncatedTo(ChronoUnit.MINUTES).toEpochMilli()
+ service.upsertValues(asset.id, "value", [
+ [-1_000L, true],
+ [0L, true],
+ [10_000L, false],
+ [20_000L, null],
+ [30_000L, null],
+ [40_000L, null],
+ [70_000L, null],
+ [100_000L, null],
+ [190_000L, false],
+ [200_000L, null],
+ [250_000L, true],
+ [260_000L, null],
+ [300_000L, false]
+ ].collect { new ValueDatapoint<>(bucketStart + it[0], it[1]) })
+
+ when: "the five-minute range is aggregated"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart, bucketStart + 299_999L,
+ "1 minute", formula, gapFill))
+
+ then: "unavailable values are distinct from false and empty buckets follow the gap-fill setting"
+ result.collect { it.timestamp } == (gapFill
+ ? [0L, 60_000L, 120_000L, 180_000L, 240_000L]
+ : [0L, 60_000L, 180_000L, 240_000L]).collect { bucketStart + it }
+ result.collect { it.value } == (gapFill
+ ? [mixedValue, nullBucketValue, null, falseBucketValue, trueBucketValue]
+ : [mixedValue, nullBucketValue, falseBucketValue, trueBucketValue])
+
+ where:
+ [storage, gapFill, formula, mixedValue, nullBucketValue, falseBucketValue, trueBucketValue] << [["historical", "predicted"], [false, true]].combinations().collectMany { storage, gapFill ->
+ [
+ [Formula.MIN, 0D, null, 0D, 1D],
+ [Formula.MAX, 1D, null, 0D, 1D],
+ [Formula.AVG, 0.5D, null, 0D, 1D],
+ [Formula.SUM, 1D, null, 0D, 1D],
+ // MEDIAN retains the existing true result when available values are tied.
+ [Formula.MEDIAN, 1D, null, 0D, 1D],
+ // COUNT retains row-count semantics, including stored nulls.
+ [Formula.COUNT, 5D, 2D, 2D, 2D]
+ ].collect { [storage, gapFill, *it] }
+ }
+ }
}
From 3133168d68783cf291a621f4958a0b55bf616168 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 11:50:02 +0200
Subject: [PATCH 06/15] Fix null handling in numeric and boolean MODE queries
---
.../query/AssetDatapointIntervalQuery.java | 12 ++---
.../assets/AssetDatapointNullQueryTest.groovy | 52 +++++++++++++++++++
2 files changed, 58 insertions(+), 6 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index 75f2057139..41ebb1ec5b 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -57,7 +57,7 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
+ "value; `DIFFERENCE` calculates the change between the last values of consecutive buckets; "
+ "`COUNT` counts datapoints; `SUM` totals numeric values (or true values for booleans); `MODE` "
+ "selects the most frequent value; and `MEDIAN` selects the middle value. `DIFFERENCE` is numeric "
- + "only. For numeric and boolean attributes, MIN, AVG, MAX, SUM and MEDIAN ignore null values and return null "
+ + "only. For numeric and boolean attributes, MIN, AVG, MAX, SUM, MODE and MEDIAN ignore null values and return null "
+ "for buckets with no available values.",
requiredMode = Schema.RequiredMode.REQUIRED)
public Formula formula;
@@ -149,7 +149,7 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
+ " SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + " cast(value as numeric) AS num_value "
+ + " cast(nullif(value, 'null'::jsonb) as numeric) AS num_value "
+ " FROM "
+ tableName
+ " "
@@ -159,7 +159,7 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
+ " SELECT num_value FROM ( "
+ " SELECT num_value, COUNT(*) AS freq "
+ " FROM bucketed b2 "
- + " WHERE b2.x = d.x "
+ + " WHERE b2.x = d.x AND num_value IS NOT NULL "
+ " GROUP BY num_value "
+ " ORDER BY freq DESC, num_value "
+ " LIMIT 1 "
@@ -223,17 +223,17 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
+ " SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + " cast(value as text)::boolean AS bool_value "
+ + " cast(nullif(value, 'null'::jsonb) as text)::boolean AS bool_value "
+ " FROM "
+ tableName
+ " "
+ " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ ") "
+ "SELECT d.x, ( "
- + " SELECT bool_value FROM ( "
+ + " SELECT bool_value::integer FROM ( "
+ " SELECT bool_value, COUNT(*) AS freq "
+ " FROM bucketed b2 "
- + " WHERE b2.x = d.x "
+ + " WHERE b2.x = d.x AND bool_value IS NOT NULL "
+ " GROUP BY bool_value "
+ " ORDER BY freq DESC, bool_value DESC "
+ " LIMIT 1 "
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 7b70b404bf..0999c24e89 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -258,4 +258,56 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
].collect { [storage, gapFill, *it] }
}
}
+
+ def "MODE ignores unavailable values for #storage #valueType.name with gapFill=#gapFill"() {
+ given: "nulls outnumber available values, followed by null-only, empty, tied, zero, and high-value buckets"
+ asset = assetStorageService.merge(new ThingAsset("Mode null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def bucketStart = Instant.ofEpochMilli(start).truncatedTo(ChronoUnit.MINUTES).toEpochMilli()
+ def buckets = [
+ [low, low, high, null, null, null],
+ [null, null],
+ [],
+ [low, high, null],
+ [zero, null],
+ [high, high, low, null]
+ ]
+ def datapoints = buckets.withIndex().collectMany { values, bucket ->
+ values.withIndex().collect { value, index ->
+ new ValueDatapoint<>(bucketStart + bucket * 60_000L + index * 1_000L, value)
+ }
+ }
+ datapoints.add(new ValueDatapoint<>(bucketStart - 1_000L, high))
+ datapoints.add(new ValueDatapoint<>(bucketStart + 360_000L, low))
+ service.upsertValues(asset.id, "value", datapoints)
+
+ when: "the six-minute range is queried for its most frequent available values"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart, bucketStart + 359_999L,
+ "1 minute", Formula.MODE, gapFill))
+
+ then: "nulls cannot win and existing numeric and boolean tie-breaking is preserved"
+ // MODE currently omits empty buckets even with gapFill=true; fixing that is a separate step.
+ result.collect { it.timestamp } == [0L, 60_000L, 180_000L, 240_000L, 300_000L]
+ .collect { bucketStart + it }
+ result.collect { it.value } == [lowResult, null, tieResult, 0D, highResult]
+
+ when: "only the entirely unavailable bucket is queried"
+ def unavailable = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart + 60_000L, bucketStart + 119_999L,
+ "1 minute", Formula.MODE, gapFill))
+
+ then: "the bucket and its null result remain present"
+ unavailable.collect { [it.timestamp, it.value] } == [[bucketStart + 60_000L, null]]
+
+ where:
+ [storage, gapFill, valueType, low, high, zero, lowResult, highResult, tieResult] << [["historical", "predicted"], [false, true]].combinations().collectMany { storage, gapFill ->
+ [
+ [ValueType.NUMBER, -5G, 7G, 0G, -5D, 7D, -5D],
+ [ValueType.BIG_NUMBER, -5G, 7G, 0G, -5D, 7D, -5D],
+ [ValueType.BOOLEAN, false, true, false, 0D, 1D, 1D]
+ ].collect { [storage, gapFill, *it] }
+ }
+ }
}
From 70412b4d1c833498c1777098c12ca583d17754c2 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 12:16:57 +0200
Subject: [PATCH 07/15] Fix: honor gap filling in MODE datapoint queries
Replace correlated frequency queries with grouped MODE aggregates.
Preserve null handling and tie-breaking, and cover empty ranges and leading/trailing gaps with regression tests.
BREAKING CHANGE: MODE with gapFill=true now includes empty buckets as null-valued results across the inclusive query range.
Results may contain more rows and exceed the configured datapoint query limit.
Use gapFill=false to retain populated-bucket-only results.
---
.../query/AssetDatapointIntervalQuery.java | 48 +++++--------------
.../assets/AssetDatapointNullQueryTest.groovy | 34 ++++++++++---
.../assets/AssetDatapointQueryTest.groovy | 8 +++-
3 files changed, 45 insertions(+), 45 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index 41ebb1ec5b..24aa7c9314 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -58,7 +58,7 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
+ "`COUNT` counts datapoints; `SUM` totals numeric values (or true values for booleans); `MODE` "
+ "selects the most frequent value; and `MEDIAN` selects the middle value. `DIFFERENCE` is numeric "
+ "only. For numeric and boolean attributes, MIN, AVG, MAX, SUM, MODE and MEDIAN ignore null values and return null "
- + "for buckets with no available values.",
+ + "for buckets with no available values. MODE ties select the smaller numeric value or true for booleans.",
requiredMode = Schema.RequiredMode.REQUIRED)
public Formula formula;
@@ -145,28 +145,15 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
- return "WITH bucketed AS ( "
- + " SELECT "
+ return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + " cast(nullif(value, 'null'::jsonb) as numeric) AS num_value "
- + " FROM "
+ + "MODE() WITHIN GROUP (ORDER BY cast(nullif(value, 'null'::jsonb) as numeric) ASC) AS mode_value "
+ + "FROM "
+ tableName
+ " "
- + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
- + ") "
- + "SELECT d.x, ( "
- + " SELECT num_value FROM ( "
- + " SELECT num_value, COUNT(*) AS freq "
- + " FROM bucketed b2 "
- + " WHERE b2.x = d.x AND num_value IS NOT NULL "
- + " GROUP BY num_value "
- + " ORDER BY freq DESC, num_value "
- + " LIMIT 1 "
- + " ) AS mode_sub "
- + ") AS mode_value "
- + "FROM (SELECT DISTINCT x FROM bucketed) d "
- + "ORDER BY d.x ASC";
+ + "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ + "GROUP BY x ORDER by x ASC";
case MEDIAN:
return "SELECT "
+ function
@@ -219,28 +206,15 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
- return "WITH bucketed AS ( "
- + " SELECT "
+ return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + " cast(nullif(value, 'null'::jsonb) as text)::boolean AS bool_value "
- + " FROM "
+ + "MODE() WITHIN GROUP (ORDER BY cast(nullif(value, 'null'::jsonb) as text)::boolean DESC)::integer AS mode_value "
+ + "FROM "
+ tableName
+ " "
- + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
- + ") "
- + "SELECT d.x, ( "
- + " SELECT bool_value::integer FROM ( "
- + " SELECT bool_value, COUNT(*) AS freq "
- + " FROM bucketed b2 "
- + " WHERE b2.x = d.x AND bool_value IS NOT NULL "
- + " GROUP BY bool_value "
- + " ORDER BY freq DESC, bool_value DESC "
- + " LIMIT 1 "
- + " ) AS mode_sub "
- + ") AS mode_value "
- + "FROM (SELECT DISTINCT x FROM bucketed) d "
- + "ORDER BY d.x ASC";
+ + "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ + "GROUP BY x ORDER by x ASC";
case MEDIAN:
return "SELECT "
+ function
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 0999c24e89..06b9c36c03 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -278,8 +278,8 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
new ValueDatapoint<>(bucketStart + bucket * 60_000L + index * 1_000L, value)
}
}
- datapoints.add(new ValueDatapoint<>(bucketStart - 1_000L, high))
- datapoints.add(new ValueDatapoint<>(bucketStart + 360_000L, low))
+ datapoints.add(new ValueDatapoint<>(bucketStart - 61_000L, high))
+ datapoints.add(new ValueDatapoint<>(bucketStart + 420_000L, low))
service.upsertValues(asset.id, "value", datapoints)
when: "the six-minute range is queried for its most frequent available values"
@@ -288,10 +288,32 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
"1 minute", Formula.MODE, gapFill))
then: "nulls cannot win and existing numeric and boolean tie-breaking is preserved"
- // MODE currently omits empty buckets even with gapFill=true; fixing that is a separate step.
- result.collect { it.timestamp } == [0L, 60_000L, 180_000L, 240_000L, 300_000L]
- .collect { bucketStart + it }
- result.collect { it.value } == [lowResult, null, tieResult, 0D, highResult]
+ result.collect { it.timestamp } == (gapFill
+ ? [0L, 60_000L, 120_000L, 180_000L, 240_000L, 300_000L]
+ : [0L, 60_000L, 180_000L, 240_000L, 300_000L]).collect { bucketStart + it }
+ result.collect { it.value } == (gapFill
+ ? [lowResult, null, null, tieResult, 0D, highResult]
+ : [lowResult, null, tieResult, 0D, highResult])
+
+ when: "the range includes empty leading and trailing buckets"
+ def extended = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart - 60_000L, bucketStart + 419_999L,
+ "1 minute", Formula.MODE, gapFill))
+
+ then: "gap filling covers the requested range rather than just the observed timestamps"
+ extended.collect { [it.timestamp, it.value] } == (gapFill
+ ? [[bucketStart - 60_000L, null]] + result.collect {
+ [it.timestamp, it.value]
+ } + [[bucketStart + 360_000L, null]]
+ : result.collect { [it.timestamp, it.value] })
+
+ when: "only the empty bucket is queried"
+ def empty = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart + 120_000L, bucketStart + 179_999L,
+ "1 minute", Formula.MODE, gapFill))
+
+ then: "an entirely empty range also follows the gap-fill setting"
+ empty.collect { [it.timestamp, it.value] } == (gapFill ? [[bucketStart + 120_000L, null]] : [])
when: "only the entirely unavailable bucket is queried"
def unavailable = service.queryDatapoints(asset.id, "value",
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
index 79cd81f6ec..5f43f910a5 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
@@ -444,19 +444,21 @@ class AssetDatapointQueryTest extends Specification implements ManagerContainerT
)
then: "MODE datapoints should be correct"
- assert modeDatapoints1.size() == 6
+ assert modeDatapoints1.size() == 7 // Include the empty bucket at the inclusive end timestamp.
assert modeDatapoints1[0].timestamp == time30.toEpochMilli()
assert modeDatapoints1[1].timestamp == time30.plus(5, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[2].timestamp == time30.plus(10, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[3].timestamp == time30.plus(15, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[4].timestamp == time30.plus(20, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[5].timestamp == time30.plus(25, ChronoUnit.MINUTES).toEpochMilli()
+ assert modeDatapoints1[6].timestamp == timeNow.toEpochMilli()
assert modeDatapoints1[0].value == 10
assert modeDatapoints1[1].value == 15
assert modeDatapoints1[2].value == 90
assert modeDatapoints1[3].value == 20
assert modeDatapoints1[4].value == 25
assert modeDatapoints1[5].value == 30
+ assert modeDatapoints1[6].value == null
when: "requesting interval datapoints using MEDIAN"
def medianDatapoints1 = assetDatapointService.queryDatapoints(
@@ -489,9 +491,11 @@ class AssetDatapointQueryTest extends Specification implements ManagerContainerT
)
then: "MODE datapoints should return 90, because it is most common within the interval"
- assert modeDatapoints2.size() == 1
+ assert modeDatapoints2.size() == 2 // Include the empty bucket at the inclusive end timestamp.
assert modeDatapoints2[0].timestamp == time60.toEpochMilli()
assert modeDatapoints2[0].value == 90
+ assert modeDatapoints2[1].timestamp == timeNow.toEpochMilli()
+ assert modeDatapoints2[1].value == null
}
def "All query should return the correct data when the maximum is respected"() {
From 69b56f682dd6d8e3c3c4224986837d622ec7e9f5 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 12:27:35 +0200
Subject: [PATCH 08/15] Fix: preserve unavailable values in DIFFERENCE queries
Normalize JSON nulls before casting and remove the fallback that replaced unavailable differences with absolute readings.
Document carry-forward semantics and add regression coverage.
BREAKING CHANGE: With gap filling enabled, the first reading after a leading empty bucket now yields null instead of its absolute value as the difference.
---
.../query/AssetDatapointIntervalQuery.java | 10 ++-
.../assets/AssetDatapointNullQueryTest.groovy | 63 +++++++++++++++++++
2 files changed, 70 insertions(+), 3 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index 24aa7c9314..f8a6a5a55b 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -58,7 +58,11 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
+ "`COUNT` counts datapoints; `SUM` totals numeric values (or true values for booleans); `MODE` "
+ "selects the most frequent value; and `MEDIAN` selects the middle value. `DIFFERENCE` is numeric "
+ "only. For numeric and boolean attributes, MIN, AVG, MAX, SUM, MODE and MEDIAN ignore null values and return null "
- + "for buckets with no available values. MODE ties select the smaller numeric value or true for booleans.",
+ + "for buckets with no available values. MODE ties select the smaller numeric value or true for booleans. "
+ + "DIFFERENCE uses the last stored value per bucket. With gap filling enabled, empty buckets carry "
+ + "the previous bucket value, while explicit null values interrupt carry-forward. A difference is "
+ + "null if either endpoint is unavailable. The first returned bucket has a difference of zero "
+ + "if its value is available; observations outside the query range are not used.",
requiredMode = Schema.RequiredMode.REQUIRED)
public Formula formula;
@@ -118,12 +122,12 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
return "WITH interval_data AS ("
+ "SELECT "
+ function
- + "(cast(? as interval), timestamp) AS x, public.locf(public.last(value::DOUBLE PRECISION, timestamp)) AS numeric_value "
+ + "(cast(? as interval), timestamp) AS x, public.locf(public.last(nullif(value, 'null'::jsonb)::DOUBLE PRECISION, timestamp)) AS numeric_value "
+ "FROM "
+ tableName
+ " "
+ "WHERE ENTITY_ID = ? AND ATTRIBUTE_NAME = ? AND TIMESTAMP >= ? AND TIMESTAMP <= ? GROUP BY x )"
- + "SELECT x, COALESCE(numeric_value - LAG(numeric_value, 1, numeric_value) OVER (ORDER BY x), numeric_value) AS delta FROM interval_data ORDER BY x ASC";
+ + "SELECT x, numeric_value - LAG(numeric_value, 1, numeric_value) OVER (ORDER BY x) AS delta FROM interval_data ORDER BY x ASC";
case COUNT:
return "SELECT "
+ function
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 06b9c36c03..4a9131051a 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -332,4 +332,67 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
].collect { [storage, gapFill, *it] }
}
}
+
+ def "DIFFERENCE preserves unavailable values for #storage #valueType.name with gapFill=#gapFill"() {
+ given: "last values, explicit unavailability, recovery, and empty minute buckets"
+ asset = assetStorageService.merge(new ThingAsset("Difference null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def bucketStart = Instant.ofEpochMilli(start).truncatedTo(ChronoUnit.MINUTES).toEpochMilli()
+ service.upsertValues(asset.id, "value", [
+ [-61_000L, 999G],
+ [0L, 75G],
+ [10_000L, 80G],
+ [120_000L, 90G],
+ [130_000L, null],
+ [240_000L, null],
+ [250_000L, 100G],
+ [300_000L, 110G],
+ [360_000L, 0G],
+ [420_000L, -5G],
+ [540_000L, -10G],
+ [660_000L, 999G]
+ ].collect { new ValueDatapoint<>(bucketStart + it[0], it[1]) })
+
+ when: "the eleven-minute range is queried for changes between bucket values"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart, bucketStart + 659_999L,
+ "1 minute", Formula.DIFFERENCE, gapFill))
+
+ then: "explicit nulls interrupt differences while empty buckets carry forward when enabled"
+ result.collect { it.timestamp } == (gapFill
+ ? (0..10).toList() : [0, 2, 4, 5, 6, 7, 9]).collect { bucketStart + it * 60_000L }
+ result.collect { it.value } == (gapFill
+ ? [0D, 0D, null, null, null, 10D, -110D, -5D, 0D, -5D, 0D]
+ : [0D, null, null, 10D, -110D, -5D, -5D])
+
+ when: "the query starts with an empty bucket before the first available value"
+ def leadingGap = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart - 60_000L, bucketStart + 119_999L,
+ "1 minute", Formula.DIFFERENCE, gapFill))
+
+ then: "an unavailable predecessor does not become an assumed zero baseline"
+ leadingGap.collect { [it.timestamp, it.value] } == (gapFill
+ ? [[bucketStart - 60_000L, null], [bucketStart, null], [bucketStart + 60_000L, 0D]]
+ : [[bucketStart, 0D]])
+
+ when: "only an empty bucket is requested"
+ def empty = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart + 60_000L, bucketStart + 119_999L,
+ "1 minute", Formula.DIFFERENCE, gapFill))
+
+ then: "carry-forward does not read observations outside the query range"
+ empty.collect { [it.timestamp, it.value] } == (gapFill ? [[bucketStart + 60_000L, null]] : [])
+
+ when: "only explicitly unavailable observations are requested"
+ def unavailable = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart + 130_000L, bucketStart + 179_999L,
+ "1 minute", Formula.DIFFERENCE, gapFill))
+
+ then: "the initial difference stays unavailable"
+ unavailable.collect { [it.timestamp, it.value] } == [[bucketStart + 120_000L, null]]
+
+ where:
+ [storage, valueType, gapFill] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER], [false, true]].combinations()
+ }
}
From e38cb9f49f800a98da285548d90fea5fb51bc888 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 14:08:09 +0200
Subject: [PATCH 09/15] Fix null handling in numeric and boolean LTTB queries
---
.../query/AssetDatapointLTTBQuery.java | 10 +--
.../assets/AssetDatapointNullQueryTest.groovy | 72 +++++++++++++++++++
2 files changed, 78 insertions(+), 4 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointLTTBQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointLTTBQuery.java
index 265a1767a2..58b0fb759d 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointLTTBQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointLTTBQuery.java
@@ -30,13 +30,15 @@
"Downsamples numeric or boolean datapoints from the inclusive range with the "
+ "Largest-Triangle-Three-Buckets (LTTB) algorithm while preserving the series' visual shape. Use this "
+ "to reduce a large series for charts. Results are chronological; boolean values are processed as "
- + "1 for true and 0 for false. Other attribute value types are rejected.")
+ + "1 for true and 0 for false. Null-valued observations are omitted before downsampling, so their "
+ + "timestamps are not returned as gap markers. A range with no available values returns no points. "
+ + "Other attribute value types are rejected.")
public final class AssetDatapointLTTBQuery extends AssetDatapointQuery {
@Schema(
description =
"Requested maximum number of representative datapoints after downsampling. Fewer "
- + "points are returned when the range contains fewer datapoints.",
+ + "points are returned when the range contains fewer available datapoints.",
example = "500",
requiredMode = Schema.RequiredMode.REQUIRED)
public int amountOfPoints;
@@ -60,11 +62,11 @@ public String getSQLQuery(String tableName, Class> attributeType) throws Illeg
boolean isNumber = Number.class.isAssignableFrom(attributeType);
boolean isBoolean = Boolean.class.isAssignableFrom(attributeType);
if (isNumber) {
- return "select * from public.unnest((select public.lttb(cast(timestamp as timestamptz), cast(value as double precision), ?) from "
+ return "select * from public.unnest((select public.lttb(cast(timestamp as timestamptz), cast(nullif(value, 'null'::jsonb) as double precision), ?) from "
+ tableName
+ " where ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ?))";
} else if (isBoolean) {
- return "select * from public.unnest((select public.lttb(cast(timestamp as timestamptz), (case when cast(cast(value as text) as boolean) is true then 1 else 0 end), ?) from "
+ return "select * from public.unnest((select public.lttb(cast(timestamp as timestamptz), (case cast(cast(nullif(value, 'null'::jsonb) as text) as boolean) when true then 1 when false then 0 else null end), ?) from "
+ tableName
+ " where ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ?))";
} else {
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 4a9131051a..4a4ecb83b7 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -28,6 +28,7 @@ import org.openremote.model.datapoint.ValueDatapoint
import org.openremote.model.datapoint.query.AssetDatapointAllQuery
import org.openremote.model.datapoint.query.AssetDatapointIntervalQuery
import org.openremote.model.datapoint.query.AssetDatapointIntervalQuery.Formula
+import org.openremote.model.datapoint.query.AssetDatapointLTTBQuery
import org.openremote.model.datapoint.query.AssetDatapointNearestQuery
import org.openremote.model.value.ValueType
import org.openremote.setup.integration.KeycloakTestSetup
@@ -395,4 +396,75 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
where:
[storage, valueType, gapFill] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER], [false, true]].combinations()
}
+
+ def "LTTB omits unavailable values in #description for #storage #valueType.name datapoints"() {
+ given: "datapoints with unavailable observations and out-of-range neighbours"
+ asset = assetStorageService.merge(new ThingAsset("LTTB null query test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def timestamps = (0..
+ new ValueDatapoint<>(timestamps[index], value)
+ }
+ def outsideValue = valueType == ValueType.BOOLEAN ? true : 999G
+ datapoints.add(new ValueDatapoint<>(timestamps.first() - 60_000L, outsideValue))
+ datapoints.add(new ValueDatapoint<>(timestamps.last() + 60_000L, outsideValue))
+ service.upsertValues(asset.id, "value", datapoints)
+
+ when: "the requested resolution exceeds the available observations"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointLTTBQuery(timestamps.first(), timestamps.last(), 10))
+
+ then: "only available observations are returned, chronologically and at their original timestamps"
+ result.collect { [it.timestamp, it.value] } == expected.collect { index, value ->
+ [timestamps[index], value]
+ }
+
+ where:
+ [storage, valueType, description, values, expected] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER, ValueType.BOOLEAN]].combinations().collectMany { storage, type ->
+ def isBoolean = type == ValueType.BOOLEAN
+ [
+ [
+ storage,
+ type,
+ "a mixed range",
+ isBoolean ? [null, true, null, false, true, null, false, null] : [null, -5G, null, 0G, 90G, null, 40G, null],
+ isBoolean ? [[1, 1D], [3, 0D], [4, 1D], [6, 0D]] : [[1, -5D], [3, 0D], [4, 90D], [6, 40D]]
+ ],
+ [storage, type, "an entirely null range", [null, null, null], []],
+ [storage, type, "a single available observation", [null, isBoolean ? false : 0G, null], [[1, 0D]]]
+ ]
+ }
+ }
+
+ def "LTTB downsampling ignores unavailable observations for #storage #valueType.name datapoints"() {
+ given: "the same available series stored with and without null observations"
+ asset = assetStorageService.merge(new ThingAsset("LTTB downsampling null test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType), new Attribute<>("available", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def values = valueType == ValueType.BOOLEAN
+ ? [null, false, true, null, false, true, null, true, false, null, true, false, null]
+ : [null, 0G, 12G, null, -4G, 50G, null, 3G, 0G, null, 9G, 2G, null]
+ def datapoints = values.withIndex().collect { value, index ->
+ new ValueDatapoint<>(start + index * 60_000L, value)
+ }
+ service.upsertValues(asset.id, "value", datapoints)
+ service.upsertValues(asset.id, "available", datapoints.findAll { it.value != null })
+
+ when: "both series are reduced from eight available points to four"
+ def query = new AssetDatapointLTTBQuery(start, start + 720_000L, 4)
+ def result = service.queryDatapoints(asset.id, "value", query)
+ def reference = service.queryDatapoints(asset.id, "available", query)
+
+ then: "null observations do not affect point selection or the first and last available points"
+ result.size() == 4
+ result.collect { [it.timestamp, it.value] } == reference.collect { [it.timestamp, it.value] }
+ result.first().timestamp == start + 60_000L
+ result.last().timestamp == start + 660_000L
+ result.every { it.value != null }
+ result.collect { it.timestamp } == result.collect { it.timestamp }.sort()
+
+ where:
+ [storage, valueType] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER, ValueType.BOOLEAN]].combinations()
+ }
}
From 16758ed38f380cd79378affd65f18d7de8a27427 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 9 Oct 2026 14:15:43 +0200
Subject: [PATCH 10/15] Clarify some doc elements
---
.../model/datapoint/query/AssetDatapointAllQuery.java | 1 +
.../model/datapoint/query/AssetDatapointIntervalQuery.java | 7 +++++--
.../model/datapoint/query/AssetDatapointQuery.java | 6 ++++--
3 files changed, 10 insertions(+), 4 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
index e034286592..bcf6b76581 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointAllQuery.java
@@ -30,6 +30,7 @@
"Returns every stored datapoint in the inclusive range, ordered from newest to oldest. "
+ "Use this when the complete series is needed without aggregation or downsampling. Supports numeric, "
+ "boolean, text, and JSON-compatible complex attribute values. "
+ + "Unavailable values retain their stored timestamps and are returned as null. "
+ "Boolean values are returned as 1 for true, 0 for false, and null for unavailable values.")
public class AssetDatapointAllQuery extends AssetDatapointQuery {
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index f8a6a5a55b..9f7eb8244b 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -55,10 +55,13 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
description =
"Aggregation applied per bucket: `MIN`, `AVG`, and `MAX` calculate the corresponding "
+ "value; `DIFFERENCE` calculates the change between the last values of consecutive buckets; "
- + "`COUNT` counts datapoints; `SUM` totals numeric values (or true values for booleans); `MODE` "
+ + "`COUNT` counts stored datapoints, including null-valued rows; `SUM` totals numeric values "
+ + "(or true values for booleans); `MODE` "
+ "selects the most frequent value; and `MEDIAN` selects the middle value. `DIFFERENCE` is numeric "
+ "only. For numeric and boolean attributes, MIN, AVG, MAX, SUM, MODE and MEDIAN ignore null values and return null "
- + "for buckets with no available values. MODE ties select the smaller numeric value or true for booleans. "
+ + "for buckets with no available values. Empty gap-filled buckets return null for COUNT. "
+ + "MODE ties select the smaller numeric value or true for booleans. Boolean MEDIAN returns 1 "
+ + "when the median of available 1/0 values is at least 0.5, otherwise 0; it returns null when unavailable. "
+ "DIFFERENCE uses the last stored value per bucket. With gap filling enabled, empty buckets carry "
+ "the previous bucket value, while explicit null values interrupt carry-forward. A difference is "
+ "null if either endpoint is unavailable. The first returned bucket has a difference of zero "
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointQuery.java
index 1d22cf9d69..3869fdef35 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointQuery.java
@@ -40,9 +40,11 @@
defaultImpl = AssetDatapointAllQuery.class)
@Schema(
description =
- "Polymorphic historical-datapoint query. Select `all` to return every value, "
+ "Polymorphic historical or predicted datapoint query. Select `all` to return every value, "
+ "`interval` to aggregate values into time buckets, `lttb` to downsample a numeric "
- + "or boolean series, or `nearest` to retrieve the last value at or before a time.",
+ + "or boolean series, or `nearest` to retrieve the last numeric value at or before a time. "
+ + "Returned datapoints use x for the timestamp and y for the value. When a query retains an "
+ + "unavailable datapoint, its JSON representation contains x but omits y.",
discriminatorProperty = "type",
discriminatorMapping = {
@DiscriminatorMapping(value = "all", schema = AssetDatapointAllQuery.class),
From b3503652f449fb6ee15338b82d6372628f07fd11 Mon Sep 17 00:00:00 2001
From: Eric Bariaux
Date: Fri, 23 Oct 2026 15:49:07 +0200
Subject: [PATCH 11/15] Fix pseudo-clock changing the JVM default time zone
---
.../container/timer/TimerService.java | 5 +--
.../container/timer/PseudoClockTest.groovy | 45 +++++++++++++++++++
.../assets/AssetDatapointNullQueryTest.groovy | 2 -
.../ResidenceNotifyAlarmTriggerTest.groovy | 14 +++---
4 files changed, 51 insertions(+), 15 deletions(-)
diff --git a/container/src/main/java/org/openremote/container/timer/TimerService.java b/container/src/main/java/org/openremote/container/timer/TimerService.java
index f84840baab..561b3e8476 100644
--- a/container/src/main/java/org/openremote/container/timer/TimerService.java
+++ b/container/src/main/java/org/openremote/container/timer/TimerService.java
@@ -22,7 +22,6 @@
import java.time.*;
import java.time.temporal.Temporal;
-import java.util.TimeZone;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Logger;
@@ -83,13 +82,11 @@ public void reset() {
PSEUDO {
protected AtomicLong offset = new AtomicLong();
protected volatile Long stopTime;
- // Explicitly define a UTC Clock
+ // Use UTC internally without changing the JVM's default time zone.
protected java.time.Clock utcClock = java.time.Clock.systemUTC();
@Override
public void init() {
- // Force JVM timezone to UTC for legacy date conversions to avoid DST issues in tests
- TimeZone.setDefault(TimeZone.getTimeZone("UTC"));
long current = getCurrentTimeMillis();
LOG.info("Initialized pseudo clock to: " + current + " / " + Instant.ofEpochMilli(current));
}
diff --git a/container/src/test/groovy/org/openremote/container/timer/PseudoClockTest.groovy b/container/src/test/groovy/org/openremote/container/timer/PseudoClockTest.groovy
index 1bc52a8054..0d60e599a6 100644
--- a/container/src/test/groovy/org/openremote/container/timer/PseudoClockTest.groovy
+++ b/container/src/test/groovy/org/openremote/container/timer/PseudoClockTest.groovy
@@ -23,17 +23,62 @@ import spock.lang.Specification
import java.time.LocalDate
import java.time.LocalTime
import java.time.ZoneId
+import java.util.concurrent.TimeUnit
class PseudoClockTest extends Specification {
TimerService.Clock clock
+ TimeZone originalTimeZone
def setup() {
+ originalTimeZone = TimeZone.getDefault()
clock = TimerService.Clock.PSEUDO
+ clock.reset()
clock.init()
clock.stop()
}
+ def cleanup() {
+ try {
+ clock.reset()
+ } finally {
+ TimeZone.setDefault(originalTimeZone)
+ }
+ }
+
+ def "initializing the pseudo clock preserves the JVM time zone #zone"() {
+ given:
+ def timeZone = TimeZone.getTimeZone(zone)
+ TimeZone.setDefault(timeZone)
+
+ when:
+ clock.init()
+
+ then:
+ TimeZone.getDefault() == timeZone
+
+ where:
+ zone << ["Europe/Brussels", "America/New_York", "Asia/Kathmandu"]
+ }
+
+ def "setTime respects the #hours hour day on #date in Europe/Brussels"() {
+ given:
+ def zone = ZoneId.of("Europe/Brussels")
+ def startDate = LocalDate.parse(date)
+ def start = clock.setTime(startDate, LocalTime.MIDNIGHT, zone)
+
+ when:
+ def end = clock.setTime(startDate.plusDays(1), LocalTime.MIDNIGHT, zone)
+
+ then:
+ end - start == TimeUnit.HOURS.toMillis(hours)
+
+ where:
+ date | hours
+ "2026-03-29" | 23
+ "2026-10-25" | 25
+ }
+
def "setTime sets #date at midnight in #zone to #expected milliseconds"() {
expect:
clock.setTime(LocalDate.parse(date), LocalTime.MIDNIGHT, ZoneId.of(zone)) == expected
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 4a4ecb83b7..5013034ad4 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -48,8 +48,6 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
long start
def setup() {
- // Match the pseudo clock's UTC zone before JDBC connections capture the JVM time zone.
- TimeZone.setDefault(TimeZone.getTimeZone("UTC"))
def container = startContainer(defaultConfig(), defaultServices())
stopPseudoClock()
assetStorageService = container.getService(AssetStorageService.class)
diff --git a/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy b/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy
index e3cd2b459c..9d3e156c6e 100644
--- a/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy
@@ -54,9 +54,6 @@ import spock.lang.Specification
import spock.util.concurrent.PollingConditions
import java.time.Instant
-import java.time.LocalDateTime
-import java.time.ZoneId
-import java.time.temporal.ChronoField
import java.time.temporal.ChronoUnit
import java.util.concurrent.CopyOnWriteArrayList
import java.util.concurrent.TimeUnit
@@ -70,13 +67,12 @@ import static org.openremote.model.util.ValueUtil.parse
class ResidenceNotifyAlarmTriggerTest extends Specification implements ManagerContainerTrait {
def getOccurrenceFromTo(long baseTime, int occurrence) {
- def tomorrowMidnight = Instant.ofEpochMilli(baseTime).truncatedTo(ChronoUnit.DAYS).plus(1, ChronoUnit.DAYS).atZone(ZoneId.systemDefault())
- def from = LocalDateTime.of(tomorrowMidnight.get(ChronoField.YEAR), tomorrowMidnight.get(ChronoField.MONTH_OF_YEAR), tomorrowMidnight.get(ChronoField.DAY_OF_MONTH), 6, 0, 0)
- from = from.plus(occurrence, ChronoUnit.WEEKS)
- def to = from.plus(2, ChronoUnit.HOURS)
+ // CalendarEvent evaluates recurrences in UTC, so expected windows must also stay in UTC across DST.
+ def from = Instant.ofEpochMilli(baseTime).truncatedTo(ChronoUnit.DAYS)
+ .plus(1 + occurrence * 7L, ChronoUnit.DAYS).plus(6, ChronoUnit.HOURS)
return [
- from.atZone(ZoneId.systemDefault()).toDate(),
- to.atZone(ZoneId.systemDefault()).toDate()
+ Date.from(from),
+ Date.from(from.plus(2, ChronoUnit.HOURS))
]
}
From 5a8735fd46bce063cffa28ec1a679f19561e712f Mon Sep 17 00:00:00 2001
From: Eric Bariaux
Date: Fri, 23 Oct 2026 16:07:39 +0200
Subject: [PATCH 12/15] Separate MODE gap filling from the JSON-null fixes
Restore MODE's existing populated-bucket-only behavior for both gapFill
settings, retaining JSON-null handling and numeric/boolean tie-breaking.
Restore the corresponding expectations and document the MODE limitation.
The gap-fill behavior change and its regression coverage will be carried
by bugfix/mode-query-gap-fill as a separate follow-up.
---
.../query/AssetDatapointIntervalQuery.java | 49 ++++++++++++++-----
.../assets/AssetDatapointNullQueryTest.groovy | 34 +++----------
.../assets/AssetDatapointQueryTest.groovy | 8 +--
3 files changed, 46 insertions(+), 45 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index 9f7eb8244b..d5864a20ae 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -47,7 +47,8 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
description =
"When true, returns empty buckets across the requested range using time-bucket gap "
+ "filling; their aggregate is generally null. When false, only buckets containing datapoints are "
- + "returned. Defaults to false.",
+ + "returned. MODE currently returns only buckets containing datapoints regardless of this flag. "
+ + "Defaults to false.",
example = "true")
public boolean gapFill;
@@ -152,15 +153,28 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
- return "SELECT "
+ return "WITH bucketed AS ( "
+ + " SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + "MODE() WITHIN GROUP (ORDER BY cast(nullif(value, 'null'::jsonb) as numeric) ASC) AS mode_value "
- + "FROM "
+ + " cast(nullif(value, 'null'::jsonb) as numeric) AS num_value "
+ + " FROM "
+ tableName
+ " "
- + "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
- + "GROUP BY x ORDER by x ASC";
+ + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ + ") "
+ + "SELECT d.x, ( "
+ + " SELECT num_value FROM ( "
+ + " SELECT num_value, COUNT(*) AS freq "
+ + " FROM bucketed b2 "
+ + " WHERE b2.x = d.x AND num_value IS NOT NULL "
+ + " GROUP BY num_value "
+ + " ORDER BY freq DESC, num_value "
+ + " LIMIT 1 "
+ + " ) AS mode_sub "
+ + ") AS mode_value "
+ + "FROM (SELECT DISTINCT x FROM bucketed) d "
+ + "ORDER BY d.x ASC";
case MEDIAN:
return "SELECT "
+ function
@@ -213,15 +227,28 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
- return "SELECT "
+ return "WITH bucketed AS ( "
+ + " SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + "MODE() WITHIN GROUP (ORDER BY cast(nullif(value, 'null'::jsonb) as text)::boolean DESC)::integer AS mode_value "
- + "FROM "
+ + " cast(nullif(value, 'null'::jsonb) as text)::boolean AS bool_value "
+ + " FROM "
+ tableName
+ " "
- + "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
- + "GROUP BY x ORDER by x ASC";
+ + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ + ") "
+ + "SELECT d.x, ( "
+ + " SELECT bool_value::integer FROM ( "
+ + " SELECT bool_value, COUNT(*) AS freq "
+ + " FROM bucketed b2 "
+ + " WHERE b2.x = d.x AND bool_value IS NOT NULL "
+ + " GROUP BY bool_value "
+ + " ORDER BY freq DESC, bool_value DESC "
+ + " LIMIT 1 "
+ + " ) AS mode_sub "
+ + ") AS mode_value "
+ + "FROM (SELECT DISTINCT x FROM bucketed) d "
+ + "ORDER BY d.x ASC";
case MEDIAN:
return "SELECT "
+ function
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index 5013034ad4..dfeacc9d84 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -277,8 +277,8 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
new ValueDatapoint<>(bucketStart + bucket * 60_000L + index * 1_000L, value)
}
}
- datapoints.add(new ValueDatapoint<>(bucketStart - 61_000L, high))
- datapoints.add(new ValueDatapoint<>(bucketStart + 420_000L, low))
+ datapoints.add(new ValueDatapoint<>(bucketStart - 1_000L, high))
+ datapoints.add(new ValueDatapoint<>(bucketStart + 360_000L, low))
service.upsertValues(asset.id, "value", datapoints)
when: "the six-minute range is queried for its most frequent available values"
@@ -287,32 +287,10 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
"1 minute", Formula.MODE, gapFill))
then: "nulls cannot win and existing numeric and boolean tie-breaking is preserved"
- result.collect { it.timestamp } == (gapFill
- ? [0L, 60_000L, 120_000L, 180_000L, 240_000L, 300_000L]
- : [0L, 60_000L, 180_000L, 240_000L, 300_000L]).collect { bucketStart + it }
- result.collect { it.value } == (gapFill
- ? [lowResult, null, null, tieResult, 0D, highResult]
- : [lowResult, null, tieResult, 0D, highResult])
-
- when: "the range includes empty leading and trailing buckets"
- def extended = service.queryDatapoints(asset.id, "value",
- new AssetDatapointIntervalQuery(bucketStart - 60_000L, bucketStart + 419_999L,
- "1 minute", Formula.MODE, gapFill))
-
- then: "gap filling covers the requested range rather than just the observed timestamps"
- extended.collect { [it.timestamp, it.value] } == (gapFill
- ? [[bucketStart - 60_000L, null]] + result.collect {
- [it.timestamp, it.value]
- } + [[bucketStart + 360_000L, null]]
- : result.collect { [it.timestamp, it.value] })
-
- when: "only the empty bucket is queried"
- def empty = service.queryDatapoints(asset.id, "value",
- new AssetDatapointIntervalQuery(bucketStart + 120_000L, bucketStart + 179_999L,
- "1 minute", Formula.MODE, gapFill))
-
- then: "an entirely empty range also follows the gap-fill setting"
- empty.collect { [it.timestamp, it.value] } == (gapFill ? [[bucketStart + 120_000L, null]] : [])
+ // Preserve existing MODE behavior: empty buckets are omitted for either gapFill setting.
+ result.collect { it.timestamp } == [0L, 60_000L, 180_000L, 240_000L, 300_000L]
+ .collect { bucketStart + it }
+ result.collect { it.value } == [lowResult, null, tieResult, 0D, highResult]
when: "only the entirely unavailable bucket is queried"
def unavailable = service.queryDatapoints(asset.id, "value",
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
index 5f43f910a5..79cd81f6ec 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
@@ -444,21 +444,19 @@ class AssetDatapointQueryTest extends Specification implements ManagerContainerT
)
then: "MODE datapoints should be correct"
- assert modeDatapoints1.size() == 7 // Include the empty bucket at the inclusive end timestamp.
+ assert modeDatapoints1.size() == 6
assert modeDatapoints1[0].timestamp == time30.toEpochMilli()
assert modeDatapoints1[1].timestamp == time30.plus(5, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[2].timestamp == time30.plus(10, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[3].timestamp == time30.plus(15, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[4].timestamp == time30.plus(20, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[5].timestamp == time30.plus(25, ChronoUnit.MINUTES).toEpochMilli()
- assert modeDatapoints1[6].timestamp == timeNow.toEpochMilli()
assert modeDatapoints1[0].value == 10
assert modeDatapoints1[1].value == 15
assert modeDatapoints1[2].value == 90
assert modeDatapoints1[3].value == 20
assert modeDatapoints1[4].value == 25
assert modeDatapoints1[5].value == 30
- assert modeDatapoints1[6].value == null
when: "requesting interval datapoints using MEDIAN"
def medianDatapoints1 = assetDatapointService.queryDatapoints(
@@ -491,11 +489,9 @@ class AssetDatapointQueryTest extends Specification implements ManagerContainerT
)
then: "MODE datapoints should return 90, because it is most common within the interval"
- assert modeDatapoints2.size() == 2 // Include the empty bucket at the inclusive end timestamp.
+ assert modeDatapoints2.size() == 1
assert modeDatapoints2[0].timestamp == time60.toEpochMilli()
assert modeDatapoints2[0].value == 90
- assert modeDatapoints2[1].timestamp == timeNow.toEpochMilli()
- assert modeDatapoints2[1].value == null
}
def "All query should return the correct data when the maximum is respected"() {
From 05dc7c4159af4ff12671c12655f0566503747b44 Mon Sep 17 00:00:00 2001
From: Eric Bariaux
Date: Fri, 23 Oct 2026 16:16:22 +0200
Subject: [PATCH 13/15] Fix MODE queries to include empty buckets when gapFill
is enabled
Use grouped MODE aggregates so time_bucket_gapfill can produce empty
buckets across the inclusive requested range. Preserve JSON-null handling,
numeric and boolean tie-breaking, and populated-only results when disabled.
Migrate the internal, leading/trailing, entirely empty range and inclusive
end-boundary assertions from the JSON-null PR. Add coverage for sparse
queries exceeding the configured result limit after gap filling.
Release note / compatibility:
MODE with gapFill=true now returns null-valued empty buckets. Existing
callers may receive more results and exceed OR_DATA_POINTS_QUERY_LIMIT
(100,000 by default). Use gapFill=false to preserve populated-bucket-only
results, or reduce the range or increase the interval to request fewer
buckets. This applies to historical and predicted numeric/boolean data.
---
.../query/AssetDatapointIntervalQuery.java | 49 +++----------
.../assets/AssetDatapointNullQueryTest.groovy | 72 +++++++++++++++++--
.../assets/AssetDatapointQueryTest.groovy | 8 ++-
3 files changed, 83 insertions(+), 46 deletions(-)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index d5864a20ae..0a2fa2b4f4 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -47,8 +47,7 @@ public class AssetDatapointIntervalQuery extends AssetDatapointQuery {
description =
"When true, returns empty buckets across the requested range using time-bucket gap "
+ "filling; their aggregate is generally null. When false, only buckets containing datapoints are "
- + "returned. MODE currently returns only buckets containing datapoints regardless of this flag. "
- + "Defaults to false.",
+ + "returned. Empty buckets count toward the datapoint query limit. Defaults to false.",
example = "true")
public boolean gapFill;
@@ -153,28 +152,15 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
- return "WITH bucketed AS ( "
- + " SELECT "
+ return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + " cast(nullif(value, 'null'::jsonb) as numeric) AS num_value "
- + " FROM "
+ + "MODE() WITHIN GROUP (ORDER BY cast(nullif(value, 'null'::jsonb) as numeric) ASC) AS mode_value "
+ + "FROM "
+ tableName
+ " "
- + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
- + ") "
- + "SELECT d.x, ( "
- + " SELECT num_value FROM ( "
- + " SELECT num_value, COUNT(*) AS freq "
- + " FROM bucketed b2 "
- + " WHERE b2.x = d.x AND num_value IS NOT NULL "
- + " GROUP BY num_value "
- + " ORDER BY freq DESC, num_value "
- + " LIMIT 1 "
- + " ) AS mode_sub "
- + ") AS mode_value "
- + "FROM (SELECT DISTINCT x FROM bucketed) d "
- + "ORDER BY d.x ASC";
+ + "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ + "GROUP BY x ORDER by x ASC";
case MEDIAN:
return "SELECT "
+ function
@@ -227,28 +213,15 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
- return "WITH bucketed AS ( "
- + " SELECT "
+ return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
- + " cast(nullif(value, 'null'::jsonb) as text)::boolean AS bool_value "
- + " FROM "
+ + "MODE() WITHIN GROUP (ORDER BY cast(nullif(value, 'null'::jsonb) as text)::boolean DESC)::integer AS mode_value "
+ + "FROM "
+ tableName
+ " "
- + " WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
- + ") "
- + "SELECT d.x, ( "
- + " SELECT bool_value::integer FROM ( "
- + " SELECT bool_value, COUNT(*) AS freq "
- + " FROM bucketed b2 "
- + " WHERE b2.x = d.x AND bool_value IS NOT NULL "
- + " GROUP BY bool_value "
- + " ORDER BY freq DESC, bool_value DESC "
- + " LIMIT 1 "
- + " ) AS mode_sub "
- + ") AS mode_value "
- + "FROM (SELECT DISTINCT x FROM bucketed) d "
- + "ORDER BY d.x ASC";
+ + "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ + "GROUP BY x ORDER by x ASC";
case MEDIAN:
return "SELECT "
+ function
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
index dfeacc9d84..164e4f86ee 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointNullQueryTest.groovy
@@ -24,6 +24,7 @@ import org.openremote.manager.datapoint.AssetPredictedDatapointService
import org.openremote.manager.setup.SetupService
import org.openremote.model.asset.impl.ThingAsset
import org.openremote.model.attribute.Attribute
+import org.openremote.model.datapoint.DatapointQueryTooLargeException
import org.openremote.model.datapoint.ValueDatapoint
import org.openremote.model.datapoint.query.AssetDatapointAllQuery
import org.openremote.model.datapoint.query.AssetDatapointIntervalQuery
@@ -277,8 +278,8 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
new ValueDatapoint<>(bucketStart + bucket * 60_000L + index * 1_000L, value)
}
}
- datapoints.add(new ValueDatapoint<>(bucketStart - 1_000L, high))
- datapoints.add(new ValueDatapoint<>(bucketStart + 360_000L, low))
+ datapoints.add(new ValueDatapoint<>(bucketStart - 61_000L, high))
+ datapoints.add(new ValueDatapoint<>(bucketStart + 420_000L, low))
service.upsertValues(asset.id, "value", datapoints)
when: "the six-minute range is queried for its most frequent available values"
@@ -287,10 +288,32 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
"1 minute", Formula.MODE, gapFill))
then: "nulls cannot win and existing numeric and boolean tie-breaking is preserved"
- // Preserve existing MODE behavior: empty buckets are omitted for either gapFill setting.
- result.collect { it.timestamp } == [0L, 60_000L, 180_000L, 240_000L, 300_000L]
- .collect { bucketStart + it }
- result.collect { it.value } == [lowResult, null, tieResult, 0D, highResult]
+ result.collect { it.timestamp } == (gapFill
+ ? [0L, 60_000L, 120_000L, 180_000L, 240_000L, 300_000L]
+ : [0L, 60_000L, 180_000L, 240_000L, 300_000L]).collect { bucketStart + it }
+ result.collect { it.value } == (gapFill
+ ? [lowResult, null, null, tieResult, 0D, highResult]
+ : [lowResult, null, tieResult, 0D, highResult])
+
+ when: "the range includes empty leading and trailing buckets"
+ def extended = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart - 60_000L, bucketStart + 419_999L,
+ "1 minute", Formula.MODE, gapFill))
+
+ then: "gap filling covers the requested range rather than just the observed timestamps"
+ extended.collect { [it.timestamp, it.value] } == (gapFill
+ ? [[bucketStart - 60_000L, null]] + result.collect {
+ [it.timestamp, it.value]
+ } + [[bucketStart + 360_000L, null]]
+ : result.collect { [it.timestamp, it.value] })
+
+ when: "only the empty bucket is queried"
+ def empty = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart + 120_000L, bucketStart + 179_999L,
+ "1 minute", Formula.MODE, gapFill))
+
+ then: "an entirely empty range also follows the gap-fill setting"
+ empty.collect { [it.timestamp, it.value] } == (gapFill ? [[bucketStart + 120_000L, null]] : [])
when: "only the entirely unavailable bucket is queried"
def unavailable = service.queryDatapoints(asset.id, "value",
@@ -310,6 +333,43 @@ class AssetDatapointNullQueryTest extends Specification implements ManagerContai
}
}
+ def "MODE counts gap-filled buckets toward the query limit for #storage #valueType.name"() {
+ given: "a sparse range with more buckets than the query limit"
+ asset = assetStorageService.merge(new ThingAsset("Mode query limit test")
+ .setRealm(realm).addOrReplaceAttributes(new Attribute<>("value", valueType)))
+ def service = storage == "historical" ? historicalService : predictedService
+ def originalLimit = service.maxAmountOfQueryPoints
+ def bucketStart = Instant.ofEpochMilli(start).truncatedTo(ChronoUnit.MINUTES).toEpochMilli()
+ def value = valueType == ValueType.BOOLEAN ? true : 1G
+ service.upsertValues(asset.id, "value", [
+ new ValueDatapoint<>(bucketStart, value),
+ new ValueDatapoint<>(bucketStart + 180_000L, value)
+ ])
+ service.maxAmountOfQueryPoints = 2
+
+ when: "only populated buckets are requested"
+ def result = service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart, bucketStart + 239_999L,
+ "1 minute", Formula.MODE, false))
+
+ then: "the two populated buckets fit within the limit"
+ result.collect { it.timestamp } == [bucketStart, bucketStart + 180_000L]
+
+ when: "gap filling increases the result to four buckets"
+ service.queryDatapoints(asset.id, "value",
+ new AssetDatapointIntervalQuery(bucketStart, bucketStart + 239_999L,
+ "1 minute", Formula.MODE, true))
+
+ then: "the generated empty buckets also count toward the limit"
+ thrown(DatapointQueryTooLargeException)
+
+ cleanup:
+ service.maxAmountOfQueryPoints = originalLimit
+
+ where:
+ [storage, valueType] << [["historical", "predicted"], [ValueType.NUMBER, ValueType.BIG_NUMBER, ValueType.BOOLEAN]].combinations()
+ }
+
def "DIFFERENCE preserves unavailable values for #storage #valueType.name with gapFill=#gapFill"() {
given: "last values, explicit unavailability, recovery, and empty minute buckets"
asset = assetStorageService.merge(new ThingAsset("Difference null query test")
diff --git a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
index 79cd81f6ec..5f43f910a5 100644
--- a/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/assets/AssetDatapointQueryTest.groovy
@@ -444,19 +444,21 @@ class AssetDatapointQueryTest extends Specification implements ManagerContainerT
)
then: "MODE datapoints should be correct"
- assert modeDatapoints1.size() == 6
+ assert modeDatapoints1.size() == 7 // Include the empty bucket at the inclusive end timestamp.
assert modeDatapoints1[0].timestamp == time30.toEpochMilli()
assert modeDatapoints1[1].timestamp == time30.plus(5, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[2].timestamp == time30.plus(10, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[3].timestamp == time30.plus(15, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[4].timestamp == time30.plus(20, ChronoUnit.MINUTES).toEpochMilli()
assert modeDatapoints1[5].timestamp == time30.plus(25, ChronoUnit.MINUTES).toEpochMilli()
+ assert modeDatapoints1[6].timestamp == timeNow.toEpochMilli()
assert modeDatapoints1[0].value == 10
assert modeDatapoints1[1].value == 15
assert modeDatapoints1[2].value == 90
assert modeDatapoints1[3].value == 20
assert modeDatapoints1[4].value == 25
assert modeDatapoints1[5].value == 30
+ assert modeDatapoints1[6].value == null
when: "requesting interval datapoints using MEDIAN"
def medianDatapoints1 = assetDatapointService.queryDatapoints(
@@ -489,9 +491,11 @@ class AssetDatapointQueryTest extends Specification implements ManagerContainerT
)
then: "MODE datapoints should return 90, because it is most common within the interval"
- assert modeDatapoints2.size() == 1
+ assert modeDatapoints2.size() == 2 // Include the empty bucket at the inclusive end timestamp.
assert modeDatapoints2[0].timestamp == time60.toEpochMilli()
assert modeDatapoints2[0].value == 90
+ assert modeDatapoints2[1].timestamp == timeNow.toEpochMilli()
+ assert modeDatapoints2[1].value == null
}
def "All query should return the correct data when the maximum is respected"() {
From 707bd7946278a31fb7424ad3818f0c1a3f089ac1 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Fri, 23 Oct 2026 17:05:19 +0200
Subject: [PATCH 14/15] Document PostgreSQL MODE tie-breaking dependency
---
.../model/datapoint/query/AssetDatapointIntervalQuery.java | 2 ++
1 file changed, 2 insertions(+)
diff --git a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
index 0a2fa2b4f4..0843c285ee 100644
--- a/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
+++ b/model/src/main/java/org/openremote/model/datapoint/query/AssetDatapointIntervalQuery.java
@@ -152,6 +152,7 @@ protected String getSQLQueryForNumbers(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
+ // Relies on PostgreSQL MODE retaining the first tied value in sort order (ASC: smaller).
return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
@@ -213,6 +214,7 @@ protected String getSQLQueryForBooleans(String tableName, String function) {
+ "WHERE ENTITY_ID = ? and ATTRIBUTE_NAME = ? and TIMESTAMP >= ? and TIMESTAMP <= ? "
+ "GROUP BY x ORDER by x ASC";
case MODE:
+ // Relies on PostgreSQL MODE retaining the first tied value in sort order (DESC: true).
return "SELECT "
+ function
+ "(cast(? as interval), timestamp) AS x, "
From 9c56c422f968743207ae8fc4434eab760f1c8e82 Mon Sep 17 00:00:00 2001
From: Eric Bariaux <375613+ebariaux@users.noreply.github.com>
Date: Mon, 26 Oct 2026 13:25:17 +0100
Subject: [PATCH 15/15] Linting
---
.../rules/residence/ResidenceNotifyAlarmTriggerTest.groovy | 5 +----
1 file changed, 1 insertion(+), 4 deletions(-)
diff --git a/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy b/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy
index 9d3e156c6e..e79561aeb8 100644
--- a/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy
+++ b/test/src/test/groovy/org/openremote/test/rules/residence/ResidenceNotifyAlarmTriggerTest.groovy
@@ -70,10 +70,7 @@ class ResidenceNotifyAlarmTriggerTest extends Specification implements ManagerCo
// CalendarEvent evaluates recurrences in UTC, so expected windows must also stay in UTC across DST.
def from = Instant.ofEpochMilli(baseTime).truncatedTo(ChronoUnit.DAYS)
.plus(1 + occurrence * 7L, ChronoUnit.DAYS).plus(6, ChronoUnit.HOURS)
- return [
- Date.from(from),
- Date.from(from.plus(2, ChronoUnit.HOURS))
- ]
+ return [Date.from(from), Date.from(from.plus(2, ChronoUnit.HOURS))]
}
def "Trigger notification when presence is detected and alarm enabled"() {