diff --git a/tom_base/settings.py b/tom_base/settings.py index 487fdda77..0748c23bd 100644 --- a/tom_base/settings.py +++ b/tom_base/settings.py @@ -287,6 +287,11 @@ 'tom_observations.cadences.resume_cadence_after_failure.ResumeCadenceAfterFailureStrategy' ] +DATA_SERVICES = { + 'Scout': { + 'base_url': 'https://ssd-api.jpl.nasa.gov/scout.api', + }, +} # Define extra target fields here. Types can be any of "number", "string", "boolean" or "datetime" # See https://tomtoolkit.github.io/docs/target_fields for documentation on this feature # For example: diff --git a/tom_catalogs/tests/harvesters/test_jplhorizons.py b/tom_catalogs/tests/harvesters/test_jplhorizons.py index 097406d07..26f8b734e 100644 --- a/tom_catalogs/tests/harvesters/test_jplhorizons.py +++ b/tom_catalogs/tests/harvesters/test_jplhorizons.py @@ -9,36 +9,37 @@ def setUp(self): self.broker = JPLHorizonsHarvester() def test_query_number_only(self): - self.broker.query('700000') + self.broker.query('69420') target = self.broker.to_target() target.save(names=getattr(target, 'extra_names', [])) # Only test things that are not likely to change (much) with time - self.assertEqual(target.name, '700000 (1994 UX10)') - self.assertEqual(target.names, ['700000 (1994 UX10)']) + self.assertEqual(target.name, '69420 (1995 YA1)') + self.assertEqual(target.names, ['69420 (1995 YA1)']) self.assertEqual(target.type, 'NON_SIDEREAL') self.assertEqual(target.scheme, 'MPC_MINOR_PLANET') self.assertEqual(target.ra, None) self.assertEqual(target.dec, None) - self.assertAlmostEqual(target.eccentricity, 0.093, places=3) - self.assertAlmostEqual(target.inclination, 4.1684084, places=3) - self.assertAlmostEqual(target.semimajor_axis, 2.657503, places=3) - self.assertAlmostEqual(target.abs_mag, 17.76, places=2) + self.assertAlmostEqual(target.eccentricity, 0.255042, places=4) + self.assertAlmostEqual(target.inclination, 13.208657, places=3) + self.assertAlmostEqual(target.semimajor_axis, 2.58835, places=3) + self.assertAlmostEqual(target.abs_mag, 15.09, places=2) self.assertAlmostEqual(target.slope, 0.15, places=2) def test_query_designation_only(self): - self.broker.query('2025 MB18') + self.broker.query('1995 YA1') target = self.broker.to_target() target.save(names=getattr(target, 'extra_names', [])) # Only test things that are not likely to change (much) with time - self.assertEqual(target.name, '(2025 MB18)') - self.assertEqual(target.names, ['(2025 MB18)']) + self.assertEqual(target.name, '69420 (1995 YA1)') + self.assertEqual(target.names, ['69420 (1995 YA1)']) self.assertEqual(target.type, 'NON_SIDEREAL') self.assertEqual(target.scheme, 'MPC_MINOR_PLANET') self.assertEqual(target.ra, None) self.assertEqual(target.dec, None) - self.assertAlmostEqual(target.eccentricity, 0.1386, places=4) - self.assertAlmostEqual(target.inclination, 19.2780, places=4) - self.assertAlmostEqual(target.abs_mag, 24.33, places=2) + self.assertAlmostEqual(target.eccentricity, 0.255042, places=4) + self.assertAlmostEqual(target.inclination, 13.208657, places=3) + self.assertAlmostEqual(target.semimajor_axis, 2.58835, places=3) + self.assertAlmostEqual(target.abs_mag, 15.09, places=2) self.assertAlmostEqual(target.slope, 0.15, places=2) def test_query_name(self): @@ -58,15 +59,15 @@ def test_query_name(self): self.assertAlmostEqual(target.slope, 0.60, places=2) def test_comet_query_desig(self): - self.broker.query('C/2025 A6') + self.broker.query('P/2012 B1') target = self.broker.to_target() target.save(names=getattr(target, 'extra_names', [])) # Only test things that are not likely to change (much) with time - self.assertEqual(target.name, 'Lemmon (C/2025 A6)') - self.assertEqual(target.names, ['Lemmon (C/2025 A6)']) + self.assertEqual(target.name, 'PANSTARRS (P/2012 B1)') + self.assertEqual(target.names, ['PANSTARRS (P/2012 B1)']) self.assertEqual(target.type, 'NON_SIDEREAL') self.assertEqual(target.scheme, 'MPC_COMET') self.assertEqual(target.ra, None) self.assertEqual(target.dec, None) - self.assertAlmostEqual(target.eccentricity, 0.9956, places=4) - self.assertAlmostEqual(target.inclination, 143.663531, places=3) + self.assertAlmostEqual(target.eccentricity, 0.410531, places=4) + self.assertAlmostEqual(target.inclination, 7.605125, places=3) diff --git a/tom_catalogs/tests/harvesters/test_mpc.py b/tom_catalogs/tests/harvesters/test_mpc.py index 82135a6ae..6cf364409 100644 --- a/tom_catalogs/tests/harvesters/test_mpc.py +++ b/tom_catalogs/tests/harvesters/test_mpc.py @@ -185,37 +185,37 @@ def setUp(self): self.broker = MPCHarvester() def test_query_number_only(self): - self.broker.query('700000') + self.broker.query('69420') target = self.broker.to_target() target.save(names=getattr(target, 'extra_names', [])) # Only test things that are not likely to change (much) with time - self.assertEqual(target.name, '700000') - self.assertEqual(target.names, ['700000']) + self.assertEqual(target.name, '69420') + self.assertEqual(target.names, ['69420']) self.assertEqual(target.type, 'NON_SIDEREAL') self.assertEqual(target.scheme, 'MPC_MINOR_PLANET') self.assertEqual(target.ra, None) self.assertEqual(target.dec, None) - self.assertAlmostEqual(target.eccentricity, 0.092, places=3) - self.assertAlmostEqual(target.inclination, 4.1688, places=4) - self.assertAlmostEqual(target.mean_anomaly, 315.8420, places=4) - self.assertAlmostEqual(target.semimajor_axis, 2.6555, places=4) - self.assertAlmostEqual(target.abs_mag, 17.76, places=2) + self.assertAlmostEqual(target.eccentricity, 0.255042, places=4) + self.assertAlmostEqual(target.inclination, 13.208657, places=3) + self.assertAlmostEqual(target.semimajor_axis, 2.58835, places=3) + self.assertAlmostEqual(target.abs_mag, 15.09, places=2) self.assertAlmostEqual(target.slope, 0.15, places=2) def test_query_designation_only(self): - self.broker.query('2025 MB18') + self.broker.query('1998 WB2') target = self.broker.to_target() target.save(names=getattr(target, 'extra_names', [])) # Only test things that are not likely to change (much) with time - self.assertEqual(target.name, '2025 MB18') - self.assertEqual(target.names, ['2025 MB18']) + self.assertEqual(target.name, '1998 WB2') + self.assertEqual(target.names, ['1998 WB2']) self.assertEqual(target.type, 'NON_SIDEREAL') self.assertEqual(target.scheme, 'MPC_MINOR_PLANET') self.assertEqual(target.ra, None) self.assertEqual(target.dec, None) - self.assertAlmostEqual(target.eccentricity, 0.1398, places=4) - self.assertAlmostEqual(target.inclination, 19.3561, places=4) - self.assertAlmostEqual(target.abs_mag, 24.33, places=2) + self.assertAlmostEqual(target.eccentricity, 0.586622, places=4) + self.assertAlmostEqual(target.inclination, 2.37248, places=3) + self.assertAlmostEqual(target.semimajor_axis, 1.97247, places=3) + self.assertAlmostEqual(target.abs_mag, 21.85, places=2) self.assertAlmostEqual(target.slope, 0.15, places=2) def test_query_name(self): diff --git a/tom_dataservices/apps.py b/tom_dataservices/apps.py index 91a0c1d23..1dc97e2d2 100644 --- a/tom_dataservices/apps.py +++ b/tom_dataservices/apps.py @@ -34,5 +34,6 @@ def data_services(self): data_services = [ {'class': f'{self.name}.data_services.simbad.SimbadDataService'}, {'class': f'{self.name}.data_services.tns.TNSDataService'}, + {'class': f'{self.name}.data_services.jpl.ScoutDataService'}, ] return data_services diff --git a/tom_dataservices/data_services/jpl.py b/tom_dataservices/data_services/jpl.py new file mode 100644 index 000000000..9337514a1 --- /dev/null +++ b/tom_dataservices/data_services/jpl.py @@ -0,0 +1,361 @@ +from math import sqrt, degrees +from typing import List +from dateutil.parser import parse +from dateutil.tz import tzutc + +from astropy.constants import GM_sun, au +from django import forms +from django.db import models +import logging +import requests +# import pprint + +from tom_dataservices.dataservices import DataService +from tom_dataservices.forms import BaseQueryForm +from tom_targets.models import BaseTarget, Target + +logger = logging.getLogger(__name__) + + +class ScoutDetail(models.Model): + class ScoutImpactRating(models.IntegerChoices): + NEGLIGIBLE = 0, 'Negligible' + SMALL = 1, 'Small' + MODEST = 2, 'Modest' + MODERATE = 3, 'Moderate' + ELEVATED = 4, 'Elevated' + target = models.OneToOneField(BaseTarget, on_delete=models.CASCADE, related_name='scout_detail') + num_obs = models.IntegerField(null=True, blank=True, help_text='Number of observations') + neo_score = models.IntegerField(null=True, blank=True, help_text='NEO digest score (0..100)') + neo1km_score = models.IntegerField(null=True, blank=True, help_text='NEO >1km digest score (0..100)') + pha_score = models.IntegerField(null=True, blank=True, help_text='PHA digest score (0..100)') + ieo_score = models.IntegerField(null=True, blank=True, help_text='IEO digest score (0..100)') + geocentric_score = models.IntegerField(null=True, blank=True, help_text='Geocentric digest score (0..100)') + impact_rating = models.IntegerField(null=True, blank=True, choices=ScoutImpactRating.choices, + help_text='Impact rating (0=negligible, 1=small, 2=modest, 3=moderate, ' + '4=elevated)') + ca_dist = models.FloatField(null=True, blank=True, help_text='Close approach distance (lunar distances)') + arc = models.FloatField(null=True, blank=True, help_text='Arc length (days)') + rms = models.FloatField(null=True, blank=True, help_text='RMS of the residuals to the orbit fit (arcsec)') + uncertainty = models.FloatField(null=True, blank=True, + help_text='1-sigma plane-of-sky positional uncertainty (arcmin)') + uncertainty_p1 = models.FloatField(null=True, blank=True, + help_text='1-sigma plane-of-sky positional uncertainty at +1 day (arcmin)') + last_run = models.DateTimeField(null=True, blank=True, help_text='Last time the data was updated from Scout') + + class Meta: + verbose_name = 'Scout Detail' + + def __str__(self): + return self.target.name + f' (Impact rating: {self.impact_rating})' + + +class ScoutForm(BaseQueryForm): + tdes = forms.CharField(required=False, + label='NEOCP temporary designation') + neo_score_min = forms.IntegerField(required=False, min_value=0, max_value=100, + label='Minimum NEO digest score (0..100)', + help_text='Minimum NEO digest score (0..100) permissible') + pha_score_min = forms.IntegerField(required=False, min_value=0, max_value=100, + label='Minimum PHA digest score (0..100)', + help_text='Minimum PHA digest score (0..100) permissible') + geo_score_max = forms.IntegerField(required=False, initial=5, min_value=0, max_value=100, + label='Maximum GEO digest score (0..100)', + help_text='Maximum Geocentric digest score (0..100) permissible') + help_text = 'Rating to character the chances of an Earth impact ' + help_text += '(0=negligible, 1=small, 2=modest, 3=moderate, 4=elevated)' + impact_rating_min = forms.IntegerField(required=False, min_value=0, max_value=4, + label='Minimum impact rating (0..4)', + help_text=help_text) + ca_dist_min = forms.FloatField(required=False, + label='Minimum CA distance (LD)', + help_text='Minimum close approach distance (lunar distances)') + pos_unc_min = forms.FloatField(required=False, + label='Minimum positional uncertainty (arcmin)') + pos_unc_max = forms.FloatField(required=False, + label='Maximum positional uncertainty (arcmin)') + + def simple_fields(self) -> List[str]: + return ['tdes', 'neo_score_min'] + + +class ScoutDataService(DataService): + """ + Docstring for ScoutDataService + """ + name = 'Scout' + app_version = '0.0.4' + info_url = 'https://cneos.jpl.nasa.gov/scout/intro.html' + query_results_table = 'tom_dataservices/scout/partials/scout_query_results_table.html' + expected_signature = {'source': 'NASA/JPL Scout API', 'version': '1.3'} + total_results = None + + # Gaussian gravitational constant + _k = degrees(sqrt(GM_sun.value) * au.value**-1.5 * 86400.0) + + @classmethod + def urls(cls, **kwargs) -> dict: + """Dictionary of urls for the JPL Scout API (all identical in this case)""" + urls = super().urls() + urls['base_url'] = cls.get_configuration('base_url', 'https://ssd-api.jpl.nasa.gov/scout.api') + urls['object_url'] = urls['base_url'] + urls['search_url'] = urls['base_url'] + return urls + + def build_query_parameters(self, parameters, **kwargs): + """ + Args: + parameters: dictionary containing either: + + - optional cutoff parameters + + - Scout name e.g. 'P10vY9r' + + Returns: + json containing parameters for querying the Scout API. + """ + data = {} + + # Save a copy of the input form parameters for later use as there are some parameters that are used in the + # query_targets method that are not part of the query to the Scout API. + # But don't save and overwrite later versions which don't have the form parameters (eg. when running + # query_targets with the tdes parameter set to a Scout name). + if 'neo_score_min' in parameters: + self.input_parameters = parameters + # pprint.pprint(parameters, indent=2) + + if parameters.get('tdes') is not None and parameters['tdes'] != '': + data['tdes'] = parameters['tdes'] + + # Return at least one orbit + data['orbits'] = 1 + data['n-orbits'] = 1 + self.query_parameters = data + return data + + def query_service(self, data, **kwargs): + """Make call to the JPL Scout service + + :param data: Dictionary containing query parameters for the Scout API. + :type data: dict + :return: json containing response from Scout API. + :rtype: dict + """ + response = requests.get(self.get_urls(url_type='search_url'), data) + + response.raise_for_status() + json_response = response.json() + if json_response is not None and 'error' not in json_response: + if json_response['signature'] == self.expected_signature: + if 'data' in json_response: + self.query_results = json_response['data'] + self.total_results = int(json_response.get('count', 0)) + else: + # Per-object data has different structure, make it into a list of 1 target + # so the the `for result in results` in `query_targets()` iterates (once..) + # over the target(s) and not over the keys of a single target + self.query_results = [json_response, ] + if self.total_results is None: + self.total_results = 1 + else: + msg = "Signature of response from Scout API does not match expected signature. " + msg += f"Expected {self.expected_signature}, got {json_response['signature']}." + logger.warning(msg) + else: + self.query_results = None + msg = "Error retrieving data from Scout." + if data.get('tdes', '') != '': + msg += f" Object {data['tdes']} is no longer on Scout." + logger.warning(msg) + return self.query_results + + def _get_filter_thresholds(self): + """Extract and normalize filter thresholds from input parameters.""" + p = self.input_parameters + + neo_score_min = p.get('neo_score_min', 0) or 0 + pha_score_min = p.get('pha_score_min', 0) or 0 + # Needs to be different as code like above will treat 0 as "no value provided" and use the default, but 0 is a + # valid value for this parameter and should be used if provided. + geo_score_max = p['geo_score_max'] if p.get('geo_score_max') is not None else 101 + + default_pos_unc_max = 360 * 60 # 360 degrees (whole sky) as arcmin + pos_unc_min = p.get('pos_unc_min', 0) or 0 + pos_unc_max = p.get('pos_unc_max', default_pos_unc_max) or default_pos_unc_max + + thresholds = { + 'neo_score_min': neo_score_min, + 'pha_score_min': pha_score_min, + 'geo_score_max': geo_score_max, + 'impact_rating_min': p['impact_rating_min'], # May be None intentionally + 'ca_dist_min': p['ca_dist_min'], + 'pos_unc_min': pos_unc_min, + 'pos_unc_max': pos_unc_max, + } + return thresholds + + def _parse_result_values(self, result): + """Parse and coerce numeric fields from a raw Scout result.""" + try: + pos_unc = float(result['unc']) + except (ValueError, TypeError): + pos_unc = 0.0 + + try: + ca_dist = float(result['caDist']) + except (ValueError, TypeError): + ca_dist = None + + return pos_unc, ca_dist + + def _passes_filters(self, result, pos_unc, ca_dist, thresholds): + """Return True if the result passes all filter thresholds.""" + impact_rating_min = thresholds['impact_rating_min'] + + impact_ok = ( + impact_rating_min is None or + (result['rating'] is not None and result['rating'] >= impact_rating_min) + ) + + ca_dist_min = thresholds['ca_dist_min'] + ca_dist_ok = ( + ca_dist_min is None or + (ca_dist is not None and ca_dist <= ca_dist_min) + ) + + return ( + result['neoScore'] >= thresholds['neo_score_min'] and + result['phaScore'] >= thresholds['pha_score_min'] and + result['geocentricScore'] <= thresholds['geo_score_max'] and + thresholds['pos_unc_min'] <= pos_unc <= thresholds['pos_unc_max'] and + impact_ok and + ca_dist_ok + ) + + def _fetch_target_data(self, result, query_parameters): + """Return full target data for a result, fetching per-object data if needed.""" + if 'orbits' in result: + # Already a per-object query response + return result + + query_parameters['tdes'] = result['objectName'] + target_parameters = self.build_query_parameters(query_parameters) + target_data = self.query_service(target_parameters, url=self.get_urls('object_url')) + + return target_data[0] if target_data is not None else None + + def query_targets(self, query_parameters, **kwargs): + """Set up and run a specialized query for retrieving targets from a DataService.""" + results = self.query_service(self.build_query_parameters(query_parameters)) + + if results is None or 'error' in results: + msg = "Error retrieving data from Scout." + if query_parameters.get('tdes', '') != '': + msg += f" Object {query_parameters['tdes']} is no longer on Scout." + return [] + + thresholds = self._get_filter_thresholds() + targets = [] + + for result in results: + pos_unc, ca_dist = self._parse_result_values(result) + + if not self._passes_filters(result, pos_unc, ca_dist, thresholds): + continue + + target_data = self._fetch_target_data(result, query_parameters) + if target_data is not None: + reduced_datums = self._parse_detail_data(target_data) + if reduced_datums is not None: + target_data['reduced_datums'] = {'scout_detail': reduced_datums} + targets.append(target_data) + + return targets + + @classmethod + def get_form_class(cls): + return ScoutForm + + def get_additional_context_data(self, **kwargs): + """Add additional context data for rendering the query results template.""" + context = {} + + context['total_results'] = self.total_results if self.total_results is not None else 0 + context['neo_score_min'] = self.input_parameters.get('neo_score_min', 0) + context['pha_score_min'] = self.input_parameters.get('pha_score_min', 0) + context['geo_score_max'] = self.input_parameters.get('geo_score_max', 5) + return context + + def create_target_from_query(self, target_results, **kwargs): + """ + Returns a Target instance for an object defined by a query result, + + :returns: target object + :rtype: `Target` + """ + + # Construct dictionary from ['orbits']['fields'] and ['orbits']['data'][0] + elements = dict(zip(target_results['orbits']['fields'], target_results['orbits']['data'][0])) + + target = Target( + name=target_results['objectName'], + type='NON-SIDEREAL', + scheme='MPC_MINOR_PLANET', + arg_of_perihelion=float(elements['w']), + lng_asc_node=float(elements['om']), + inclination=float(elements['inc']), + eccentricity=float(elements['ec']), + epoch_of_elements=float(elements['epoch'][2:]) - 0.5, + epoch_of_perihelion=float(elements['tp'][2:]) - 0.5, + perihdist=float(elements['qr']), + abs_mag=float(elements['H']), + slope=elements.get('G', 0.15) # Never actually present ? + ) + try: + target.semimajor_axis = target.perihdist / (1.0 - target.eccentricity) + if target.semimajor_axis < 0 or target.semimajor_axis > 1000.0: + target.semimajor_axis = None + except (ZeroDivisionError, ValueError): + target.semimajor_axis = None + if target.semimajor_axis: + target.mean_daily_motion = self._k / (target.semimajor_axis * sqrt(target.semimajor_axis)) + if target.mean_daily_motion and target.epoch_of_elements and target.epoch_of_perihelion: + td = target.epoch_of_elements - target.epoch_of_perihelion + mean_anomaly = td * target.mean_daily_motion + # Normalize into 0...360 range + mean_anomaly = mean_anomaly % 360.0 + if mean_anomaly < 0.0: + mean_anomaly += 360.0 + target.mean_anomaly = mean_anomaly + return target + + def _parse_detail_data(self, query_results, **kwargs): + """Parse and coerce relevant fields from a per-object query result to create a dictionary of reduced datums. + (These aren't really "reduced datums" in the sense of being derived from the raw data, but a + temporary hacky workaround as these are the only things supported post-Target saving) + """ + + reduced_datums = { + 'num_obs': query_results.get('nObs'), + 'neo_score': query_results.get('neoScore'), + 'neo1km_score': query_results.get('neo1kmScore'), + 'pha_score': query_results.get('phaScore'), + 'ieo_score': query_results.get('ieoScore'), + 'geocentric_score': query_results.get('geocentricScore'), + 'impact_rating': query_results.get('rating'), + 'ca_dist': float(query_results.get('caDist')) if query_results.get('caDist') is not None else None, + 'arc': float(query_results.get('arc')) if query_results.get('arc') is not None else None, + 'rms': float(query_results.get('rmsN')) if query_results.get('rmsN') is not None else None, + 'uncertainty': float(query_results.get('unc')) if query_results.get('unc') is not None else None, + 'uncertainty_p1': float(query_results.get('uncP1')) if query_results.get('uncP1') is not None else None, + 'last_run': parse(query_results.get('lastRun')).replace(tzinfo=tzutc()) if query_results.get('lastRun') + else None + } + return reduced_datums + + def create_reduced_datums_from_query(self, target, data=None, data_type=None, **kwargs): + if data is not None and data_type == 'scout_detail': + scout_detail, created = ScoutDetail.objects.get_or_create(target=target, **data) + else: + scout_detail, created = None, None + return scout_detail, created diff --git a/tom_dataservices/migrations/0002_scoutdetail.py b/tom_dataservices/migrations/0002_scoutdetail.py new file mode 100644 index 000000000..81a417cb2 --- /dev/null +++ b/tom_dataservices/migrations/0002_scoutdetail.py @@ -0,0 +1,38 @@ +# Generated by Django 4.2.29 on 2026-03-06 17:15 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('tom_targets', '0030_alter_basetarget_slope'), + ('tom_dataservices', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='ScoutDetail', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('num_obs', models.IntegerField(blank=True, help_text='Number of observations', null=True)), + ('neo_score', models.IntegerField(blank=True, help_text='NEO digest score (0..100)', null=True)), + ('neo1km_score', models.IntegerField(blank=True, help_text='NEO >1km digest score (0..100)', null=True)), + ('pha_score', models.IntegerField(blank=True, help_text='PHA digest score (0..100)', null=True)), + ('ieo_score', models.IntegerField(blank=True, help_text='IEO digest score (0..100)', null=True)), + ('geocentric_score', models.IntegerField(blank=True, help_text='Geocentric digest score (0..100)', null=True)), + ('impact_rating', models.IntegerField(blank=True, choices=[(0, 'Negligible'), (1, 'Small'), (2, 'Modest'), (3, 'Moderate'), (4, 'Elevated')], help_text='Impact rating (0=negligible, 1=small, 2=modest, 3=moderate, 4=elevated)', null=True)), + ('ca_dist', models.FloatField(blank=True, help_text='Close approach distance (lunar distances)', null=True)), + ('arc', models.FloatField(blank=True, help_text='Arc length (days)', null=True)), + ('rms', models.FloatField(blank=True, help_text='RMS of the residuals to the orbit fit (arcsec)', null=True)), + ('uncertainty', models.FloatField(blank=True, help_text='1-sigma plane-of-sky positional uncertainty (arcmin)', null=True)), + ('uncertainty_p1', models.FloatField(blank=True, help_text='1-sigma plane-of-sky positional uncertainty at +1 day (arcmin)', null=True)), + ('last_run', models.DateTimeField(blank=True, help_text='Last time the data was updated from Scout', null=True)), + ('target', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='scout_detail', to='tom_targets.basetarget')), + ], + options={ + 'verbose_name': 'Scout Detail', + }, + ), + ] diff --git a/tom_dataservices/templates/tom_dataservices/scout/partials/scout_query_results_table.html b/tom_dataservices/templates/tom_dataservices/scout/partials/scout_query_results_table.html new file mode 100644 index 000000000..7066a2286 --- /dev/null +++ b/tom_dataservices/templates/tom_dataservices/scout/partials/scout_query_results_table.html @@ -0,0 +1,50 @@ +Showing {{results|length}} result{{results|length|pluralize}} {% if results|length != total_results %}(filtered from {{total_results}} total result{{total_results|pluralize}}){% endif %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% for result in results %} + + + + + + + + + + + + + + + + + + + {% endfor %} + +
Object Class Score
Name#obsRADecV (mag)RatePOS Unc(')HImpact RatingCA Distance (LD)NEOPHAIEOGeocentricRMS
{{result.objectName}}{{ result.nObs }}{{ result.ra }}{{ result.dec }}{{ result.Vmag }}{{ result.rate }}{{ result.unc }}{{ result.H }}{{ result.rating }}{{ result.caDist }}{{ result.neoScore }}{{ result.phaScore }}{{ result.ieoScore }}{{ result.geocentricScore }}{{ result.rmsN }}
diff --git a/tom_dataservices/tests/data_services/__init__.py b/tom_dataservices/tests/data_services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tom_dataservices/tests/data_services/test_jpl.py b/tom_dataservices/tests/data_services/test_jpl.py new file mode 100644 index 000000000..6adc8c806 --- /dev/null +++ b/tom_dataservices/tests/data_services/test_jpl.py @@ -0,0 +1,628 @@ +from datetime import datetime +from dateutil.tz import tzutc + +from django.test import tag, SimpleTestCase, TestCase +from unittest import mock + +from tom_dataservices.data_services.jpl import ScoutDataService +# from tom_dataservices.tests.factories import scout_resultsFactory +from tom_targets.models import Target + + +def make_result(overrides=None): + """Return a minimal valid Scout result dict, with optional field overrides.""" + base = { + 'objectName': 'ZTF10BL', + 'neoScore': 100, + 'neo1kmScore': 0, + 'phaScore': 0, + 'ieoScore': 0, + 'geocentricScore': 1, + 'rating': 2, + 'unc': '1400', + 'uncP1': '1500', + 'caDist': '0.98', + 'arc': '0.35', + 'nObs': 4, + 'rmsN': '0.12', + 'lastRun': '2026-02-11 22:45', + } + if overrides: + base.update(overrides) + return base + + +def make_result_with_orbits(overrides=None): + """Return a Scout result that already contains orbit data (per-object query response).""" + result = make_result(overrides) + result['orbits'] = { + 'fields': ['idx', 'epoch', 'ec', 'qr', 'tp', 'om', 'w', 'inc', 'H', + 'dca', 'tca', 'moid', 'vinf', 'geoEcc', 'impFlag'], + 'data': [[0, '2461079.712931752', '8.855752093403347E-01', '4.998861947435592E-01', + '2461038.529885748', '1.3898404962804094E+02', '2.6665272638172337E+02', + '1.4692644033445657E+01', '25.402927', '4.34144690247134E-03', + '2.4610794091831E+06', '1.971147172E-03', '2.790457665E+01', + '1.278753791E+03', 0]], + 'count': '1', + } + return result + + +class TestGetFilterThresholds(SimpleTestCase): + """Tests for ScoutDataService._get_filter_thresholds()""" + + def setUp(self): + self.ds = ScoutDataService() + + def _set_input_params(self, overrides=None): + params = { + 'neo_score_min': None, + 'pha_score_min': None, + 'geo_score_max': None, + 'impact_rating_min': None, + 'ca_dist_min': None, + 'pos_unc_min': None, + 'pos_unc_max': None, + } + if overrides: + params.update(overrides) + self.ds.input_parameters = params + + def test_all_none_returns_permissive_defaults(self): + """When all optional params are None, defaults should allow everything through.""" + self._set_input_params() + thresholds = self.ds._get_filter_thresholds() + + self.assertEqual(thresholds['neo_score_min'], 0) + self.assertEqual(thresholds['pha_score_min'], 0) + self.assertEqual(thresholds['geo_score_max'], 101) + self.assertEqual(thresholds['pos_unc_min'], 0) + self.assertEqual(thresholds['pos_unc_max'], 360 * 60) + self.assertIsNone(thresholds['impact_rating_min']) + self.assertIsNone(thresholds['ca_dist_min']) + + def test_explicit_values_are_used(self): + """Explicitly set values should be returned as-is.""" + self._set_input_params({ + 'neo_score_min': 50, + 'pha_score_min': 10, + 'geo_score_max': 3, + 'pos_unc_min': 5, + 'pos_unc_max': 120, + 'impact_rating_min': 1, + 'ca_dist_min': 0.5, + }) + thresholds = self.ds._get_filter_thresholds() + + self.assertEqual(thresholds['neo_score_min'], 50) + self.assertEqual(thresholds['pha_score_min'], 10) + self.assertEqual(thresholds['geo_score_max'], 3) + self.assertEqual(thresholds['pos_unc_min'], 5) + self.assertEqual(thresholds['pos_unc_max'], 120) + self.assertEqual(thresholds['impact_rating_min'], 1) + self.assertEqual(thresholds['ca_dist_min'], 0.5) + + def test_zero_values_are_preserved_not_replaced_by_default(self): + """Explicit zero should be kept, not treated as falsy and replaced by a default.""" + self._set_input_params({'neo_score_min': 0, 'pha_score_min': 0, 'pos_unc_min': 0}) + thresholds = self.ds._get_filter_thresholds() + + self.assertEqual(thresholds['neo_score_min'], 0) + self.assertEqual(thresholds['pha_score_min'], 0) + self.assertEqual(thresholds['pos_unc_min'], 0) + + +class TestParseResultValues(SimpleTestCase): + """Tests for ScoutDataService._parse_result_values()""" + + def setUp(self): + self.ds = ScoutDataService() + + def test_valid_numeric_strings(self): + result = make_result({'unc': '1400', 'caDist': '0.98'}) + pos_unc, ca_dist = self.ds._parse_result_values(result) + + self.assertEqual(pos_unc, 1400.0) + self.assertAlmostEqual(ca_dist, 0.98) + + def test_non_numeric_unc_defaults_to_zero(self): + """If 'unc' cannot be cast to float, pos_unc should default to 0.0.""" + result = make_result({'unc': 'N/A'}) + pos_unc, _ = self.ds._parse_result_values(result) + + self.assertEqual(pos_unc, 0.0) + + def test_none_ca_dist_returns_none(self): + """A None 'caDist' (as returned by the API when unknown) should yield None.""" + result = make_result({'caDist': None}) + _, ca_dist = self.ds._parse_result_values(result) + + self.assertIsNone(ca_dist) + + def test_non_numeric_ca_dist_returns_none(self): + """A non-numeric 'caDist' string that raises TypeError should yield None.""" + result = make_result({'caDist': 'unknown'}) + _, ca_dist = self.ds._parse_result_values(result) + + self.assertIsNone(ca_dist) + + +class TestPassesFilters(SimpleTestCase): + """Tests for ScoutDataService._passes_filters()""" + + def setUp(self): + self.ds = ScoutDataService() + # Permissive thresholds that let everything through by default + self.permissive = { + 'neo_score_min': 0, + 'pha_score_min': 0, + 'geo_score_max': 101, + 'impact_rating_min': None, + 'ca_dist_min': None, + 'pos_unc_min': 0, + 'pos_unc_max': 360 * 60, + } + + def _thresholds(self, overrides=None): + t = dict(self.permissive) + if overrides: + t.update(overrides) + return t + + def test_passes_with_permissive_thresholds(self): + result = make_result() + self.assertTrue(self.ds._passes_filters(result, 1400.0, 0.98, self._thresholds())) + + def test_fails_neo_score_below_minimum(self): + result = make_result({'neoScore': 30}) + self.assertFalse(self.ds._passes_filters(result, 0.0, None, self._thresholds({'neo_score_min': 50}))) + + def test_fails_pha_score_below_minimum(self): + result = make_result({'phaScore': 0}) + self.assertFalse(self.ds._passes_filters(result, 0.0, None, self._thresholds({'pha_score_min': 5}))) + + def test_fails_geocentric_score_above_maximum(self): + result = make_result({'geocentricScore': 6}) + self.assertFalse(self.ds._passes_filters(result, 0.0, None, self._thresholds({'geo_score_max': 5}))) + + def test_passes_geocentric_score_strictly_below_maximum(self): + result = make_result({'geocentricScore': 4}) + self.assertTrue(self.ds._passes_filters(result, 0.0, None, self._thresholds({'geo_score_max': 5}))) + + def test_passes_geocentric_score_of_zero(self): + result = make_result({'geocentricScore': 0}) + self.assertTrue(self.ds._passes_filters(result, 0.0, None, self._thresholds({'geo_score_max': 5}))) + + def test_fails_pos_unc_below_minimum(self): + result = make_result() + self.assertFalse(self.ds._passes_filters(result, 50.0, None, self._thresholds({'pos_unc_min': 100}))) + + def test_fails_pos_unc_above_maximum(self): + result = make_result() + self.assertFalse(self.ds._passes_filters(result, 500.0, None, self._thresholds({'pos_unc_max': 100}))) + + def test_passes_when_impact_rating_min_is_none(self): + """impact_rating_min=None means no impact filter — any rating (or None) should pass.""" + result = make_result({'rating': None}) + self.assertTrue(self.ds._passes_filters(result, 0.0, None, self._thresholds({'impact_rating_min': None}))) + + def test_fails_when_rating_is_none_and_impact_rating_min_is_set(self): + """If a minimum rating is required but the result has no rating, it should be filtered out.""" + result = make_result({'rating': None}) + self.assertFalse(self.ds._passes_filters(result, 0.0, None, self._thresholds({'impact_rating_min': 1}))) + + def test_fails_when_rating_below_minimum(self): + result = make_result({'rating': 1}) + self.assertFalse(self.ds._passes_filters(result, 0.0, None, self._thresholds({'impact_rating_min': 2}))) + + def test_passes_when_ca_dist_min_is_none(self): + """ca_dist_min=None means no close-approach filter.""" + result = make_result() + self.assertTrue(self.ds._passes_filters(result, 0.0, None, self._thresholds({'ca_dist_min': None}))) + + def test_fails_when_ca_dist_exceeds_minimum(self): + """Result should be filtered out when its caDist is greater than the threshold.""" + result = make_result() + self.assertFalse(self.ds._passes_filters(result, 0.0, 1.5, self._thresholds({'ca_dist_min': 1.0}))) + + def test_fails_when_ca_dist_required_but_is_none(self): + """If a ca_dist_min threshold is set but the result has no caDist, filter it out.""" + result = make_result() + self.assertFalse(self.ds._passes_filters(result, 0.0, None, self._thresholds({'ca_dist_min': 1.0}))) + + def test_passes_when_ca_dist_within_minimum(self): + result = make_result() + self.assertTrue(self.ds._passes_filters(result, 0.0, 0.5, self._thresholds({'ca_dist_min': 1.0}))) + + +class TestFetchTargetData(SimpleTestCase): + """Tests for ScoutDataService._fetch_target_data()""" + + def setUp(self): + self.ds = ScoutDataService() + self.query_parameters = {'tdes': ''} + + def test_returns_result_directly_when_orbits_present(self): + """If the result already has orbit data, no additional query_service call should be made.""" + result = make_result_with_orbits() + with mock.patch.object(self.ds, 'query_service') as mock_qs: + target_data = self.ds._fetch_target_data(result, self.query_parameters) + + mock_qs.assert_not_called() + self.assertEqual(target_data, result) + + def test_fetches_per_object_data_when_orbits_absent(self): + """If orbits are not present, query_service should be called to fetch per-object data.""" + result = make_result() # no 'orbits' key + per_object_result = make_result_with_orbits() + + with mock.patch.object(self.ds, 'query_service', return_value=[per_object_result]) as mock_qs: + with mock.patch.object(self.ds, 'get_urls', return_value='http://mock-url'): + target_data = self.ds._fetch_target_data(result, self.query_parameters) + + mock_qs.assert_called_once() + self.assertEqual(target_data, per_object_result) + + def test_sets_tdes_on_query_parameters_when_fetching(self): + """query_parameters['tdes'] should be updated to the result's objectName before fetching.""" + result = make_result({'objectName': 'ZTF10BL'}) + per_object_result = make_result_with_orbits() + + with mock.patch.object(self.ds, 'query_service', return_value=[per_object_result]): + with mock.patch.object(self.ds, 'get_urls', return_value='http://mock-url'): + self.ds._fetch_target_data(result, self.query_parameters) + + self.assertEqual(self.query_parameters['tdes'], 'ZTF10BL') + + def test_returns_none_when_query_service_returns_none(self): + """If the per-object query_service call returns None, _fetch_target_data should return None.""" + result = make_result() # no 'orbits' key + + with mock.patch.object(self.ds, 'query_service', return_value=None): + with mock.patch.object(self.ds, 'get_urls', return_value='http://mock-url'): + target_data = self.ds._fetch_target_data(result, self.query_parameters) + + self.assertIsNone(target_data) + + +class TestQueryTargetsFiltering(TestCase): + """Integration-level tests for query_targets filtering behaviour using mocked query_service.""" + + def setUp(self): + self.ds = ScoutDataService() + self.base_input_parameters = { + 'ca_dist_min': None, + 'data_service': 'Scout', + 'geo_score_max': 5, + 'impact_rating_min': None, + 'neo_score_min': None, + 'pha_score_min': None, + 'pos_unc_max': None, + 'pos_unc_min': None, + 'query_name': '', + 'query_save': False, + 'tdes': 'ZTF10BL', + } + + @mock.patch('tom_dataservices.data_services.jpl.ScoutDataService.query_service') + def test_returns_empty_list_when_query_service_returns_none(self, mock_qs): + mock_qs.return_value = None + targets = self.ds.query_targets(self.base_input_parameters) + self.assertEqual(targets, []) + + @mock.patch('tom_dataservices.data_services.jpl.ScoutDataService.query_service') + def test_result_excluded_by_geocentric_score_filter(self, mock_qs): + """A result with geocentricScore > geo_score_max should be excluded.""" + result = make_result_with_orbits({'geocentricScore': 6}) # fails geo_score_max=5 + mock_qs.return_value = [result] + + targets = self.ds.query_targets(self.base_input_parameters) + self.assertEqual(targets, []) + + @mock.patch('tom_dataservices.data_services.jpl.ScoutDataService.query_service') + def test_result_included_when_all_filters_pass(self, mock_qs): + """A result that satisfies all default filters should be included.""" + result = make_result_with_orbits({'geocentricScore': 1}) + mock_qs.return_value = [result] + + targets = self.ds.query_targets(self.base_input_parameters) + self.assertEqual(len(targets), 1) + self.assertEqual(targets[0]['objectName'], 'ZTF10BL') + + @mock.patch('tom_dataservices.data_services.jpl.ScoutDataService.query_service') + def test_multiple_results_partial_filter(self, mock_qs): + """Only results passing all filters should be returned from a multi-result response.""" + passing = make_result_with_orbits({'objectName': 'ZTF10BL', 'geocentricScore': 1}) + failing = make_result_with_orbits({'objectName': 'ZTF99XX', 'geocentricScore': 6}) + mock_qs.return_value = [passing, failing] + + targets = self.ds.query_targets(self.base_input_parameters) + self.assertEqual(len(targets), 1) + self.assertEqual(targets[0]['objectName'], 'ZTF10BL') + + +class TestParseDetailData(SimpleTestCase): + """Tests for ScoutDataService._parse_detail_data()""" + + def setUp(self): + self.ds = ScoutDataService() + + def test_returns_reduced_datums_dict_with_expected_keys(self): + detail_data = make_result_with_orbits() + reduced_datums = self.ds._parse_detail_data(detail_data) + + self.assertIsInstance(reduced_datums, dict) + + expected_keys = ['num_obs', 'neo_score', 'neo1km_score', 'pha_score', 'ieo_score', 'geocentric_score', + 'impact_rating', 'ca_dist', 'arc', 'rms', 'uncertainty', 'uncertainty_p1', 'last_run'] + + for datum in reduced_datums: + self.assertTrue(datum in expected_keys) + # Check that values are not still strings (e.g. '1400') but have been converted to appropriate + # types (e.g. float 1400.0) + self.assertNotEqual(str, type(reduced_datums[datum])) + + def test_convert_values(self): + detail_data = make_result_with_orbits() + + reduced_datums = self.ds._parse_detail_data(detail_data) + + expected_datums = {'num_obs': 4, + 'neo_score': 100, + 'neo1km_score': 0, + 'pha_score': 0, + 'ieo_score': 0, + 'geocentric_score': 1, + 'impact_rating': 2, + 'ca_dist': 0.98, + 'arc': 0.35, + 'rms': 0.12, + 'uncertainty': 1400.0, + 'uncertainty_p1': 1500.0, + 'last_run': datetime(2026, 2, 11, 22, 45, tzinfo=tzutc()) + } + + self.assertDictEqual(reduced_datums, expected_datums) + + def test_convert_values_more_nones(self): + detail_data = make_result_with_orbits({'rmsN': None, 'unc': None, 'uncP1': None, 'caDist': None, + 'lastRun': None, 'arc': None}) + + reduced_datums = self.ds._parse_detail_data(detail_data) + + expected_datums = {'num_obs': 4, + 'neo_score': 100, + 'neo1km_score': 0, + 'pha_score': 0, + 'ieo_score': 0, + 'geocentric_score': 1, + 'impact_rating': 2, + 'ca_dist': None, + 'arc': None, + 'rms': None, + 'uncertainty': None, + 'uncertainty_p1': None, + 'last_run': None + } + + self.assertDictEqual(reduced_datums, expected_datums) + + +class TestScoutDataService(TestCase): + """ + Test the functionality of the JPL ScoutDataService + """ + def setUp(self): + self.jpl_ds = ScoutDataService() + self.input_parameters = {'ca_dist_min': None, + 'data_service': 'Scout', + 'geo_score_max': 5, + 'impact_rating_min': None, + 'neo_score_min': None, + 'pha_score_min': None, + 'pos_unc_max': None, + 'pos_unc_min': None, + 'query_name': '', + 'query_save': False, + 'tdes': ''} + self.scout_results = [{'uncP1': '1500', + 'tEphem': '2026-02-11 22:45', + 'caDist': '0.98', + 'phaScore': 0, + 'vInf': '20.1', + 'moid': '0.001', + 'ra': '08:54', + 'objectName': 'ZTF10BL', + 'neo1kmScore': 0, + 'geocentricScore': 1, + 'rate': '1.9', + 'Vmag': '20.8', + 'dec': '+28', + 'tisserandScore': 39, + 'rating': 2, + 'arc': '0.35', + 'H': '26.3', + 'elong': '156', + 'unc': '1400', + 'ieoScore': 0, + 'orbits': {'data': [[0, + '2461079.712931752', + '8.855752093403347E-01', + '4.998861947435592E-01', + '2461038.529885748', + '1.3898404962804094E+02', + '2.6665272638172337E+02', + '1.4692644033445657E+01', + '25.402927', + '4.34144690247134E-03', + '2.4610794091831E+06', + '1.971147172E-03', + '2.790457665E+01', + '1.278753791E+03', + 0]], + 'count': '1', + 'fields': ['idx', + 'epoch', + 'ec', + 'qr', + 'tp', + 'om', + 'w', + 'inc', + 'H', + 'dca', + 'tca', + 'moid', + 'vinf', + 'geoEcc', + 'impFlag']}, + 'nObs': 4, + 'rmsN': '0.12', + 'signature': {'source': 'NASA/JPL Scout API', 'version': '1.3'}, + 'lastRun': '2026-02-08 13:52', + 'neoScore': 100},] + + target_params = {'name': 'ZTF10BL', + 'type': 'NON-SIDEREAL', + 'permissions': 'OPEN', + 'scheme': 'MPC_MINOR_PLANET', + 'epoch_of_elements': 61079.212931752, + 'mean_anomaly': 4.4452481923884894, + 'arg_of_perihelion': 266.65272638172337, + 'eccentricity': 0.8855752093403347, + 'lng_asc_node': 138.98404962804094, + 'inclination': 14.692644033445657, + 'mean_daily_motion': 0.10793879092762414, + 'semimajor_axis': 4.368687867914702, + 'epoch_of_perihelion': 61038.029885748, + 'perihdist': 0.4998861947435592, + 'abs_mag': 25.402927, + 'slope': 0.15} + self.test_target = Target.objects.create(**target_params) + + def test_build_query_parameters_no_target(self): + """ + Test that the build_query_parameters method correctly builds the query parameters for the JPL ScoutDataService + """ + expected_parameters = {} + + parameters = self.jpl_ds.build_query_parameters(self.input_parameters) + + self.assertEqual(parameters, expected_parameters) + self.assertEqual(self.jpl_ds.input_parameters, self.input_parameters) + + def test_build_query_parameters_with_target(self): + """ + Test that the build_query_parameters method correctly builds the query parameters for the JPL ScoutDataService + """ + self.input_parameters['tdes'] = 'ZTF10BL' + expected_parameters = {'tdes': 'ZTF10BL', 'orbits': 1, 'n-orbits': 1} + parameters = self.jpl_ds.build_query_parameters(self.input_parameters) + + self.assertEqual(parameters, expected_parameters) + self.assertEqual(self.jpl_ds.input_parameters, self.input_parameters) + + @mock.patch('tom_dataservices.data_services.jpl.ScoutDataService.query_service') + def test_query_targets_single(self, mock_client): + mock_client.side_effect = [self.scout_results, ] + self.input_parameters['tdes'] = 'ZTF10BL' + + targets = self.jpl_ds.query_targets(self.input_parameters) + expected_target_results = {'objectName': self.scout_results[0]['objectName'], + 'neoScore': self.scout_results[0]['neoScore'], + 'phaScore': self.scout_results[0]['phaScore'], + 'geocentricScore': self.scout_results[0]['geocentricScore'], + 'rating': self.scout_results[0]['rating'], + 'unc': self.scout_results[0]['unc'], + 'orbits': self.scout_results[0]['orbits'], + } + for target in targets: + for key in expected_target_results.keys(): + if key == 'orbits': + self.assertEqual(type(target[key]), type(expected_target_results[key])) + self.assertEqual(type(target[key]['data']), type(expected_target_results[key]['data'])) + else: + self.assertEqual(target[key], expected_target_results[key]) + + @mock.patch('tom_dataservices.data_services.jpl.ScoutDataService.query_service') + def test_query_targets_filter_on_geo0(self, mock_client): + first_result = self.scout_results[0].copy() + first_result['geocentricScore'] = 0 # passes geo_score_max=0 cut + second_result = self.scout_results[0].copy() + second_result['objectName'] = 'ZTF99XX' + mock_client.side_effect = [[first_result, second_result], ] + self.input_parameters['geo_score_max'] = 0 + + targets = self.jpl_ds.query_targets(self.input_parameters) + self.assertEqual(len(targets), 1) + expected_target_results = {'objectName': first_result['objectName'], + 'neoScore': first_result['neoScore'], + 'phaScore': first_result['phaScore'], + 'geocentricScore': first_result['geocentricScore'], + 'rating': first_result['rating'], + 'unc': first_result['unc'], + 'orbits': first_result['orbits'], + } + for target in targets: + for key in expected_target_results.keys(): + if key == 'orbits': + self.assertEqual(type(target[key]), type(expected_target_results[key])) + self.assertEqual(type(target[key]['data']), type(expected_target_results[key]['data'])) + else: + self.assertEqual(target[key], expected_target_results[key]) + + def test_create_target_from_query(self): + expected_target = self.test_target + + target = self.jpl_ds.create_target_from_query(self.scout_results[0]) + + self.assertEqual(target.name, expected_target.name) + self.assertEqual(target.type, expected_target.type) + self.assertEqual(target.ra, expected_target.ra) + self.assertEqual(target.dec, expected_target.dec) + self.assertEqual(target.scheme, expected_target.scheme) + self.assertEqual(target.epoch_of_elements, expected_target.epoch_of_elements) + self.assertAlmostEqual(target.mean_anomaly, expected_target.mean_anomaly, places=6) + + +@tag('canary') +class TestScoutDataServiceCanary(TestCase): + """Tests that actually hit the JPL Scout API.""" + + def setUp(self): + self.jpl_ds = ScoutDataService() + self.input_parameters = {'ca_dist_min': None, + 'data_service': 'Scout', + 'geo_score_max': 5, + 'impact_rating_min': None, + 'neo_score_min': None, + 'pha_score_min': None, + 'pos_unc_max': None, + 'pos_unc_min': None, + 'query_name': '', + 'query_save': False, + 'tdes': ''} + self.expected_result_keys = ['lastRun', 'neo1kmScore', 'phaScore', 'geocentricScore', 'arc', 'rate', + 'neoScore', 'rating', 'elong', 'uncP1', 'vInf', 'objectName', 'dec', 'H', + 'caDist', 'moid', 'ra', 'unc', 'Vmag', 'nObs', 'rmsN', 'tEphem', + 'tisserandScore', 'ieoScore'] + + def test_boilerplate(self): + self.assertTrue(True) + + def test_query_service(self): + """Test query_service.""" + results = self.jpl_ds.query_service(self.jpl_ds.build_query_parameters(self.input_parameters)) + + self.assertIsNotNone(results) + self.assertIsInstance(results, list) + for key in results[0].keys(): # type: ignore + self.assertIn(key, self.expected_result_keys) + + def test_query_targets_single(self): + """Test query_targets with a single result.""" + pass + + def test_create_target_from_query(self): + """Test create_target_from_query.""" + pass