Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions default_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
"auto_exposure_zero_star_handler": "sweep",
"menu_anim_speed": 0.1,
"text_scroll_speed": "Med",
"t9_search": false,
"screen_direction": "right",
"mount_type": "Alt/Az",
"solver_debug": 0,
Expand Down
81 changes: 81 additions & 0 deletions python/PiFinder/catalogs.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# mypy: ignore-errors
import logging
import re
import time
import datetime
import pytz
Expand All @@ -26,6 +27,31 @@

logger = logging.getLogger("Catalog")

# Mapping from keypad numbers to characters (non-conventional layout)
KEYPAD_DIGIT_TO_CHARS = {
"7": "abc",
"8": "def",
"9": "ghi",
"4": "jkl",
"5": "mno",
"6": "pqrs",
"1": "tuv",
"2": "wxyz",
"3": "'-+/",
}

LETTER_TO_DIGIT_MAP: dict[str, str] = {}
for _digit, _chars in KEYPAD_DIGIT_TO_CHARS.items():
# Map the digit to itself so numbers in names still match
LETTER_TO_DIGIT_MAP[_digit] = _digit
for _char in _chars:
LETTER_TO_DIGIT_MAP[_char] = _digit
LETTER_TO_DIGIT_MAP[_char.upper()] = _digit

translator = str.maketrans(LETTER_TO_DIGIT_MAP)
VALID_T9_DIGITS = "".join(KEYPAD_DIGIT_TO_CHARS.keys())
INVALID_T9_DIGITS_RE = re.compile(f"[^{VALID_T9_DIGITS}]")

# collection of all catalog-related classes

# CatalogBase : just the CompositeObjects (imported from catalog_base)
Expand Down Expand Up @@ -345,6 +371,8 @@ class Catalogs:
def __init__(self, catalogs: List[Catalog]):
self.__catalogs: List[Catalog] = catalogs
self.catalog_filter: Union[CatalogFilter, None] = None
self._t9_cache: dict[tuple[str, int], list[str]] = {}
self._t9_cache_dirty = True

def filter_catalogs(self):
"""
Expand Down Expand Up @@ -400,6 +428,56 @@ def get_object(self, catalog_code: str, sequence: int) -> Optional[CompositeObje

# this is memory efficient and doesn't hit the sdcard, but could be faster
# also, it could be cached
def _name_to_t9_digits(self, name: str) -> str:
translated_name = name.translate(translator)
return INVALID_T9_DIGITS_RE.sub("", translated_name)

def _object_cache_key(self, obj: CompositeObject) -> tuple[str, int]:
return (obj.catalog_code, obj.sequence)

def _invalidate_t9_cache(self) -> None:
self._t9_cache_dirty = True

def _rebuild_t9_cache(self, objs: list[CompositeObject]) -> None:
self._t9_cache = {}
for obj in objs:
self._t9_cache[self._object_cache_key(obj)] = [
self._name_to_t9_digits(name) for name in obj.names
]
self._t9_cache_dirty = False

def _ensure_t9_cache(self, objs: list[CompositeObject]) -> None:
current_keys = {self._object_cache_key(obj) for obj in objs}
if self._t9_cache_dirty or current_keys != set(self._t9_cache.keys()):
self._rebuild_t9_cache(objs)

def search_by_t9(self, search_digits: str) -> List[CompositeObject]:
"""Search catalog objects using keypad digits.

