Skip to content

Commit aefd204

Browse files
feat: add depth range constraint to query filters (#112)
* Initial plan * Add depth range filter to query constraints - Add DepthRangeConstraintResult class to handle min/max depth constraints - Add DepthRangeFilter with custom dialog for depth range input - Add "Depth range" filter to default filters list - Support querying depths between two values, >= min, or <= max - Add unit tests for depth constraints Co-authored-by: kevinsbarnard <[email protected]> * Add additional unit tests for DepthRangeConstraintResult Co-authored-by: kevinsbarnard <[email protected]> --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: kevinsbarnard <[email protected]>
1 parent 4641f00 commit aefd204

3 files changed

Lines changed: 253 additions & 0 deletions

File tree

src/vars_gridview/ui/QueryDialog.py

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
QAbstractItemView,
77
QDialog,
88
QDialogButtonBox,
9+
QDoubleSpinBox,
910
QHBoxLayout,
1011
QInputDialog,
1112
QLineEdit,
@@ -19,6 +20,7 @@
1920
QSpinBox,
2021
QFormLayout,
2122
QCompleter,
23+
QCheckBox,
2224
)
2325
from PyQt6.QtGui import QDragEnterEvent, QDropEvent
2426

@@ -228,6 +230,46 @@ def __str__(self) -> str:
228230
)
229231

230232

233+
class DepthRangeConstraintResult(BaseResult):
234+
"""
235+
A constraint result that creates min/max constraints for depth range.
236+
"""
237+
238+
def __init__(self, min_depth: Optional[float], max_depth: Optional[float]):
239+
self.min_depth = min_depth
240+
self.max_depth = max_depth
241+
242+
@property
243+
def constraints(self) -> Iterable[QueryConstraint]:
244+
if self.min_depth is not None and self.max_depth is not None:
245+
# Both min and max specified - use minmax for between constraint
246+
yield QueryConstraint(
247+
column="depth_meters",
248+
minmax=[self.min_depth, self.max_depth],
249+
)
250+
elif self.min_depth is not None:
251+
# Only min specified - depth >= min
252+
yield QueryConstraint(
253+
column="depth_meters",
254+
min=self.min_depth,
255+
)
256+
elif self.max_depth is not None:
257+
# Only max specified - depth <= max
258+
yield QueryConstraint(
259+
column="depth_meters",
260+
max=self.max_depth,
261+
)
262+
263+
def __str__(self) -> str:
264+
if self.min_depth is not None and self.max_depth is not None:
265+
return f"Depth: {self.min_depth}m - {self.max_depth}m"
266+
elif self.min_depth is not None:
267+
return f"Depth: >= {self.min_depth}m"
268+
elif self.max_depth is not None:
269+
return f"Depth: <= {self.max_depth}m"
270+
return "Depth: (no constraint)"
271+
272+
231273
# FILTER IMPLEMENTATIONS
232274

233275

@@ -355,6 +397,87 @@ def __call__(self) -> Optional[VerifierConstraintResult]:
355397
return VerifierConstraintResult(result.value) if result else None
356398

357399

