diff --git a/astro_data/pifinder_objects.db b/astro_data/pifinder_objects.db index 79200610..40efa599 100644 Binary files a/astro_data/pifinder_objects.db and b/astro_data/pifinder_objects.db differ diff --git a/default_config.json b/default_config.json index 6b188339..0500aac5 100644 --- a/default_config.json +++ b/default_config.json @@ -16,6 +16,8 @@ "chart_dso": 128, "chart_reticle": 128, "chart_constellations": 64, + "image_nsew": true, + "image_bbox": true, "chart_coord_sys": "horiz", "target_pixel": [256, 256], "gps_type": "ublox", diff --git a/python/PiFinder/cat_images.py b/python/PiFinder/cat_images.py index 1eedfd74..f65ed270 100644 --- a/python/PiFinder/cat_images.py +++ b/python/PiFinder/cat_images.py @@ -5,6 +5,7 @@ to handle catalog image loading """ +import math import os from PIL import Image, ImageChops, ImageDraw from PiFinder import image_util @@ -19,6 +20,103 @@ logger = logging.getLogger("Catalog.Images") +def cardinal_vectors(image_rotate, fx=1, fy=1): + """Return (nx, ny), (ex, ey) unit vectors for North and East. + + image_rotate: degrees the POSS image was rotated (180 + roll). + fx, fy: -1 to mirror that axis (flip/flop), +1 otherwise. + """ + theta = math.radians(image_rotate) + n = (fx * math.sin(theta), fy * -math.cos(theta)) + e = (-fx * math.cos(theta), -fy * math.sin(theta)) + return n, e + + +def size_overlay_points(extents, pa, image_rotate, px_per_arcsec, cx, cy, fx=1, fy=1): + """Compute outline points for the size overlay. + + Returns a list of (x, y) tuples. + For 1 extent returns None (caller should use native ellipse). + """ + if not extents or len(extents) == 1: + return None + + theta = math.radians(image_rotate - pa - 90) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + points = [] + if len(extents) == 2: + rx = extents[0] * px_per_arcsec / 2 + ry = extents[1] * px_per_arcsec / 2 + for i in range(36): + t = 2 * math.pi * i / 36 + x = rx * math.cos(t) + y = ry * math.sin(t) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + else: + step = 2 * math.pi / len(extents) + for i, ext in enumerate(extents): + angle = i * step - math.pi / 2 + r = ext * px_per_arcsec / 2 + x = r * math.cos(angle) + y = r * math.sin(angle) + points.append( + (cx + fx * (x * cos_t - y * sin_t), cy + fy * (x * sin_t + y * cos_t)) + ) + return points + + +def vertex_overlay_points( + vertices, obj_ra, obj_dec, image_rotate, px_per_arcsec, cx, cy, fx=1, fy=1 +): + """Project RA/Dec vertex pairs to pixel coords via gnomonic projection. + + vertices: list of [ra, dec] pairs in degrees. + obj_ra, obj_dec: object center in degrees. + Returns list of (x, y) pixel tuples. + """ + theta = math.radians(image_rotate) + cos_t = math.cos(theta) + sin_t = math.sin(theta) + + ra0 = math.radians(obj_ra) + dec0 = math.radians(obj_dec) + cos_dec0 = math.cos(dec0) + sin_dec0 = math.sin(dec0) + + points = [] + for ra_deg, dec_deg in vertices: + ra = math.radians(ra_deg) + dec = math.radians(dec_deg) + cos_dec = math.cos(dec) + sin_dec = math.sin(dec) + dra = ra - ra0 + + cos_c = sin_dec0 * sin_dec + cos_dec0 * cos_dec * math.cos(dra) + if cos_c <= 0: + continue + # gnomonic: xi points East, eta points North (radians) + xi = (cos_dec * math.sin(dra)) / cos_c + eta = (cos_dec0 * sin_dec - sin_dec0 * cos_dec * math.cos(dra)) / cos_c + + # convert to arcsec offsets then pixels + dx_arcsec = -xi * 206264.806 # negate: East is left on POSS + dy_arcsec = -eta * 206264.806 # negate: North is up, pixel y is down + + dx_px = dx_arcsec * px_per_arcsec + dy_px = dy_arcsec * px_per_arcsec + + # apply image rotation + rx = dx_px * cos_t - dy_px * sin_t + ry = dx_px * sin_t + dy_px * cos_t + + points.append((cx + fx * rx, cy + fy * ry)) + return points + + def _orient_image(return_image, roll, flip_image, flop_image): """ Orient a source survey image to match the eyepiece view. @@ -54,6 +152,8 @@ def get_display_image( display_class, burn_in=True, magnification=None, + show_nsew=True, + show_bbox=True, flip_image=False, flop_image=False, ): @@ -66,7 +166,6 @@ def get_display_image( roll: degrees """ - object_image_path = resolve_image_name(catalog_object, source="POSS") logger.debug("object_image_path = %s", object_image_path) if not os.path.exists(object_image_path): @@ -82,6 +181,10 @@ def get_display_image( else: return_image = Image.open(object_image_path) + image_rotate = 180 + if roll is not None: + image_rotate += roll + # Orient to match the eyepiece view (see ADR 0003) return_image = _orient_image(return_image, roll, flip_image, flop_image) @@ -123,6 +226,92 @@ def get_display_image( width=1, ) + cx = display_class.fov_res / 2 + cy = display_class.fov_res / 2 + fx = -1 if flop_image else 1 + fy = -1 if flip_image else 1 + + # NSEW cardinal labels — show only 2: topmost and leftmost + if show_nsew: + (nx, ny), (ex, ey) = cardinal_vectors(image_rotate, fx, fy) + label_font = display_class.fonts.base + label_color = display_class.colors.get(64) + r_label = display_class.fov_res / 2 - 2 + top_limit = display_class.titlebar_height + bottom_limit = display_class.fov_res - label_font.height * 2 + + candidates = [ + ("N", nx, ny), + ("S", -nx, -ny), + ("E", ex, ey), + ("W", -ex, -ey), + ] + by_top = sorted(candidates, key=lambda c: c[2]) + by_left = sorted(candidates, key=lambda c: c[1]) + chosen = {by_top[0][0]: by_top[0]} + # pick leftmost that isn't already chosen + for c in by_left: + if c[0] not in chosen: + chosen[c[0]] = c + break + + for label, dx, dy in chosen.values(): + lx = cx + dx * r_label - label_font.width / 2 + ly = cy + dy * r_label - label_font.height / 2 + lx = max(0, min(lx, display_class.fov_res - label_font.width)) + ly = max(top_limit, min(ly, bottom_limit)) + ui_utils.shadow_outline_text( + ri_draw, + (lx, ly), + label, + font=label_font, + align="left", + fill=label_color, + shadow_color=display_class.colors.get(0), + outline=1, + ) + + # Size overlay + extents = catalog_object.size.extents + if show_bbox and extents and fov > 0: + px_per_arcsec = display_class.fov_res / (fov * 3600) + overlay_color = display_class.colors.get(100) + + if catalog_object.size.is_vertices: + points = vertex_overlay_points( + extents, + catalog_object.ra, + catalog_object.dec, + image_rotate, + px_per_arcsec, + cx, + cy, + fx, + fy, + ) + if len(points) >= 2: + ri_draw.line(points, fill=overlay_color, width=1) + elif len(extents) == 1: + r = extents[0] * px_per_arcsec / 2 + ri_draw.ellipse( + [cx - r, cy - r, cx + r, cy + r], + outline=overlay_color, + width=1, + ) + else: + points = size_overlay_points( + extents, + catalog_object.size.position_angle, + image_rotate, + px_per_arcsec, + cx, + cy, + fx, + fy, + ) + if points: + ri_draw.polygon(points, outline=overlay_color) + # Pad out image if needed if display_class.fov_res != display_class.resX: pad_image = Image.new("RGB", display_class.resolution) diff --git a/python/PiFinder/catalog_imports/bright_stars_loader.py b/python/PiFinder/catalog_imports/bright_stars_loader.py index fa4dfc34..b202b2a9 100644 --- a/python/PiFinder/catalog_imports/bright_stars_loader.py +++ b/python/PiFinder/catalog_imports/bright_stars_loader.py @@ -9,7 +9,7 @@ from tqdm import tqdm import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( NewCatalogObject, @@ -45,8 +45,7 @@ def load_bright_stars(): sequence = int(dfs[0]) logging.debug(f"---------------> Bright Stars {sequence=} <---------------") - size = "" - # const = dfs[2].strip() + size = SizeObject([]) desc = "" ra_h = int(dfs[3]) @@ -58,7 +57,6 @@ def load_bright_stars(): dec_deg = dec_to_deg(dec_d, dec_m, 0) mag = MagnitudeObject([float(dfs[7].strip())]) - # const = dfs[8] new_object = NewCatalogObject( object_type=obj_type, diff --git a/python/PiFinder/catalog_imports/caldwell_loader.py b/python/PiFinder/catalog_imports/caldwell_loader.py index 0e29f513..ae25157e 100644 --- a/python/PiFinder/catalog_imports/caldwell_loader.py +++ b/python/PiFinder/catalog_imports/caldwell_loader.py @@ -17,6 +17,7 @@ insert_catalog, insert_catalog_max_sequence, add_space_after_prefix, + parse_arcmin_size, ) # Import shared database object @@ -46,7 +47,7 @@ def load_caldwell(): mag = MagnitudeObject([]) else: mag = MagnitudeObject([float(mag)]) - size = dfs[5][5:].strip() + size = parse_arcmin_size(dfs[5][5:].strip()) ra_h = int(dfs[6]) ra_m = float(dfs[7]) ra_deg = ra_to_deg(ra_h, ra_m, 0) diff --git a/python/PiFinder/catalog_imports/catalog_import_utils.py b/python/PiFinder/catalog_imports/catalog_import_utils.py index 279169df..e828e37b 100644 --- a/python/PiFinder/catalog_imports/catalog_import_utils.py +++ b/python/PiFinder/catalog_imports/catalog_import_utils.py @@ -10,7 +10,7 @@ from dataclasses import dataclass, field from tqdm import tqdm -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.ui.ui_utils import normalize from PiFinder import calc_utils from PiFinder.db.objects_db import ObjectsDatabase @@ -30,7 +30,7 @@ class NewCatalogObject: dec: float mag: MagnitudeObject object_id: int = 0 - size: str = "" + size: SizeObject = field(default_factory=lambda: SizeObject([])) description: str = "" aka_names: list[str] = field(default_factory=list) surface_brightness: float = 0.0 @@ -76,7 +76,7 @@ def insert(self, find_object_id=True): self.ra, self.dec, self.constellation, - self.size, + self.size.to_json(), self.mag.to_json(), self.surface_brightness, ) @@ -158,6 +158,54 @@ def get_object_id(self, object_name: str): return result +# Trailing unit suffixes a size token may carry, mapped to their arcsecond +# multiplier. A bare token defaults to arcminutes (see parse_arcmin_size). +_SIZE_UNIT_MULTIPLIERS = {'"': 1.0, "'": 60.0, "°": 3600.0} + + +def _size_token_to_arcsec(token: str) -> Optional[float]: + """Convert one size token to arcseconds, honouring a trailing unit suffix. + + ' -> arcmin, " -> arcsec, ° -> degrees; a bare number defaults to arcmin. + Returns None when the numeric part can't be parsed. + """ + multiplier = _SIZE_UNIT_MULTIPLIERS["'"] # bare tokens are arcminutes + if token and token[-1] in _SIZE_UNIT_MULTIPLIERS: + multiplier = _SIZE_UNIT_MULTIPLIERS[token[-1]] + token = token[:-1] + try: + return float(token) * multiplier + except ValueError: + return None + + +def parse_arcmin_size(raw: str) -> SizeObject: + """Parse a size string into a SizeObject (extents stored in arcseconds). + + Values default to arcminutes, but a token may override its unit with a + trailing suffix: ' (arcmin), " (arcsec), or ° (degrees) — so arcsecond + catalogs like EGC ('36"') parse correctly. The axis separators 'x', '×', + '/', and '+' are all treated as delimiters, so "32'x6.5'", "0.3/5.8", and + "30'+30'" each yield two extents. Tokens whose numeric part can't be + parsed (e.g. "nl", "see") are skipped with a warning. + """ + if not raw: + return SizeObject([]) + cleaned = raw.lower() + for separator in ("x", "×", "/", "+"): + cleaned = cleaned.replace(separator, " ") + values = [] + for token in cleaned.split(): + arcsec = _size_token_to_arcsec(token) + if arcsec is None: + logging.warning("Non-numeric size token %r in %r", token, raw) + else: + values.append(arcsec) + if not values: + return SizeObject([]) + return SizeObject.from_arcsec(*values) + + def safe_convert_to_float(x): """Convert to float, filtering out non-numeric values""" try: @@ -278,11 +326,52 @@ def count_empty_entries_in_tables(): ) +def count_nonempty_sizes_per_catalog(): + """Report per-catalog coverage of non-empty size extents. + + A valid-but-empty size payload ({"e": []}) still counts as "has a size" + when you only check for a JSON payload, so a size-parsing regression can + silently wipe a catalog's extents without tripping a presence check. This + counts payloads that actually carry extents, joining catalog_objects to + objects since size lives on the object (shared across catalogs). + """ + _, db_c = objects_db.get_conn_cursor() + rows = db_c.execute( + """ + SELECT co.catalog_code AS catalog_code, + COUNT(*) AS total, + SUM( + CASE WHEN o.size IS NOT NULL + AND o.size != '' + AND o.size NOT LIKE '%"e": []%' + THEN 1 ELSE 0 END + ) AS with_size + FROM catalog_objects co + JOIN objects o ON o.id = co.object_id + GROUP BY co.catalog_code + ORDER BY co.catalog_code + """ + ).fetchall() + logging.info("Size extent coverage per catalog (objects with non-empty extents):") + for row in rows: + total = row["total"] + with_size = row["with_size"] or 0 + pct = (with_size / total * 100) if total else 0.0 + logging.info( + " %-8s %6d/%-6d (%3.0f%%)", + row["catalog_code"], + with_size, + total, + pct, + ) + + def print_database(): """Print database statistics""" logging.info(">-------------------------------------------------------") count_common_names_per_catalog() count_empty_entries_in_tables() + count_nonempty_sizes_per_catalog() logging.info("<-------------------------------------------------------") diff --git a/python/PiFinder/catalog_imports/harris_loader.py b/python/PiFinder/catalog_imports/harris_loader.py index 72649f02..bf067992 100644 --- a/python/PiFinder/catalog_imports/harris_loader.py +++ b/python/PiFinder/catalog_imports/harris_loader.py @@ -14,7 +14,7 @@ import numpy as np import numpy.typing as npt import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( delete_catalog_from_database, @@ -286,15 +286,13 @@ def create_cluster_object(entry: npt.NDArray, seq: int) -> Dict[str, Any]: logging.debug(f" Magnitude: None (invalid value: {mag_value})") # Size - use half-mass radius (Rh) in arcminutes - # Format using utils.format_size_value to match other catalogs rh = entry["Rh"].item() if is_valid_value(rh): - # Convert to string, removing unnecessary decimals - result["size"] = utils.format_size_value(rh) + result["size"] = SizeObject.from_arcmin(float(rh)) if VERBOSE: - logging.debug(f" Size (half-mass radius): {result['size']} arcmin") + logging.debug(f" Size (half-mass radius): {rh} arcmin") else: - result["size"] = "" + result["size"] = SizeObject([]) if VERBOSE: logging.debug(f" Size: None (invalid Rh value: {rh})") diff --git a/python/PiFinder/catalog_imports/herschel_loader.py b/python/PiFinder/catalog_imports/herschel_loader.py index f6f3c904..4d2d7089 100644 --- a/python/PiFinder/catalog_imports/herschel_loader.py +++ b/python/PiFinder/catalog_imports/herschel_loader.py @@ -54,9 +54,13 @@ def load_herschel400(): f"---------------> Herschel 400 {sequence=} <---------------" ) - object_id = objects_db.get_catalog_object_by_sequence( + result = objects_db.get_catalog_object_by_sequence( "NGC", NGC_sequence - )["id"] + ) + if result is None: + logging.warning("NGC %s not found, skipping H%d", NGC_sequence, sequence) + continue + object_id = result["id"] objects_db.insert_name(object_id, h_name, catalog) objects_db.insert_catalog_object(object_id, catalog, sequence, h_desc) conn.commit() diff --git a/python/PiFinder/catalog_imports/lynga_loader.py b/python/PiFinder/catalog_imports/lynga_loader.py index 09f7df85..0797bc40 100644 --- a/python/PiFinder/catalog_imports/lynga_loader.py +++ b/python/PiFinder/catalog_imports/lynga_loader.py @@ -17,7 +17,7 @@ import numpy as np import numpy.typing as npt import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( delete_catalog_from_database, @@ -410,11 +410,11 @@ def create_cluster_object(entry: npt.NDArray, seq: int) -> Dict[str, Any]: # Angular diameter in arcminutes diam = entry["Diam"].item() if is_valid_value(diam): - result["size"] = utils.format_size_value(diam) + result["size"] = SizeObject.from_arcmin(float(diam)) if VERBOSE: - logging.debug(f" Size: {result['size']} arcmin") + logging.debug(f" Size: {result['size']}") else: - result["size"] = "" + result["size"] = SizeObject([]) # --- Description --- description_parts: List[str] = [] diff --git a/python/PiFinder/catalog_imports/main.py b/python/PiFinder/catalog_imports/main.py index 63df2f41..01abb457 100644 --- a/python/PiFinder/catalog_imports/main.py +++ b/python/PiFinder/catalog_imports/main.py @@ -86,6 +86,8 @@ def main(): objects_db, _ = init_shared_database() logging.info("creating catalog tables") + conn, _ = objects_db.get_conn_cursor() + conn.execute("PRAGMA journal_mode = WAL") objects_db.destroy_tables() objects_db.create_tables() @@ -122,6 +124,12 @@ def main(): resolve_object_images() print_database() + # Finalize: checkpoint WAL and switch to DELETE mode so the .db is + # self-contained (no -wal/-shm sidecars needed at runtime). + logging.info("Finalizing database...") + conn.execute("PRAGMA wal_checkpoint(TRUNCATE)") + conn.execute("PRAGMA journal_mode = DELETE") + if __name__ == "__main__": main() diff --git a/python/PiFinder/catalog_imports/post_processing.py b/python/PiFinder/catalog_imports/post_processing.py index 22fa3a8c..d411547c 100644 --- a/python/PiFinder/catalog_imports/post_processing.py +++ b/python/PiFinder/catalog_imports/post_processing.py @@ -11,7 +11,7 @@ # Import shared database object from .database import objects_db from .catalog_import_utils import NewCatalogObject -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject import PiFinder.utils as utils @@ -123,7 +123,7 @@ def add_missing_messier_objects(): ra=185.552, # 12h 22m 12.5272s in degrees dec=58.083, # +58° 4′ 58.549″ in degrees mag=MagnitudeObject([9.9]), # Average of components A (9.64) and B (10.11) - size="0.1'", + size=SizeObject.from_arcmin(0.1), description="Winnecke 4 double star", aka_names=m40_aka_names, ) @@ -143,7 +143,7 @@ def add_missing_messier_objects(): ra=56.85, # 03h 47m 24s in degrees dec=24.117, # +24° 07′ 00″ in degrees mag=MagnitudeObject([1.6]), - size="120'", # 2° = 120 arcminutes + size=SizeObject.from_degrees(2.0), # 2° description="Pleiades open cluster", aka_names=m45_aka_names, ) @@ -163,7 +163,7 @@ def add_missing_messier_objects(): ra=274.6, # 18h 18m 24s in degrees dec=-18.4, # -18° 24′ 00″ in degrees mag=MagnitudeObject([4.6]), # Visual magnitude of the brightest part - size="90'", # About 1.5 degrees + size=SizeObject.from_degrees(1.5), # ~1.5° description="Sagittarius Star Cloud", aka_names=m24_aka_names, ) @@ -183,7 +183,7 @@ def add_missing_messier_objects(): ra=226.623, # 15h 06m 29.5s in degrees dec=55.763, # +55° 45′ 48″ in degrees mag=MagnitudeObject([10.7]), - size="5.2'x2.3'", + size=SizeObject.from_arcmin(5.2, 2.3), description="Spindle Galaxy (controversial Messier object)", aka_names=m102_aka_names, ) diff --git a/python/PiFinder/catalog_imports/sac_loaders.py b/python/PiFinder/catalog_imports/sac_loaders.py index 8fa58859..238746f5 100644 --- a/python/PiFinder/catalog_imports/sac_loaders.py +++ b/python/PiFinder/catalog_imports/sac_loaders.py @@ -9,7 +9,7 @@ from tqdm import tqdm import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( NewCatalogObject, @@ -23,6 +23,33 @@ from .database import objects_db +def _parse_sac_asterism_size(raw: str) -> SizeObject: + """Parse SAC asterism size strings like '3d X 2.4d', '10x5', '20deg x 15deg'. + + Values with 'd', 'deg', or '°' are degrees; plain numbers are arcminutes. + """ + cleaned = raw.strip().replace(" ", "").replace("X", "x") + if not cleaned: + return SizeObject([]) + parts = cleaned.split("x") + values = [] + is_degrees = False + for p in parts: + p = p.strip() + if "deg" in p or "°" in p or p.endswith("d"): + is_degrees = True + p = p.replace("deg", "").replace("°", "").rstrip("d") + try: + values.append(float(p)) + except ValueError: + return SizeObject([]) + if not values: + return SizeObject([]) + if is_degrees: + return SizeObject.from_degrees(*values) + return SizeObject.from_arcmin(*values) + + def load_sac_asterisms(): """Load the SAC Asterisms catalog""" logging.info("Loading SAC Asterisms") @@ -56,7 +83,6 @@ def load_sac_asterisms(): logging.debug( f"---------------> SAC Asterisms {sequence=} <---------------" ) - # const = dfs[2].strip() ra = dfs[3].strip() dec = dfs[4].strip() mag = dfs[5].strip() @@ -64,13 +90,7 @@ def load_sac_asterisms(): mag = MagnitudeObject([]) else: mag = MagnitudeObject([float(mag)]) - size = ( - dfs[6] - .replace(" ", "") - .replace("X", "x") - .replace("deg", "°") - .replace("d", "°") - ) + size = _parse_sac_asterism_size(dfs[6]) desc = dfs[9].strip() ra = ra.split() @@ -182,6 +202,11 @@ def load_sac_multistars(): dec_m = float(dec[1]) dec_deg = dec_to_deg(dec_d, dec_m, 0) + if sep and utils.is_number(sep): + size = SizeObject.from_arcsec(float(sep)) + else: + size = SizeObject([]) + new_object = NewCatalogObject( object_type=obj_type, catalog_code=catalog, @@ -189,7 +214,7 @@ def load_sac_multistars(): ra=ra_deg, dec=dec_deg, mag=mag, - size=sep, + size=size, description=desc, aka_names=name, ) @@ -249,10 +274,9 @@ def load_sac_redstars(): logging.debug( f"---------------> SAC Red Stars {sequence=} <---------------" ) - # const = dfs[3].strip() ra = dfs[4].strip() dec = dfs[5].strip() - size = "" + size = SizeObject([]) mag = dfs[6].strip() if mag == "none": mag = MagnitudeObject([]) diff --git a/python/PiFinder/catalog_imports/specialized_loaders.py b/python/PiFinder/catalog_imports/specialized_loaders.py index 5e29e770..e8d68aee 100644 --- a/python/PiFinder/catalog_imports/specialized_loaders.py +++ b/python/PiFinder/catalog_imports/specialized_loaders.py @@ -15,7 +15,7 @@ from collections import namedtuple, defaultdict import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg, b1950_to_j2000 from .catalog_import_utils import ( NewCatalogObject, @@ -23,6 +23,7 @@ insert_catalog, insert_catalog_max_sequence, add_space_after_prefix, + parse_arcmin_size, ) # Import shared database object @@ -43,7 +44,7 @@ def load_egc(): delete_catalog_from_database(catalog) insert_catalog(catalog, Path(utils.astro_data_dir, "EGC.desc")) - egc = Path(utils.astro_data_dir, "egc.tsv") + egc = Path(utils.astro_data_dir, "EGC.tsv") # Create shared ObjectFinder to avoid recreating for each object from .catalog_import_utils import ObjectFinder @@ -72,7 +73,8 @@ def load_egc(): dec_s = int(dec[2]) dec_deg = dec_to_deg(dec_deg, dec_m, dec_s) - size = dfs[5] + raw_size = dfs[5].strip() + size = parse_arcmin_size(raw_size) mag = MagnitudeObject([float(dfs[4])]) desc = dfs[7] @@ -138,7 +140,7 @@ def load_collinder(): dec_s = int(dec[9:11]) dec_deg = dec_to_deg(dec_deg, dec_m, dec_s) - size = dfs[7] + size = parse_arcmin_size(dfs[7]) desc = f"{dfs[6]} stars, like {dfs[8]}" # Assuming all the parsing logic is done and all variables are available... @@ -284,7 +286,7 @@ def load_taas200(): mag = MagnitudeObject([]) else: mag = MagnitudeObject([float(mag)]) - size = row["Size"] + size = parse_arcmin_size(row["Size"]) desc = row["Description"] nr_stars = row["# Stars"] gc = row["GC Conc or Class"] @@ -363,10 +365,13 @@ def load_rasc_double_Stars(): alternate_ids = dfs[2].split(",") wds = dfs[3] obj_type = "D*" - # const = dfs[4] mags = json.loads(dfs[7]) mag = MagnitudeObject(mags) - size = dfs[8] + raw_sep = dfs[8].strip() + try: + size = SizeObject.from_arcsec(float(raw_sep)) + except (ValueError, TypeError): + size = SizeObject([]) # 03 31.1 +27 44 ra = dfs[5].split() ra_h = int(ra[0]) @@ -429,7 +434,7 @@ def load_barnard(): for row in tqdm(list(df), leave=False): Barn = row[1:5].strip() if Barn[-1] == "a": - print(f"Skipping {Barn=}") + logging.debug(f"Skipping {Barn=}") continue RA2000h = int(row[22:24]) RA2000m = int(row[25:27]) @@ -437,7 +442,7 @@ def load_barnard(): DE2000_sign = row[32] DE2000d = int(row[33:35]) DE2000m = int(row[36:38]) - Diam = float(row[39:44]) if row[39:44].strip() else "" + raw_diam = row[39:44].strip() sequence = Barn logging.debug(f"<------------- Barnard {sequence=} ------------->") obj_type = "Nb" @@ -451,6 +456,11 @@ def load_barnard(): dec_deg = dec_to_deg(dec_deg, dec_m, 0) desc = barn_dict[Barn].strip() + if raw_diam: + barn_size = SizeObject.from_arcmin(float(raw_diam)) + else: + barn_size = SizeObject([]) + new_object = NewCatalogObject( object_type=obj_type, catalog_code=catalog, @@ -458,7 +468,7 @@ def load_barnard(): ra=ra_deg, dec=dec_deg, mag=MagnitudeObject([]), - size=str(Diam), + size=barn_size, description=desc, aka_names=[], ) @@ -490,8 +500,8 @@ def load_sharpless(): # read description dictionary descriptions_dict = {} - with open(akas, mode="r", newline="", encoding="utf-8") as file: - reader = csv.reader(open(descriptions, "r")) + with open(descriptions, "r") as file: + reader = csv.reader(file) for row in reader: if len(row) == 2: k, v = row @@ -569,7 +579,7 @@ def load_sharpless(): sequence=record["Sh2"], ra=j_ra_deg, dec=dec_deg, - size=str(record["Diam"]), + size=SizeObject.from_arcmin(float(record["Diam"])), mag=MagnitudeObject([]), description=desc, aka_names=current_akas, @@ -738,7 +748,6 @@ def load_tlk_90_vars(): ra=ra_deg, dec=dec_deg, mag=mag_object, - size="", description=desc, aka_names=current_akas, ) @@ -778,6 +787,12 @@ def load_abell(): if other_name != "": aka_names.append(other_name) + raw_abell_size = split_line[6].strip() + try: + abell_size = SizeObject.from_arcmin(float(raw_abell_size)) + except (ValueError, TypeError): + abell_size = SizeObject([]) + new_object = NewCatalogObject( object_type=obj_type, catalog_code=catalog, @@ -785,7 +800,7 @@ def load_abell(): ra=float(split_line[3].strip()), dec=float(split_line[4].strip()), mag=MagnitudeObject([float(split_line[5].strip())]), - size=split_line[6].strip(), + size=abell_size, aka_names=aka_names, ) diff --git a/python/PiFinder/catalog_imports/steinicke_loader.py b/python/PiFinder/catalog_imports/steinicke_loader.py index 622c073e..8bccebad 100644 --- a/python/PiFinder/catalog_imports/steinicke_loader.py +++ b/python/PiFinder/catalog_imports/steinicke_loader.py @@ -15,8 +15,7 @@ from collections import defaultdict import PiFinder.utils as utils -from PiFinder.utils import format_size_value -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from .catalog_import_utils import ( NewCatalogObject, delete_catalog_from_database, @@ -464,12 +463,18 @@ def get_priority(obj): # Get surface brightness surface_brightness = obj.get("surface_brightness") - # Format size information - size = "" + # Format size information (arcminutes from Steinicke) + pa = float(obj["position_angle"]) if obj.get("position_angle") else 0.0 if obj.get("diameter_larger"): - size = format_size_value(obj["diameter_larger"]) + larger = float(obj["diameter_larger"]) if obj.get("diameter_smaller"): - size += f"x{format_size_value(obj['diameter_smaller'])}" + size = SizeObject.from_arcmin( + larger, float(obj["diameter_smaller"]), position_angle=pa + ) + else: + size = SizeObject.from_arcmin(larger, position_angle=pa) + else: + size = SizeObject([]) desc = "" extra = "" diff --git a/python/PiFinder/catalog_imports/wds_loader.py b/python/PiFinder/catalog_imports/wds_loader.py index 96d79863..395f35ec 100644 --- a/python/PiFinder/catalog_imports/wds_loader.py +++ b/python/PiFinder/catalog_imports/wds_loader.py @@ -15,7 +15,7 @@ from collections import defaultdict import PiFinder.utils as utils -from PiFinder.composite_object import MagnitudeObject +from PiFinder.composite_object import MagnitudeObject, SizeObject from PiFinder.calc_utils import ra_to_deg, dec_to_deg from .catalog_import_utils import ( delete_catalog_from_database, @@ -206,8 +206,8 @@ def handle_multiples(key, values) -> dict: result["ra"] = value["ra"] result["dec"] = value["dec"] result["mag"] = MagnitudeObject([mag1, mag2]) - sizemax = np.max([value["Sep_First"], value["Sep_Last"]]) - result["size"] = str(round(sizemax, 1)) + sizemax = float(np.max([value["Sep_First"], value["Sep_Last"]])) + result["size"] = SizeObject.from_arcsec(round(sizemax, 1)) discoverers.add(value["Discoverer_Number"]) notes = value["Notes"].strip() notes_str = "" if len(notes) == 0 else f" Notes: {notes}" diff --git a/python/PiFinder/catalogs.py b/python/PiFinder/catalogs.py index a34c71b2..1b4acbb5 100644 --- a/python/PiFinder/catalogs.py +++ b/python/PiFinder/catalogs.py @@ -14,7 +14,7 @@ from PiFinder.db.db import Database from PiFinder.db.objects_db import ObjectsDatabase from PiFinder.db.observations_db import ObservationsDatabase -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject from PiFinder.utils import Timer from PiFinder.config import Config from PiFinder.catalog_base import ( @@ -651,7 +651,7 @@ def add_planet(self, sequence: int, name: str, planet: Dict[str, Dict[str, float "ra": ra, "dec": dec, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": MagnitudeObject([planet["mag"]]), "names": [name.capitalize()], "catalog_code": "PL", @@ -819,7 +819,6 @@ def _create_full_composite_object(self, catalog_obj: Dict) -> CompositeObject: "sequence": catalog_obj["sequence"], "description": catalog_obj.get("description", ""), "const": obj_data.get("const", ""), - "size": obj_data.get("size", ""), "surface_brightness": obj_data.get("surface_brightness", None), } @@ -836,6 +835,8 @@ def _create_full_composite_object(self, catalog_obj: Dict) -> CompositeObject: composite_instance.mag = MagnitudeObject([]) composite_instance.mag_str = "-" + composite_instance.size = SizeObject.from_json(obj_data.get("size", "")) + composite_instance._details_loaded = True return composite_instance @@ -967,7 +968,6 @@ def _create_full_composite_object( "sequence": catalog_obj["sequence"], "description": catalog_obj.get("description", ""), "const": obj_data.get("const", ""), - "size": obj_data.get("size", ""), "surface_brightness": obj_data.get("surface_brightness", None), } @@ -984,6 +984,8 @@ def _create_full_composite_object( composite_instance.mag = MagnitudeObject([]) composite_instance.mag_str = "-" + composite_instance.size = SizeObject.from_json(obj_data.get("size", "")) + composite_instance._details_loaded = True return composite_instance diff --git a/python/PiFinder/comet_catalog.py b/python/PiFinder/comet_catalog.py index dbe89858..e41603ee 100644 --- a/python/PiFinder/comet_catalog.py +++ b/python/PiFinder/comet_catalog.py @@ -13,7 +13,7 @@ ) from PiFinder.catalogs import Catalog from PiFinder.state import SharedStateObj -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject import PiFinder.comets as comets from PiFinder.utils import Timer, comet_file from PiFinder.calc_utils import sf_utils @@ -280,7 +280,7 @@ def add_comet(self, sequence: int, name: str, comet: Dict[str, Dict[str, float]] "ra": ra, "dec": dec, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": mag, "mag_str": mag.calc_two_mag_representation(), "names": [name], diff --git a/python/PiFinder/composite_object.py b/python/PiFinder/composite_object.py index bd87c636..eff3800f 100644 --- a/python/PiFinder/composite_object.py +++ b/python/PiFinder/composite_object.py @@ -2,10 +2,173 @@ from dataclasses import dataclass, field import numpy as np import json -from typing import List +import math +from typing import List, Union, cast from PiFinder.utils import is_number +class SizeObject: + """Structured angular size for astronomical objects. + + All extents are stored internally in arcseconds. + - [] -> unknown / point source + - [d] -> circular, diameter d + - [major, minor] -> elliptical (major x minor axes) + - [v1, v2, ...] -> polygon radial distances at equal angular intervals + - [[ra,dec], ...] -> RA/Dec polyline vertices (degrees) + - [[[ra,dec],[ra,dec]], ...] -> disconnected line segments (degrees) + + The geometry field disambiguates: "polyline" or "segments". + """ + + def __init__( + self, + extents: Union[List[float], List[List[float]]], + position_angle: float = 0.0, + geometry: str = "", + ): + self.extents: Union[List[float], List[List[float]]] = extents + self.position_angle: float = position_angle + self.geometry: str = geometry + + # --- mode detection --- + + @property + def is_vertices(self) -> bool: + """True for polyline vertices: [[ra,dec], ...]""" + if not self.extents: + return False + if self.geometry == "segments": + return False + if self.geometry == "polyline": + return True + return isinstance(self.extents[0], (list, tuple)) + + @property + def is_segments(self) -> bool: + """True for disconnected segments: [[[ra,dec],[ra,dec]], ...]""" + if self.geometry == "segments": + return True + return False + + def _all_vertices(self) -> List[List[float]]: + """Collect all RA/Dec vertices regardless of geometry type.""" + if self.is_segments: + verts: List[List[float]] = [] + # Segments-mode extents: list of [[ra,dec],[ra,dec]] segments. + for seg in cast(List[List[List[float]]], self.extents): + verts.extend(seg) + return verts + if self.is_vertices: + return cast(List[List[float]], self.extents) + return [] + + @property + def max_extent_arcsec(self) -> float: + if not self.extents: + return 0.0 + verts = self._all_vertices() + if verts: + max_sep = 0.0 + for i in range(len(verts)): + for j in range(i + 1, len(verts)): + ra1, dec1 = math.radians(verts[i][0]), math.radians(verts[i][1]) + ra2, dec2 = math.radians(verts[j][0]), math.radians(verts[j][1]) + dra = ra2 - ra1 + ddec = dec2 - dec1 + cos_dec = math.cos((dec1 + dec2) / 2) + sep = math.sqrt((dra * cos_dec) ** 2 + ddec**2) + max_sep = max(max_sep, sep) + return math.degrees(max_sep) * 3600.0 + # Numeric extents at this point — vertex/segment branches handled above. + return max(cast(List[float], self.extents)) + + # --- constructors --- + + @classmethod + def from_arcmin(cls, *values: float, position_angle: float = 0.0) -> "SizeObject": + return cls([v * 60.0 for v in values], position_angle=position_angle) + + @classmethod + def from_arcsec(cls, *values: float, position_angle: float = 0.0) -> "SizeObject": + return cls(list(values), position_angle=position_angle) + + @classmethod + def from_degrees(cls, *values: float, position_angle: float = 0.0) -> "SizeObject": + return cls([v * 3600.0 for v in values], position_angle=position_angle) + + @classmethod + def from_vertices(cls, vertices: List[List[float]]) -> "SizeObject": + return cls(vertices, position_angle=0.0) + + # --- serialization --- + + def to_json(self) -> str: + return json.dumps({"e": self.extents, "p": self.position_angle}) + + @classmethod + def from_json(cls, json_str: str) -> "SizeObject": + if not json_str: + return cls([]) + try: + parsed = json.loads(json_str) + except (json.JSONDecodeError, TypeError): + # Legacy DB rows store size as plain text (e.g. "5'", "17x8"), + # not JSON. Degrade to an empty SizeObject so the catalog + # still loads; a re-import populates proper extents. + return cls([]) + if not isinstance(parsed, dict) or "e" not in parsed: + return cls([]) + return cls(parsed["e"], position_angle=parsed.get("p", 0.0)) + + # --- display --- + + def _format_value(self, arcsec: float, unit_suffix: str) -> str: + """Format a single value, dropping .0 for whole numbers.""" + if unit_suffix == '"': + val = arcsec + elif unit_suffix == "'": + val = arcsec / 60.0 + else: + val = arcsec / 3600.0 + if val == int(val): + return f"{int(val)}{unit_suffix}" + return f"{val:.1f}{unit_suffix}" + + def _pick_unit(self, arcsec: float) -> str: + """Choose display unit for a value in arcseconds.""" + if arcsec >= 3600.0: + return "°" + if arcsec >= 60.0: + return "'" + return '"' + + def to_display_string(self) -> str: + if not self.extents: + return "" + if self.is_vertices or self.is_segments: + extent = self.max_extent_arcsec + return f"~{self._format_value(extent, self._pick_unit(extent))}" + # Numeric-extent path: extents is List[float] here. + extents = cast(List[float], self.extents) + unit = self._pick_unit(max(extents)) + if len(extents) == 1: + return self._format_value(extents[0], unit) + if len(extents) == 2: + a = self._format_value(extents[0], unit) + b = self._format_value(extents[1], unit) + # strip repeated unit suffix for compact display: 17'x8' + return f"{a}x{b}" + # 3+ extents: show max extent only with polygon marker + return f"~{self._format_value(max(extents), unit)}" + + def __repr__(self) -> str: + return f"SizeObject({self.extents})" + + def __str__(self) -> str: + return self.to_display_string() + + class MagnitudeObject: UNKNOWN_MAG: float = 99 mags: List = [] @@ -48,9 +211,17 @@ def __repr__(self): @classmethod def from_json(cls, json_str): - data = json.loads(json_str) - obj = cls(data["mags"]) - return obj + if not json_str: + return cls([]) + try: + data = json.loads(json_str) + except (json.JSONDecodeError, TypeError): + # Legacy DB rows store mag as plain text (e.g. "12.5", "12.5/13.5"), + # not JSON. Degrade to an empty MagnitudeObject. + return cls([]) + if not isinstance(data, dict) or "mags" not in data: + return cls([]) + return cls(data["mags"]) @dataclass @@ -67,7 +238,7 @@ class CompositeObject: # dec in degrees, J2000 dec: float = field(default=0.0) const: str = field(default="") - size: str = field(default="") + size: "SizeObject" = field(default_factory=lambda: SizeObject([])) mag: MagnitudeObject = field(default=MagnitudeObject([])) mag_str: str = field(default="") catalog_code: str = field(default="") diff --git a/python/PiFinder/db/objects_db.py b/python/PiFinder/db/objects_db.py index b8ad3b50..95eaa3c2 100644 --- a/python/PiFinder/db/objects_db.py +++ b/python/PiFinder/db/objects_db.py @@ -18,9 +18,6 @@ def __init__(self, db_path=utils.pifinder_db): self.cursor.execute("PRAGMA mmap_size = 268435456;") # 256MB memory mapping self.cursor.execute("PRAGMA cache_size = -64000;") # 64MB cache (negative = KB) self.cursor.execute("PRAGMA temp_store = MEMORY;") # Keep temporary data in RAM - self.cursor.execute( - "PRAGMA journal_mode = WAL;" - ) # Write-ahead logging for better concurrency self.cursor.execute( "PRAGMA synchronous = NORMAL;" ) # Balanced safety/performance @@ -40,7 +37,7 @@ def create_tables(self): dec NUMERIC, const TEXT, size TEXT, - mag NUMERIC, + mag TEXT, surface_brightness NUMERIC ); """ diff --git a/python/PiFinder/gen_images.py b/python/PiFinder/gen_images.py index 81865cb4..9a2c5ef7 100644 --- a/python/PiFinder/gen_images.py +++ b/python/PiFinder/gen_images.py @@ -11,6 +11,7 @@ from PIL import Image, ImageOps from PiFinder.db.objects_db import ObjectsDatabase from PiFinder.catalogs import CompositeObject +from PiFinder.composite_object import SizeObject, MagnitudeObject BASE_IMAGE_PATH = "/Users/rich/Projects/Astronomy/PiFinder/astro_data/catalog_images" @@ -56,7 +57,10 @@ def fetch_object_image(_obj, low_cut=10): Returns image path """ - catalog_object = CompositeObject.from_dict(dict(_obj)) + obj_dict = dict(_obj) + obj_dict["size"] = SizeObject.from_json(obj_dict.get("size", "")) + obj_dict["mag"] = MagnitudeObject.from_json(obj_dict.get("mag", "")) + catalog_object = CompositeObject.from_dict(obj_dict) ra = catalog_object.ra dec = catalog_object.dec diff --git a/python/PiFinder/plot.py b/python/PiFinder/plot.py index 6a143d61..19489e68 100644 --- a/python/PiFinder/plot.py +++ b/python/PiFinder/plot.py @@ -42,18 +42,13 @@ def _load_raw_stars(): cache_dir = Path(utils.data_dir, "cache") pkl_path = cache_dir / "hip_main.pkl" - if ( - pkl_path.exists() - and pkl_path.stat().st_mtime >= dat_path.stat().st_mtime - ): + if pkl_path.exists() and pkl_path.stat().st_mtime >= dat_path.stat().st_mtime: try: _RAW_STARS_DF = pandas.read_pickle(pkl_path) logger.info("Loaded Hipparcos catalog from cache: %s", pkl_path) return _RAW_STARS_DF except Exception as e: - logger.warning( - "Hipparcos cache unreadable, reparsing %s: %s", dat_path, e - ) + logger.warning("Hipparcos cache unreadable, reparsing %s: %s", dat_path, e) logger.info("Parsing Hipparcos catalog from %s", dat_path) with load.open(str(dat_path)) as f: @@ -311,6 +306,32 @@ def plot_markers(self, marker_list): return ret_image + def project_vertices(self, vertices): + """Project RA/Dec vertex pairs to screen pixel coords. + + vertices: list of [ra_deg, dec_deg] pairs. + Returns list of (x, y) screen tuples. + """ + rows = [(Angle(degrees=ra)._hours, dec) for ra, dec in vertices] + df = pandas.DataFrame(rows, columns=["ra_hours", "dec_degrees"]) + df["epoch_year"] = 1991.25 + positions = self.earth.observe(Star.from_dataframe(df)) + df["x"], df["y"] = self.projection(positions) + + roll_rad = self.roll * (np.pi / 180) + roll_sin = np.sin(roll_rad) + roll_cos = np.cos(roll_rad) + + df = df.assign( + xr=df["x"] * roll_cos - df["y"] * roll_sin, + yr=df["y"] * roll_cos + df["x"] * roll_sin, + ) + df = df.assign( + x_pos=df["xr"] * self.pixel_scale + self.render_center[0], + y_pos=df["yr"] * -1 * self.pixel_scale + self.render_center[1], + ) + return list(zip(df["x_pos"], df["y_pos"])) + def update_projection(self, ra, dec): """ Updates the shared projection used for various plotting diff --git a/python/PiFinder/pos_server.py b/python/PiFinder/pos_server.py index 2ea42fb5..65f48043 100644 --- a/python/PiFinder/pos_server.py +++ b/python/PiFinder/pos_server.py @@ -16,7 +16,7 @@ from multiprocessing import Queue from typing import Tuple, Union from PiFinder.calc_utils import ra_to_deg, dec_to_deg, sf_utils -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject from PiFinder.multiproclogging import MultiprocLogging from skyfield.positionlib import position_of_radec import sys @@ -211,7 +211,7 @@ def handle_goto_command(shared_state, ra_parsed, dec_parsed): "ra": comp_ra, "dec": comp_dec, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": MagnitudeObject([]), "catalog_code": "PUSH", "sequence": sequence, diff --git a/python/PiFinder/ssd1333_device.py b/python/PiFinder/ssd1333_device.py index 5ac7c80d..ef81e676 100644 --- a/python/PiFinder/ssd1333_device.py +++ b/python/PiFinder/ssd1333_device.py @@ -51,48 +51,66 @@ class ssd1333(color_device): :type v_offset: int """ - def __init__(self, serial_interface=None, width=176, height=176, rotate=0, - framebuffer=None, h_offset=0, v_offset=0, - bgr=False, **kwargs): + def __init__( + self, + serial_interface=None, + width=176, + height=176, + rotate=0, + framebuffer=None, + h_offset=0, + v_offset=0, + bgr=False, + **kwargs, + ): # A[2] in remap register: 0=color sequence A-B-C, 1=swapped C-B-A self._color_order = 0x04 if bgr else 0x00 if h_offset != 0 or v_offset != 0: + def offset(bbox): left, top, right, bottom = bbox - return (left + h_offset, top + v_offset, - right + h_offset, bottom + v_offset) + return ( + left + h_offset, + top + v_offset, + right + h_offset, + bottom + v_offset, + ) + self._apply_offsets = offset - super(ssd1333, self).__init__(serial_interface, width, height, - rotate, framebuffer, **kwargs) + super(ssd1333, self).__init__( + serial_interface, width, height, rotate, framebuffer, **kwargs + ) def _supported_dimensions(self): return [(176, 176)] def _init_sequence(self): - self.command(0xFD, 0x12) # Unlock IC MCU interface - self.command(0xAE) # Display OFF (sleep mode on) - self.command(0xB3, 0xF1) # Front clock: osc freq max (0xF), divide by 2 - self.command(0xCA, 0xAF) # MUX ratio = 175 (176 lines) - self.command(0x15, 0x00, self._w - 1) # Set column address range - self.command(0x75, 0x00, self._h - 1) # Set row address range - self.command(0xA0, 0x70 | self._color_order) # Remap: 65k color, COM split odd even, - # COM scan reversed, color order - self.command(0xA1, 0x00) # Display start line = 0 - self.command(0xA2, 0x00) # Display offset = 0 - self.command(0xB1, 0x32) # Phase 1 = 4 DCLKs, Phase 2 = 6 DCLKs - self.command(0xBB, 0x17) # Pre-charge voltage = 0.40 x VCC - self.command(0xBE, 0x05) # VCOMH = 0.82 x VCC - self.command(0xC7, 0x0F) # Master contrast: no reduction (max) - self.command(0xB6, 0x08) # Second pre-charge period = 8 DCLKs - self.command(0xB9) # Use built-in linear LUT - self.command(0xA6) # Normal display mode + self.command(0xFD, 0x12) # Unlock IC MCU interface + self.command(0xAE) # Display OFF (sleep mode on) + self.command(0xB3, 0xF1) # Front clock: osc freq max (0xF), divide by 2 + self.command(0xCA, 0xAF) # MUX ratio = 175 (176 lines) + self.command(0x15, 0x00, self._w - 1) # Set column address range + self.command(0x75, 0x00, self._h - 1) # Set row address range + self.command( + 0xA0, 0x70 | self._color_order + ) # Remap: 65k color, COM split odd even, + # COM scan reversed, color order + self.command(0xA1, 0x00) # Display start line = 0 + self.command(0xA2, 0x00) # Display offset = 0 + self.command(0xB1, 0x32) # Phase 1 = 4 DCLKs, Phase 2 = 6 DCLKs + self.command(0xBB, 0x17) # Pre-charge voltage = 0.40 x VCC + self.command(0xBE, 0x05) # VCOMH = 0.82 x VCC + self.command(0xC7, 0x0F) # Master contrast: no reduction (max) + self.command(0xB6, 0x08) # Second pre-charge period = 8 DCLKs + self.command(0xB9) # Use built-in linear LUT + self.command(0xA6) # Normal display mode def _set_position(self, top, right, bottom, left): - self.command(0x15, left, right - 1) # Set column address - self.command(0x75, top, bottom - 1) # Set row address - self.command(0x5C) # Write RAM command + self.command(0x15, left, right - 1) # Set column address + self.command(0x75, top, bottom - 1) # Set row address + self.command(0x5C) # Write RAM command def contrast(self, level): """ diff --git a/python/PiFinder/ui/callbacks.py b/python/PiFinder/ui/callbacks.py index 71c5190b..bbf7b90a 100644 --- a/python/PiFinder/ui/callbacks.py +++ b/python/PiFinder/ui/callbacks.py @@ -22,7 +22,7 @@ from PiFinder.ui.base import UIModule from PiFinder.ui.textentry import UITextEntry from PiFinder.catalogs import CatalogFilter -from PiFinder.composite_object import CompositeObject, MagnitudeObject +from PiFinder.composite_object import CompositeObject, MagnitudeObject, SizeObject if TYPE_CHECKING: @@ -424,7 +424,7 @@ def create_custom_object_from_coords( "ra": ra_deg, "dec": dec_deg, "const": constellation, - "size": "", + "size": SizeObject([]), "mag": MagnitudeObject([]), "mag_str": "", "catalog_code": "USER", diff --git a/python/PiFinder/ui/chart.py b/python/PiFinder/ui/chart.py index 6a3c04ca..154cd8cb 100644 --- a/python/PiFinder/ui/chart.py +++ b/python/PiFinder/ui/chart.py @@ -62,6 +62,7 @@ def plot_markers(self): return marker_list = [] + vertex_objects = [] # is there a target? target = self.ui_state.target() @@ -69,12 +70,16 @@ def plot_markers(self): marker_list.append( (plot.Angle(degrees=target.ra)._hours, target.dec, "target") ) + if target.size.is_vertices: + vertex_objects.append(target) marker_brightness = self.config_object.get_option("chart_dso", 128) if marker_brightness == 0: return for obs_target in self.ui_state.observing_list(): + if obs_target.size.is_vertices: + vertex_objects.append(obs_target) marker = OBJ_TYPE_MARKERS.get(obs_target.obj_type) if marker: marker_list.append( @@ -100,6 +105,13 @@ def plot_markers(self): ) self.screen.paste(ImageChops.add(self.screen, marker_image)) + if vertex_objects: + line_color = self.colors.get(marker_brightness) + for obj in vertex_objects: + screen_pts = self.starfield.project_vertices(obj.size.extents) + if len(screen_pts) >= 2: + self.draw.line(screen_pts, fill=line_color, width=1) + def _draw_orientation_indicator(self, orientation: "ChartOrientation"): """ Draw a small "up" indicator at the top-left of the chart. diff --git a/python/PiFinder/ui/menu_structure.py b/python/PiFinder/ui/menu_structure.py index 75ba6794..932a0e3a 100644 --- a/python/PiFinder/ui/menu_structure.py +++ b/python/PiFinder/ui/menu_structure.py @@ -897,6 +897,45 @@ def _(key: str) -> Any: }, ], }, + { + "name": _("Image..."), + "class": UITextMenu, + "select": "single", + "items": [ + { + "name": _("NSEW Labels"), + "class": UITextMenu, + "select": "single", + "config_option": "image_nsew", + "items": [ + { + "name": _("On"), + "value": True, + }, + { + "name": _("Off"), + "value": False, + }, + ], + }, + { + "name": _("Object Size"), + "class": UITextMenu, + "select": "single", + "config_option": "image_bbox", + "items": [ + { + "name": _("On"), + "value": True, + }, + { + "name": _("Off"), + "value": False, + }, + ], + }, + ], + }, { "name": _("Camera Exp"), "class": UITextMenu, @@ -1090,23 +1129,25 @@ def _(key: str) -> Any: "post_callback": callbacks.restart_pifinder, "items": [ { - "name": _("Off"), # TRANSLATORS: IMU sensitivity setting + "name": _("Off"), # TRANSLATORS: IMU sensitivity setting "value": 100, }, { - "name": _("Very Low"), # TRANSLATORS: IMU sensitivity setting + "name": _( + "Very Low" + ), # TRANSLATORS: IMU sensitivity setting "value": 3, }, { - "name": _("Low"), # TRANSLATORS: IMU sensitivity setting + "name": _("Low"), # TRANSLATORS: IMU sensitivity setting "value": 2, }, { - "name": _("Medium"), # TRANSLATORS: IMU sensitivity setting + "name": _("Medium"), # TRANSLATORS: IMU sensitivity setting "value": 1, }, { - "name": _("High"), # TRANSLATORS: IMU sensitivity setting + "name": _("High"), # TRANSLATORS: IMU sensitivity setting "value": 0.5, }, ], diff --git a/python/PiFinder/ui/object_details.py b/python/PiFinder/ui/object_details.py index 6430407a..01b4b3c0 100644 --- a/python/PiFinder/ui/object_details.py +++ b/python/PiFinder/ui/object_details.py @@ -9,6 +9,7 @@ from pydeepskylog.exceptions import InvalidParameterError from PiFinder import cat_images +from PiFinder.composite_object import MagnitudeObject from PiFinder.ui.marking_menus import MarkingMenuOption, MarkingMenu from PiFinder.obj_types import OBJ_TYPES from PiFinder.ui.align import align_on_radec @@ -232,26 +233,29 @@ def update_object_info(self): self.config_object.equipment.active_telescope, self.config_object.equipment.active_eyepiece, ) - if self.object.mag_str == "-": + mag = self.object.mag + magnitude = ( + mag.filter_mag + if mag is not None and mag.filter_mag != MagnitudeObject.UNKNOWN_MAG + else None + ) + if magnitude is None: self.contrast = "" else: try: - if self.object.size: - # Check if the size contains 'x' - if "x" in self.object.size: - diameter1, diameter2 = map( - float, self.object.size.split("x") - ) - diameter1 = ( - diameter1 * 60.0 - ) # Convert arc seconds to arc minutes - diameter2 = diameter2 * 60.0 - elif "'" in self.object.size: - # Convert arc minutes to arc seconds - diameter1 = float(self.object.size.replace("'", "")) * 60.0 - diameter2 = diameter1 + size = self.object.size + if ( + size + and size.extents + and not size.is_vertices + and not size.is_segments + ): + # SizeObject.extents are stored in arcseconds. + if len(size.extents) >= 2: + diameter1 = float(size.extents[0]) + diameter2 = float(size.extents[1]) else: - diameter1 = diameter2 = float(self.object.size) * 60.0 + diameter1 = diameter2 = float(size.extents[0]) else: diameter1 = diameter2 = None @@ -260,7 +264,7 @@ def update_object_info(self): telescope_diameter=self.config_object.equipment.active_telescope.aperture_mm, magnification=magnification, surf_brightness=None, - magnitude=float(self.object.mag_str), + magnitude=magnitude, object_diameter1=diameter1, object_diameter2=diameter2, ) @@ -333,6 +337,8 @@ def update_object_info(self): self.display_class, burn_in=self.object_display_mode in [DM_POSS, DM_SDSS], magnification=magnification, + show_nsew=self.config_object.get_option("image_nsew", True), + show_bbox=self.config_object.get_option("image_bbox", True), flip_image=flip_image, flop_image=flop_image, ) diff --git a/python/PiFinder/ui/object_list.py b/python/PiFinder/ui/object_list.py index 0f1bdfd3..5f58b830 100644 --- a/python/PiFinder/ui/object_list.py +++ b/python/PiFinder/ui/object_list.py @@ -398,7 +398,8 @@ def create_aka_text(self, obj: CompositeObject) -> str: def create_info_text(self, obj: CompositeObject) -> str: obj_mag = self._safe_obj_mag(obj) mag = f"m{obj_mag:2.0f}" if obj_mag != MagnitudeObject.UNKNOWN_MAG else "m--" - size = f"{self.ruler}{obj.size.strip()}" if obj.size.strip() else "" + size_str = str(obj.size) + size = f"{self.ruler}{size_str}" if size_str else "" check = f" {self.checkmark}" if obj.logged else "" size_logged = f"{mag} {size}{check}" if len(size_logged) > 12: diff --git a/python/PiFinder/utils.py b/python/PiFinder/utils.py index d1d0b0c3..1f8c45fb 100644 --- a/python/PiFinder/utils.py +++ b/python/PiFinder/utils.py @@ -159,27 +159,3 @@ def is_number(s): return True except (ValueError, TypeError): return False - - -def format_size_value(value): - """ - Format a size value, removing unnecessary .0 decimals but preserving meaningful decimals. - - Examples: - 17.0 -> "17" - 17.5 -> "17.5" - 17.25 -> "17.3" (rounded to 1 decimal) - """ - if value is None or value == "": - return "" - - try: - num_val = float(value) - # If it's a whole number, return as integer - if num_val == int(num_val): - return str(int(num_val)) - # Otherwise, round to 1 decimal and remove trailing zeros - formatted = f"{num_val:.1f}" - return formatted.rstrip("0").rstrip(".") - except (ValueError, TypeError): - return str(value) # Return as-is if not a number diff --git a/python/tests/test_cat_images.py b/python/tests/test_cat_images.py new file mode 100644 index 00000000..dde38653 --- /dev/null +++ b/python/tests/test_cat_images.py @@ -0,0 +1,297 @@ +import math +import pytest +from PiFinder.cat_images import ( + cardinal_vectors, + size_overlay_points, + vertex_overlay_points, +) +from PiFinder.composite_object import SizeObject + + +def approx_pt(pt, abs=1e-6): + return pytest.approx(pt, abs=abs) + + +# --- cardinal_vectors --- + + +@pytest.mark.unit +class TestCardinalVectors: + def test_no_rotation(self): + """image_rotate=0: POSS north-up, east-left → N at (0, -1), E at (-1, 0).""" + (nx, ny), (ex, ey) = cardinal_vectors(0) + assert (nx, ny) == approx_pt((0, -1)) + assert (ex, ey) == approx_pt((-1, 0)) + + def test_180_rotation(self): + """image_rotate=180: N flips to (0, 1), E to (1, 0).""" + (nx, ny), (ex, ey) = cardinal_vectors(180) + assert (nx, ny) == approx_pt((0, 1)) + assert (ex, ey) == approx_pt((1, 0)) + + def test_90_rotation(self): + """image_rotate=90: N at (1, 0), E at (0, -1).""" + (nx, ny), (ex, ey) = cardinal_vectors(90) + assert (nx, ny) == approx_pt((1, 0)) + assert (ex, ey) == approx_pt((0, -1)) + + def test_flip_mirrors_x(self): + """flip negates x components of both vectors.""" + (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1) + assert (nx, ny) == approx_pt((0, -1)) + assert (ex, ey) == approx_pt((1, 0)) + + def test_flop_mirrors_y(self): + """flop negates y components of both vectors.""" + (nx, ny), (ex, ey) = cardinal_vectors(0, fy=-1) + assert (nx, ny) == approx_pt((0, 1)) + assert (ex, ey) == approx_pt((-1, 0)) + + def test_flip_and_flop(self): + """Both flip and flop: equivalent to 180° rotation of vectors.""" + (nx, ny), (ex, ey) = cardinal_vectors(0, fx=-1, fy=-1) + assert (nx, ny) == approx_pt((0, 1)) + assert (ex, ey) == approx_pt((1, 0)) + + def test_orthogonality(self): + """N and E should always be perpendicular.""" + for angle in [0, 45, 90, 135, 180, 270]: + for fx, fy in [(1, 1), (-1, 1), (1, -1), (-1, -1)]: + (nx, ny), (ex, ey) = cardinal_vectors(angle, fx, fy) + dot = nx * ex + ny * ey + assert dot == pytest.approx( + 0, abs=1e-10 + ), f"Not orthogonal at angle={angle}, fx={fx}, fy={fy}" + + def test_unit_length(self): + """N and E vectors should have unit length.""" + for angle in [0, 30, 45, 90, 180, 270]: + (nx, ny), (ex, ey) = cardinal_vectors(angle) + assert math.hypot(nx, ny) == pytest.approx(1) + assert math.hypot(ex, ey) == pytest.approx(1) + + +# --- size_overlay_points --- + + +@pytest.mark.unit +class TestSizeOverlayPoints: + def test_single_extent_returns_none(self): + """1 extent → None (caller uses native ellipse).""" + assert size_overlay_points([100], 0, 0, 1.0, 64, 64) is None + + def test_empty_returns_none(self): + assert size_overlay_points([], 0, 0, 1.0, 64, 64) is None + + def test_two_extents_point_count(self): + """2 extents → 36-point ellipse polygon.""" + pts = size_overlay_points([120, 60], 0, 0, 1.0, 64, 64) + assert len(pts) == 36 + + def test_two_extents_centered(self): + """Ellipse centroid should be at (cx, cy).""" + cx, cy = 64, 64 + pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) + avg_x = sum(p[0] for p in pts) / len(pts) + avg_y = sum(p[1] for p in pts) / len(pts) + assert avg_x == pytest.approx(cx, abs=0.1) + assert avg_y == pytest.approx(cy, abs=0.1) + + def test_two_extents_symmetry(self): + """No rotation, no PA: major axis aligned with North (vertical).""" + cx, cy = 64, 64 + pts = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) + xs = [p[0] - cx for p in pts] + ys = [p[1] - cy for p in pts] + # PA=0 → major axis along North → vertical + assert max(abs(x) for x in xs) == pytest.approx(30, abs=0.5) + assert max(abs(y) for y in ys) == pytest.approx(60, abs=0.5) + + def test_two_extents_rotation(self): + """90° image rotation moves major axis from vertical to horizontal.""" + cx, cy = 64, 64 + pts = size_overlay_points([120, 60], 0, 90, 1.0, cx, cy) + xs = [p[0] - cx for p in pts] + ys = [p[1] - cy for p in pts] + # 90° rotation: North moves to +X, major axis now horizontal + assert max(abs(x) for x in xs) == pytest.approx(60, abs=0.5) + assert max(abs(y) for y in ys) == pytest.approx(30, abs=0.5) + + def test_position_angle(self): + """PA=90 rotates opposite to image_rotate (PA goes N→E, image_rotate goes CW).""" + cx, cy = 64, 64 + pts_rot = size_overlay_points([120, 60], 0, 270, 1.0, cx, cy) + pts_pa = size_overlay_points([120, 60], 90, 0, 1.0, cx, cy) + for a, b in zip(pts_rot, pts_pa): + assert a[0] == pytest.approx(b[0], abs=1e-6) + assert a[1] == pytest.approx(b[1], abs=1e-6) + + def test_pa90_aligns_with_east(self): + """PA=90° major axis must align with the East vector from cardinal_vectors.""" + cx, cy = 64, 64 + for rot in [0, 90, 180, 270]: + _, (ex, ey) = cardinal_vectors(rot) + pts = size_overlay_points([200, 40], 90, rot, 1.0, cx, cy) + dists = [(p[0] - cx, p[1] - cy) for p in pts] + farthest = max(dists, key=lambda d: math.hypot(*d)) + direction = ( + farthest[0] / math.hypot(*farthest), + farthest[1] / math.hypot(*farthest), + ) + dot = abs(direction[0] * ex + direction[1] * ey) + assert dot == pytest.approx( + 1.0, abs=0.02 + ), f"PA=90 major axis not along East at image_rotate={rot}" + + def test_pa0_aligns_with_north(self): + """PA=0 major axis must align with the North vector from cardinal_vectors.""" + cx, cy = 64, 64 + for rot in [0, 90, 180, 270]: + (nx, ny), _ = cardinal_vectors(rot) + pts = size_overlay_points([200, 40], 0, rot, 1.0, cx, cy) + # Find the point farthest from center — should be along North + dists = [(p[0] - cx, p[1] - cy) for p in pts] + farthest = max(dists, key=lambda d: math.hypot(*d)) + direction = ( + farthest[0] / math.hypot(*farthest), + farthest[1] / math.hypot(*farthest), + ) + # Should be parallel to North (same or opposite direction) + dot = abs(direction[0] * nx + direction[1] * ny) + assert dot == pytest.approx( + 1.0, abs=0.02 + ), f"PA=0 major axis not along North at image_rotate={rot}" + + def test_flip_mirrors_x(self): + """fx=-1 mirrors all points horizontally around cx.""" + cx, cy = 64, 64 + pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) + pts_flip = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fx=-1) + for a, b in zip(pts_normal, pts_flip): + assert a[0] - cx == pytest.approx(-(b[0] - cx), abs=1e-6) + assert a[1] == pytest.approx(b[1], abs=1e-6) + + def test_flop_mirrors_y(self): + """fy=-1 mirrors all points vertically around cy.""" + cx, cy = 64, 64 + pts_normal = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy) + pts_flop = size_overlay_points([120, 60], 30, 180, 1.0, cx, cy, fy=-1) + for a, b in zip(pts_normal, pts_flop): + assert a[0] == pytest.approx(b[0], abs=1e-6) + assert a[1] - cy == pytest.approx(-(b[1] - cy), abs=1e-6) + + def test_three_extents_point_count(self): + """3+ extents → polygon with len(extents) points.""" + pts = size_overlay_points([100, 80, 60, 90], 0, 0, 1.0, 64, 64) + assert len(pts) == 4 + + def test_px_per_arcsec_scaling(self): + """Doubling px_per_arcsec doubles the distance from center.""" + cx, cy = 64, 64 + pts1 = size_overlay_points([120, 60], 0, 0, 1.0, cx, cy) + pts2 = size_overlay_points([120, 60], 0, 0, 2.0, cx, cy) + for a, b in zip(pts1, pts2): + assert (b[0] - cx) == pytest.approx(2 * (a[0] - cx), abs=1e-6) + assert (b[1] - cy) == pytest.approx(2 * (a[1] - cy), abs=1e-6) + + +# --- SizeObject vertex mode --- + + +@pytest.mark.unit +class TestSizeObjectVertices: + def test_from_vertices_stores_nested_pairs(self): + verts = [[10.0, 20.0], [10.1, 20.1], [10.2, 20.0]] + s = SizeObject.from_vertices(verts) + assert s.extents == verts + assert s.position_angle == 0.0 + + def test_is_vertices_true(self): + s = SizeObject.from_vertices([[10.0, 20.0], [10.1, 20.1]]) + assert s.is_vertices is True + + def test_is_vertices_false_for_numeric(self): + s = SizeObject.from_arcsec(100, 50) + assert s.is_vertices is False + + def test_is_vertices_false_for_empty(self): + s = SizeObject([]) + assert s.is_vertices is False + + def test_max_extent_arcsec_same_dec(self): + """Two points at same dec, 1° apart in RA at dec=0.""" + s = SizeObject.from_vertices([[10.0, 0.0], [11.0, 0.0]]) + expected = 3600.0 # 1 degree = 3600 arcsec + assert s.max_extent_arcsec == pytest.approx(expected, rel=1e-3) + + def test_max_extent_arcsec_same_ra(self): + """Two points at same RA, 0.5° apart in dec.""" + s = SizeObject.from_vertices([[10.0, 20.0], [10.0, 20.5]]) + expected = 1800.0 # 0.5 degree + assert s.max_extent_arcsec == pytest.approx(expected, rel=1e-3) + + def test_max_extent_arcsec_numeric_fallback(self): + s = SizeObject.from_arcsec(100, 200, 150) + assert s.max_extent_arcsec == 200 + + def test_to_display_string_vertices(self): + """Vertex mode shows ~span format.""" + s = SizeObject.from_vertices([[10.0, 20.0], [10.0, 20.5]]) + display = s.to_display_string() + assert display.startswith("~") + assert "'" in display # 1800 arcsec = 30 arcmin + + def test_json_roundtrip(self): + verts = [[10.0, 20.0], [10.1, 20.1]] + s = SizeObject.from_vertices(verts) + s2 = SizeObject.from_json(s.to_json()) + assert s2.is_vertices is True + assert s2.extents == verts + + +# --- vertex_overlay_points --- + + +@pytest.mark.unit +class TestVertexOverlayPoints: + def test_center_vertex_at_center(self): + """A vertex at the object center projects to (cx, cy).""" + pts = vertex_overlay_points([[10.0, 20.0]], 10.0, 20.0, 0, 1.0, 64, 64) + assert len(pts) == 1 + assert pts[0][0] == pytest.approx(64, abs=0.1) + assert pts[0][1] == pytest.approx(64, abs=0.1) + + def test_offset_vertex_north(self): + """A vertex 100" north of center should appear above center (lower y).""" + dec_offset = 100.0 / 3600.0 # 100 arcsec in degrees + pts = vertex_overlay_points( + [[10.0, 20.0 + dec_offset]], 10.0, 20.0, 0, 1.0, 64, 64 + ) + # image_rotate=0: POSS has N at top of raw image but after + # the 180+roll rotation in get_display_image, here we test + # raw projection + assert len(pts) == 1 + # With image_rotate=0 and no flip, north (positive dec) goes to negative dy + assert pts[0][1] < 64 + + def test_two_vertices_produce_two_points(self): + pts = vertex_overlay_points( + [[10.0, 20.0], [10.01, 20.01]], 10.0, 20.0, 0, 1.0, 64, 64 + ) + assert len(pts) == 2 + + def test_scaling(self): + """Doubling px_per_arcsec doubles offset from center.""" + dec_off = 100.0 / 3600.0 + pts1 = vertex_overlay_points( + [[10.0, 20.0 + dec_off]], 10.0, 20.0, 0, 1.0, 64, 64 + ) + pts2 = vertex_overlay_points( + [[10.0, 20.0 + dec_off]], 10.0, 20.0, 0, 2.0, 64, 64 + ) + dx1 = pts1[0][0] - 64 + dy1 = pts1[0][1] - 64 + dx2 = pts2[0][0] - 64 + dy2 = pts2[0][1] - 64 + assert dx2 == pytest.approx(2 * dx1, abs=0.1) + assert dy2 == pytest.approx(2 * dy1, abs=0.1) diff --git a/python/tests/test_chart.py b/python/tests/test_chart.py index 53f062c2..a2a923c0 100644 --- a/python/tests/test_chart.py +++ b/python/tests/test_chart.py @@ -50,9 +50,7 @@ def test_eq_auto_southern_with_gps(self): def test_eq_auto_no_gps_falls_back_to_ncp(self): # No location lock yet: northern-hemisphere default, but flagged. - result = get_chart_rotation_angle( - 10.0, 20.0, "eq_auto", location=_no_gps() - ) + result = get_chart_rotation_angle(10.0, 20.0, "eq_auto", location=_no_gps()) assert result == ChartOrientation(0.0, "NCP", True) def test_eq_auto_lockless_location_object_is_fallback(self): @@ -80,7 +78,9 @@ def test_horiz_with_gps_and_datetime_is_zenith(self): import datetime as _dt result = get_chart_rotation_angle( - 10.0, 20.0, "horiz", + 10.0, + 20.0, + "horiz", location=_gps_at(lat=45.0, lon=-75.0, altitude=100.0), dt=_dt.datetime(2024, 6, 1, 22, 0, 0, tzinfo=_dt.timezone.utc), ) diff --git a/python/tests/test_size_parsing.py b/python/tests/test_size_parsing.py new file mode 100644 index 00000000..31dc2f36 --- /dev/null +++ b/python/tests/test_size_parsing.py @@ -0,0 +1,97 @@ +"""Unit tests for the catalog size-string parser (parse_arcmin_size). + +These guard the regressions seen during a catalog regen where tokens carrying +unit suffixes (', ", °) or '/' '+' separators were dropped or mangled. +""" + +import logging + +import pytest + +from PiFinder.catalog_imports.catalog_import_utils import parse_arcmin_size + + +@pytest.mark.unit +def test_empty_string_is_unknown(): + assert parse_arcmin_size("").extents == [] + + +@pytest.mark.unit +def test_bare_number_defaults_to_arcmin(): + # 5 arcmin -> 300 arcsec + assert parse_arcmin_size("5").extents == pytest.approx([300.0]) + + +@pytest.mark.unit +def test_arcmin_prime_suffix_stripped(): + # Taas200 "32'x6.5'" was previously dropped entirely. + assert parse_arcmin_size("32'x6.5'").extents == pytest.approx([1920.0, 390.0]) + + +@pytest.mark.unit +def test_arcsec_double_prime_is_arcsec_not_arcmin(): + # EGC "36"" is arcseconds; must NOT be multiplied to arcmin. + assert parse_arcmin_size('36"').extents == pytest.approx([36.0]) + assert parse_arcmin_size('2.7"').extents == pytest.approx([2.7]) + + +@pytest.mark.unit +def test_degree_suffix(): + # 35° -> 126000 arcsec + assert parse_arcmin_size("35°").extents == pytest.approx([126000.0]) + + +@pytest.mark.unit +def test_slash_separator(): + # Caldwell "0.3/5.8" -> two arcmin extents. + assert parse_arcmin_size("0.3/5.8").extents == pytest.approx([18.0, 348.0]) + + +@pytest.mark.unit +def test_plus_separator(): + assert parse_arcmin_size("30'+30'").extents == pytest.approx([1800.0, 1800.0]) + + +@pytest.mark.unit +def test_unicode_times_separator(): + assert parse_arcmin_size("5×3").extents == pytest.approx([300.0, 180.0]) + + +@pytest.mark.unit +def test_truncated_mixed_token_keeps_both_axes(): + # Fixed-width truncation dropped the closing prime: "8.1'x2.6". + # Previously stored as a round 2.6' object (major axis lost); + # now both axes survive (8.1' major default-arcmin minor). + assert parse_arcmin_size("8.1'x2.6").extents == pytest.approx([486.0, 156.0]) + + +@pytest.mark.unit +def test_trailing_dot_float(): + # "13.0'x6." -> 13.0' x 6.0' + assert parse_arcmin_size("13.0'x6.").extents == pytest.approx([780.0, 360.0]) + + +@pytest.mark.unit +def test_interspersed_word_is_skipped_but_numbers_survive(): + # "30 and 30" warns on 'and' but still yields two extents. + assert parse_arcmin_size("30 and 30").extents == pytest.approx([1800.0, 1800.0]) + + +@pytest.mark.unit +@pytest.mark.parametrize("raw", ["nl", "n/a", "see Tirion"]) +def test_non_numeric_only_is_unknown(raw): + assert parse_arcmin_size(raw).extents == [] + + +@pytest.mark.unit +def test_non_numeric_token_warns(caplog): + with caplog.at_level(logging.WARNING): + parse_arcmin_size("nl") + assert any("Non-numeric size token" in r.message for r in caplog.records) + + +@pytest.mark.unit +def test_clean_input_emits_no_warning(caplog): + with caplog.at_level(logging.WARNING): + parse_arcmin_size("32'x6.5'") + assert not any("Non-numeric size token" in r.message for r in caplog.records)