Uses the existing keypad letter mapping (including its non-conventional
layout) to convert object names to their digit representation and
returns all objects whose digit string contains the search pattern.
"""

objs = self.get_objects(only_selected=False, filtered=False)
result: list[CompositeObject] = []
if not search_digits:
return result

self._ensure_t9_cache(objs)

for obj in objs:
for digits in self._t9_cache.get(self._object_cache_key(obj), []):
if len(digits) < len(search_digits):
continue
if search_digits in digits:
result.append(obj)
logger.debug(
"Found %s in %s %i via T9", digits, obj.catalog_code, obj.sequence
)
break
return result

def search_by_text(self, search_text: str) -> List[CompositeObject]:
objs = self.get_objects(only_selected=False, filtered=False)
result = []
Expand All @@ -419,12 +497,14 @@ def search_by_text(self, search_text: str) -> List[CompositeObject]:
def set(self, catalogs: List[Catalog]):
self.__catalogs = catalogs
self.select_all_catalogs()
self._invalidate_t9_cache()

def add(self, catalog: Catalog, select: bool = False):
if catalog.catalog_code not in [x.catalog_code for x in self.__catalogs]:
if select:
self.catalog_filter.selected_catalogs.add(catalog.catalog_code)
self.__catalogs.append(catalog)
self._invalidate_t9_cache()
else:
logger.warning(
"Catalog %s already exists, not replaced (in Catalogs.add)",
Expand All @@ -435,6 +515,7 @@ def remove(self, catalog_code: str):
for catalog in self.__catalogs:
if catalog.catalog_code == catalog_code:
self.__catalogs.remove(catalog)
self._invalidate_t9_cache()
return

logger.warning("Catalog %s does not exist, cannot remove", catalog_code)
Expand Down
16 changes: 16 additions & 0 deletions python/PiFinder/ui/menu_structure.py
Original file line number Diff line number Diff line change
Expand Up @@ -679,6 +679,22 @@ def _(key: str) -> Any:
},
],
},
{
"name": _("T9 Search"),
"class": UITextMenu,
"select": "single",
"config_option": "t9_search",
"items": [
{
"name": _("Off"),
"value": False,
},
{
"name": _("On"),
"value": True,
},
],
},
{
"name": _("Az Arrows"),
"class": UITextMenu,
Expand Down
18 changes: 17 additions & 1 deletion python/PiFinder/ui/textentry.py
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ def __init__(self, *args, **kwargs):
self.SEARCH_DEBOUNCE_MS = 250 # milliseconds


@property
def t9_search_enabled(self) -> bool:
return bool(self.config_object.get_option("t9_search", False))

def draw_text_entry(self):
line_text_y = self.text_y + 15
self.draw.line(
Expand Down Expand Up @@ -273,7 +277,10 @@ def _perform_search(self, search_text, search_version):
# Priority catalogs (NGC, IC, M) are loaded first, WDS loads in background
# So search will work immediately with those, WDS results appear when loading completes
logger.info(f"Starting search for '{search_text}'")
results = self.catalogs.search_by_text(search_text)
if self.t9_search_enabled:
results = self.catalogs.search_by_t9(search_text)
else:
results = self.catalogs.search_by_text(search_text)
logger.info(f"Search for '{search_text}' found {len(results)} results")

# Only update if this search is still current (not superseded by newer search)
Expand Down Expand Up @@ -335,6 +342,15 @@ def key_long_minus(self):
def key_number(self, number):
current_time = time.time()
number_key = str(number)
if not self.text_entry_mode and self.t9_search_enabled:
# In T9 mode we simply append the pressed digit
self.last_key_press_time = current_time
self.last_key = number
if number_key in self.keys:
self.char_index = 0
self.add_char(number_key)
return

# Check if the same key is pressed within a short time
if self.last_key == number and self.within_keypress_window(current_time):
self.char_index = (self.char_index + 1) % self.keys.get_nr_entries(
Expand Down
172 changes: 172 additions & 0 deletions python/tests/test_t9_search.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
import sys
import types

import pytest


@pytest.fixture()
def catalogs_api(monkeypatch):
"""Provide catalog helpers while isolating the calc_utils stub."""

# Avoid expensive ephemeris downloads triggered during PiFinder.calc_utils import
stub_calc_utils = types.ModuleType("PiFinder.calc_utils")
stub_calc_utils.FastAltAz = None
stub_calc_utils.sf_utils = None
monkeypatch.setitem(sys.modules, "PiFinder.calc_utils", stub_calc_utils)

# Avoid optional timezone dependency required by the catalogs module
stub_pytz = types.ModuleType("pytz")
stub_pytz.timezone = lambda name: name
stub_pytz.utc = "UTC"
monkeypatch.setitem(sys.modules, "pytz", stub_pytz)

# Avoid optional dataclasses JSON dependency required by config/equipment imports
stub_dataclasses_json = types.ModuleType("dataclasses_json")

def dataclass_json(cls=None, **_kwargs):
def decorator(inner_cls):
return inner_cls

return decorator(cls) if cls is not None else decorator

stub_dataclasses_json.dataclass_json = dataclass_json
monkeypatch.setitem(sys.modules, "dataclasses_json", stub_dataclasses_json)

# Avoid optional numpy dependency pulled in via CompositeObject
stub_numpy = types.ModuleType("numpy")
stub_numpy.array = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "numpy", stub_numpy)

# Avoid timezone lookup dependency required by SharedState
stub_timezonefinder = types.ModuleType("timezonefinder")

class _TimezoneFinder:
def timezone_at(self, **_kwargs):
return "UTC"

stub_timezonefinder.TimezoneFinder = _TimezoneFinder
monkeypatch.setitem(sys.modules, "timezonefinder", stub_timezonefinder)

# Avoid skyfield dependency pulled in by comets module
stub_skyfield = types.ModuleType("skyfield")
stub_skyfield_data = types.ModuleType("skyfield.data")
stub_skyfield_constants = types.ModuleType("skyfield.constants")
stub_skyfield_data.mpc = types.SimpleNamespace(COMET_URL="")
stub_skyfield_constants.GM_SUN_Pitjeva_2005_km3_s2 = 0
monkeypatch.setitem(sys.modules, "skyfield", stub_skyfield)
monkeypatch.setitem(sys.modules, "skyfield.data", stub_skyfield_data)
monkeypatch.setitem(sys.modules, "skyfield.constants", stub_skyfield_constants)

from PiFinder import catalogs as catalogs_module

return catalogs_module.Catalogs, catalogs_module.KEYPAD_DIGIT_TO_CHARS, catalogs_module.LETTER_TO_DIGIT_MAP


class DummyObject:
def __init__(self, names, catalog_code="TST", sequence=1):
self.names = names
self.catalog_code = catalog_code
self.sequence = sequence


class DummyCatalog:
def __init__(self, catalog_code, objects):
self.catalog_code = catalog_code
self._objects = objects

def is_selected(self):
return True

def get_objects(self):
return self._objects


@pytest.mark.unit
def test_letter_mapping_uses_keypad_layout(catalogs_api):
_, KEYPAD_DIGIT_TO_CHARS, LETTER_TO_DIGIT_MAP = catalogs_api
# spot-check the non-conventional keypad mapping
assert LETTER_TO_DIGIT_MAP["t"] == "1"
assert LETTER_TO_DIGIT_MAP["v"] == "1"
assert LETTER_TO_DIGIT_MAP["m"] == "5"
assert LETTER_TO_DIGIT_MAP["'"] == "3"
# ensure every keypad character is represented in the mapping
for digit, chars in KEYPAD_DIGIT_TO_CHARS.items():
for char in chars:
assert LETTER_TO_DIGIT_MAP[char] == digit


@pytest.mark.unit
def test_search_by_t9_matches_objects(catalogs_api):
Catalogs, _, _ = catalogs_api
objects = [
DummyObject(["Vega"], sequence=1),
DummyObject(["M31", "Andromeda"], sequence=2),
DummyObject(["Polaris"], sequence=3),
]
catalogs = Catalogs([DummyCatalog("TST", objects)])

# Vega -> v(1)e(8)g(9)a(7)
vega_results = catalogs.search_by_t9("1897")
assert len(vega_results) == 1
assert vega_results[0].sequence == 1

# M31 -> m(5)3(3)1(1)
m31_results = catalogs.search_by_t9("531")
assert len(m31_results) == 1
assert m31_results[0].sequence == 2

# No matches should return an empty list
assert catalogs.search_by_t9("9999") == []


@pytest.mark.unit
def test_search_by_t9_uses_cached_digits(monkeypatch, catalogs_api):
Catalogs, _, _ = catalogs_api
objects = [DummyObject(["Vega"], sequence=1)]
catalogs = Catalogs([DummyCatalog("TST", objects)])

call_count = 0
original = Catalogs._name_to_t9_digits

def counting(self, name):
nonlocal call_count
call_count += 1
return original(self, name)

monkeypatch.setattr(Catalogs, "_name_to_t9_digits", counting)

catalogs.search_by_t9("1")
first_count = call_count

# Subsequent searches should use cached digit strings
catalogs.search_by_t9("18")
assert call_count == first_count


@pytest.mark.unit
def test_search_by_t9_cache_invalidation_on_catalog_change(monkeypatch, catalogs_api):
Catalogs, _, _ = catalogs_api
objects = [DummyObject(["Vega"], sequence=1)]
dummy_catalog = DummyCatalog("TST", objects)
catalogs = Catalogs([dummy_catalog])

catalogs.search_by_t9("1897")

new_object = DummyObject(["Deneb"], sequence=2)
dummy_catalog._objects.append(new_object)

# Update tracker to ensure cache rebuild triggers conversion for new object
call_count = 0
original = Catalogs._name_to_t9_digits

def counting(self, name):
nonlocal call_count
call_count += 1
return original(self, name)

monkeypatch.setattr(Catalogs, "_name_to_t9_digits", counting)

results = catalogs.search_by_t9("88587")

assert any(obj.sequence == 2 for obj in results)
assert call_count > 0