400+
class DepthRangeFilter(BaseFilter):
401+
"""
402+
A filter that allows the user to specify a depth range constraint.
403+
"""
404+
405+
def __call__(self) -> Optional[DepthRangeConstraintResult]:
406+
# Create a custom dialog for depth range input
407+
dialog = QDialog(self.parent)
408+
dialog.setWindowTitle("Depth Range")
409+
layout = QFormLayout(dialog)
410+
411+
# Min depth input
412+
min_depth_checkbox = QCheckBox("Set minimum depth")
413+
min_depth_spinbox = QDoubleSpinBox()
414+
min_depth_spinbox.setRange(0, 10000)
415+
min_depth_spinbox.setValue(0)
416+
min_depth_spinbox.setSuffix(" m")
417+
min_depth_spinbox.setDecimals(2)
418+
min_depth_spinbox.setEnabled(False)
419+
420+
# Max depth input
421+
max_depth_checkbox = QCheckBox("Set maximum depth")
422+
max_depth_spinbox = QDoubleSpinBox()
423+
max_depth_spinbox.setRange(0, 10000)
424+
max_depth_spinbox.setValue(1000)
425+
max_depth_spinbox.setSuffix(" m")
426+
max_depth_spinbox.setDecimals(2)
427+
max_depth_spinbox.setEnabled(False)
428+
429+
# Connect checkboxes to enable/disable spinboxes
430+
min_depth_checkbox.toggled.connect(min_depth_spinbox.setEnabled)
431+
max_depth_checkbox.toggled.connect(max_depth_spinbox.setEnabled)
432+
433+
# Add widgets to layout
434+
layout.addRow(min_depth_checkbox, min_depth_spinbox)
435+
layout.addRow(max_depth_checkbox, max_depth_spinbox)
436+
437+
# Dialog buttons
438+
button_box = QDialogButtonBox(
439+
QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel
440+
)
441+
button_box.accepted.connect(dialog.accept)
442+
button_box.rejected.connect(dialog.reject)
443+
layout.addWidget(button_box)
444+
445+
# Show dialog and get result
446+
if dialog.exec() == QDialog.DialogCode.Accepted:
447+
min_depth = (
448+
min_depth_spinbox.value() if min_depth_checkbox.isChecked() else None
449+
)
450+
max_depth = (
451+
max_depth_spinbox.value() if max_depth_checkbox.isChecked() else None
452+
)
453+
454+
# Validate that at least one constraint is set
455+
if min_depth is None and max_depth is None:
456+
QMessageBox.warning(
457+
self.parent,
458+
"No Constraint",
459+
"Please set at least one depth constraint (minimum or maximum).",
460+
)
461+
return None
462+
463+
# Validate that min <= max if both are set
464+
if (
465+
min_depth is not None
466+
and max_depth is not None
467+
and min_depth > max_depth
468+
):
469+
QMessageBox.warning(
470+
self.parent,
471+
"Invalid Range",
472+
"Minimum depth cannot be greater than maximum depth.",
473+
)
474+
return None
475+
476+
return DepthRangeConstraintResult(min_depth, max_depth)
477+
478+
return None
479+
480+
358481
# HELPER DIALOG CLASSES
359482

360483

@@ -753,6 +876,7 @@ def _create_default_filters(self) -> List[BaseFilter]:
753876
),
754877
SimpleTextFilter(self, "Activity", "activity"),
755878
SimpleTextFilter(self, "Observation group", "observation_group"),
879+
DepthRangeFilter(self, "Depth range"),
756880
GeneratorFilter(self, "Generator", "generator", prompt="Generator"),
757881
VerifierFilter(self, "Verifier", "verifier", prompt="Verifier"),
758882
FunctionalFilter(self, "Verified", lambda: VerifiedConstraintResult()),

tests/test_depth_range_result.py

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import unittest
2+
from vars_gridview.ui.QueryDialog import DepthRangeConstraintResult
3+
4+
5+
class TestDepthRangeConstraintResult(unittest.TestCase):
6+
"""Test the DepthRangeConstraintResult class."""
7+
8+
def test_both_min_and_max(self):
9+
"""Test depth range with both min and max values."""
10+
result = DepthRangeConstraintResult(100.0, 2500.0)
11+
12+
# Check string representation
13+
self.assertEqual(str(result), "Depth: 100.0m - 2500.0m")
14+
15+
# Check constraints
16+
constraints = list(result.constraints)
17+
self.assertEqual(len(constraints), 1)
18+
self.assertEqual(constraints[0].column, "depth_meters")
19+
self.assertEqual(constraints[0].minmax, [100.0, 2500.0])
20+
21+
def test_min_only(self):
22+
"""Test depth range with only minimum value (>= 100m)."""
23+
result = DepthRangeConstraintResult(100.0, None)
24+
25+
# Check string representation
26+
self.assertEqual(str(result), "Depth: >= 100.0m")
27+
28+
# Check constraints
29+
constraints = list(result.constraints)
30+
self.assertEqual(len(constraints), 1)
31+
self.assertEqual(constraints[0].column, "depth_meters")
32+
self.assertEqual(constraints[0].min, 100.0)
33+
self.assertIsNone(constraints[0].max)
34+
self.assertIsNone(constraints[0].minmax)
35+
36+
def test_max_only(self):
37+
"""Test depth range with only maximum value (<= 2500m)."""
38+
result = DepthRangeConstraintResult(None, 2500.0)
39+
40+
# Check string representation
41+
self.assertEqual(str(result), "Depth: <= 2500.0m")
42+
43+
# Check constraints
44+
constraints = list(result.constraints)
45+
self.assertEqual(len(constraints), 1)
46+
self.assertEqual(constraints[0].column, "depth_meters")
47+
self.assertEqual(constraints[0].max, 2500.0)
48+
self.assertIsNone(constraints[0].min)
49+
self.assertIsNone(constraints[0].minmax)
50+
51+
def test_neither_min_nor_max(self):
52+
"""Test depth range with neither min nor max (edge case)."""
53+
result = DepthRangeConstraintResult(None, None)
54+
55+
# Check string representation
56+
self.assertEqual(str(result), "Depth: (no constraint)")
57+
58+
# Check that no constraints are yielded
59+
constraints = list(result.constraints)
60+
self.assertEqual(len(constraints), 0)
61+
62+
def test_example_shallow_water(self):
63+
"""Example: Query for shallow water observations (0-100m)."""
64+
result = DepthRangeConstraintResult(0.0, 100.0)
65+
self.assertEqual(str(result), "Depth: 0.0m - 100.0m")
66+
67+
def test_example_deep_water(self):
68+
"""Example: Query for deep water observations (>= 2500m)."""
69+
result = DepthRangeConstraintResult(2500.0, None)
70+
self.assertEqual(str(result), "Depth: >= 2500.0m")
71+
72+
73+
if __name__ == "__main__":
74+
unittest.main()

tests/test_query_constraints.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import unittest
2+
from vars_gridview.lib.m3.query import QueryConstraint
3+
4+
5+
class TestDepthConstraints(unittest.TestCase):
6+
"""Test the depth range constraints functionality."""
7+
8+
def test_min_depth_constraint(self):
9+
"""Test that a minimum depth constraint is created correctly."""
10+
constraint = QueryConstraint(column="depth_meters", min=100.0)
11+
result_dict = constraint.to_dict()
12+
13+
self.assertEqual(result_dict["column"], "depth_meters")
14+
self.assertEqual(result_dict["min"], 100.0)
15+
self.assertNotIn("max", result_dict)
16+
self.assertNotIn("minmax", result_dict)
17+
18+
def test_max_depth_constraint(self):
19+
"""Test that a maximum depth constraint is created correctly."""
20+
constraint = QueryConstraint(column="depth_meters", max=2500.0)
21+
result_dict = constraint.to_dict()
22+
23+
self.assertEqual(result_dict["column"], "depth_meters")
24+
self.assertEqual(result_dict["max"], 2500.0)
25+
self.assertNotIn("min", result_dict)
26+
self.assertNotIn("minmax", result_dict)
27+
28+
def test_minmax_depth_constraint(self):
29+
"""Test that a depth range (minmax) constraint is created correctly."""
30+
constraint = QueryConstraint(column="depth_meters", minmax=[100.0, 2500.0])
31+
result_dict = constraint.to_dict()
32+
33+
self.assertEqual(result_dict["column"], "depth_meters")
34+
self.assertEqual(result_dict["minmax"], [100.0, 2500.0])
35+
self.assertNotIn("min", result_dict)
36+
self.assertNotIn("max", result_dict)
37+
38+
def test_constraint_to_dict_skip_null(self):
39+
"""Test that null values are excluded when skip_null=True."""
40+
constraint = QueryConstraint(
41+
column="depth_meters",
42+
min=100.0,
43+
max=None,
44+
equals=None
45+
)
46+
result_dict = constraint.to_dict(skip_null=True)
47+
48+
self.assertIn("column", result_dict)
49+
self.assertIn("min", result_dict)
50+
self.assertNotIn("max", result_dict)
51+
self.assertNotIn("equals", result_dict)
52+
53+
54+
if __name__ == "__main__":
55+
unittest.main()

0 commit comments

Comments
 (0)