55import astropy .units as u
66from astropy .coordinates .errors import ConvertError
77from astropy .time import Time
8- from ..syndynes import Syndyne , Synchrone , Syndynes , Synchrones , SynGenerator
8+ from astropy .wcs import WCS
9+ from ..syndynes import (
10+ Syndyne ,
11+ Syndynes ,
12+ Synchrone ,
13+ Synchrones ,
14+ SourceOrbit ,
15+ SynCollection ,
16+ SynGenerator ,
17+ )
918from ..state import State
1019from ..models import SolarGravity , SolarGravityAndRadiationPressure
1120
1221
1322pytest .importorskip ("scipy" )
1423
1524
25+ def fake_state (source , betas , ages ):
26+ """Fake dynamics: particles are displaced beta * 1e4 km from the source for
27+ a little more control on the tests."""
28+ r0 = 10000 * u .km
29+ v0 = 0.1 * u .km / u .s
30+ length = len (betas )
31+ return State (
32+ (betas * r0 )[:, np .newaxis ] + source .r ,
33+ (betas * v0 )[:, np .newaxis ] + source .v ,
34+ source .t - ages ,
35+ frame = source .frame ,
36+ )
37+
38+
39+ def fake_syndynes ():
40+ observer = State (
41+ [0 , 1 , 0 ] * u .au ,
42+ [30 , 0 , 0 ] * u .km / u .s ,
43+ Time ("2025-12-29" ),
44+ frame = "icrs" ,
45+ )
46+ comet = State (
47+ [1 , 0 , 0 ] * u .au ,
48+ [0 , 30 , 0 ] * u .km / u .s ,
49+ observer .t ,
50+ frame = "heliocentriceclipticiau76" ,
51+ )
52+
53+ betas = 10.0 ** - np .arange (4 )
54+ ages = np .arange (10 ) * u .day
55+
56+ items = []
57+ for beta in betas :
58+ particles = fake_state (comet , np .repeat (beta , 10 ), ages * 0 )
59+ initial = fake_state (comet , np .zeros (10 ), ages )
60+ items .append (
61+ Syndyne (
62+ comet ,
63+ beta ,
64+ ages ,
65+ particles .r ,
66+ particles .v ,
67+ particles .t ,
68+ initial ,
69+ observer = observer ,
70+ )
71+ )
72+
73+ return comet , observer , betas , ages , items
74+
75+
76+ def fake_synchrones ():
77+ observer = State (
78+ [0 , 1 , 0 ] * u .au ,
79+ [30 , 0 , 0 ] * u .km / u .s ,
80+ Time ("2025-12-29" ),
81+ frame = "icrs" ,
82+ )
83+ comet = State (
84+ [1 , 0 , 0 ] * u .au ,
85+ [0 , 30 , 0 ] * u .km / u .s ,
86+ observer .t ,
87+ frame = "heliocentriceclipticiau76" ,
88+ )
89+
90+ betas = 10.0 ** - np .arange (4 )
91+ ages = np .arange (10 ) * u .day
92+
93+ items = []
94+ for age in ages :
95+ particles = fake_state (comet , betas , age )
96+ initial = fake_state (comet , np .zeros (10 ), ages )
97+ items .append (
98+ Synchrone (
99+ comet ,
100+ betas ,
101+ age ,
102+ particles .r ,
103+ particles .v ,
104+ particles .t ,
105+ initial ,
106+ observer = observer ,
107+ )
108+ )
109+
110+ return comet , observer , betas , ages , items
111+
112+
16113@pytest .fixture
17114def example_syndynes ():
18115 comet = State (
@@ -115,8 +212,6 @@ def test_to_ephem(self, example_syndynes):
115212 assert u .allclose (eph ["deltadot" ], deltadot , atol = 1e-12 * u .km / u .s )
116213 assert u .allclose (eph ["lon" ], coords .lon )
117214 assert u .allclose (eph ["lat" ], coords .lat )
118- assert u .allclose (eph ["coords" ].lon , coords .lon )
119- assert u .allclose (eph ["coords" ].lat , coords .lat )
120215 assert u .allclose (eph ["x" ], particles .x )
121216 assert u .allclose (eph ["y" ], particles .y )
122217 assert u .allclose (eph ["z" ], particles .z )
@@ -161,10 +256,7 @@ def test_to_ephem(self, example_syndynes):
161256 assert np .all (eph ["t_relative" ] == 0 * u .s )
162257 assert eph .meta ["observer" ] is None
163258 assert np .all (
164- [
165- k not in eph
166- for k in ("delta" , "deltadot" , "ra" , "dec" , "lon" , "lat" , "coords" )
167- ]
259+ [k not in eph for k in ("delta" , "deltadot" , "ra" , "dec" , "lon" , "lat" )]
168260 )
169261
170262 def test_init_1d (self , example_syndynes ):
@@ -219,6 +311,24 @@ def test_getitem(self, example_syndynes):
219311 assert isinstance (dust .syndynes ()[:1 ], Syndynes )
220312
221313
314+ class TestSynCollection :
315+ def test_plot (self ):
316+ """Test plot's label_format when plotting a mix of syndynes and synchrones"""
317+
318+ plt = pytest .importorskip ("matplotlib.pyplot" )
319+
320+ _ , _ , _ , _ , syndynes = fake_syndynes ()
321+ _ , _ , _ , _ , synchrones = fake_synchrones ()
322+ collection = SynCollection (syndynes + synchrones )
323+
324+ _ , ax = plt .subplots ()
325+ collection .plot ()
326+
327+ for i , line in enumerate (ax .get_lines ()):
328+ # when label="", mpl sets it to _child0, _child1, ...
329+ assert line .get_label ().startswith ("_child" )
330+
331+
222332class TestSynchrone :
223333 def test_init (self , example_syndynes ):
224334 comet , betas , ages , dust , observer = example_syndynes
@@ -287,8 +397,8 @@ def test_to_ephem(self, example_syndynes):
287397
288398 eph = synchrone .to_ephem ()
289399
290- # most everything is tested in TestSyndyne.test_to_ephem
291- # we just need to make sure the betas and ages are right
400+ # most everything is tested in TestSyndyne.test_to_ephem let's just
401+ # check the betas and ages
292402 assert np .allclose (eph ["beta_rad" ], betas )
293403 assert u .allclose (eph ["age" ], age )
294404
@@ -299,90 +409,158 @@ def test_getitem(self, example_syndynes):
299409 assert isinstance (dust .synchrones ()[:1 ], Synchrones )
300410
301411
302- def random_state (length = 1 ):
303- return State (
304- np .random .rand (length , 3 ).squeeze () * u .au ,
305- np .random .rand (length , 3 ).squeeze () * u .km / u .s ,
306- np .random .rand () * u .day ,
307- )
412+ class TestSyndynes :
413+ def test_init (self ):
414+ comet , _ , betas , ages , items = fake_syndynes ()
308415
416+ # create from list
417+ syndynes = Syndynes (items )
309418
310- def test_syndynes ():
311- comet = random_state ()
312- betas = np .linspace (0 , 1 , 3 )
313- ages = np .arange (10 ) * u .day
419+ # create from syndynes object
420+ syndynes2 = Syndynes (syndynes )
421+ assert np .all (syndynes2 [0 ].r == syndynes [0 ].r )
314422
315- items = []
316- for beta in betas :
317- particles = random_state (len (ages ))
318- initial = random_state (len (ages ))
319- items .append (
320- Syndyne (
321- comet ,
322- beta ,
323- ages ,
324- particles .r ,
325- particles .v ,
326- particles .t ,
327- initial ,
328- )
423+ # create from tuple
424+ syndynes3 = Syndynes (tuple (items ))
425+ assert np .all (syndynes3 [0 ].r == syndynes [0 ].r )
426+
427+ # cannot mix syndynes and synchrones
428+ _ , _ , _ , _ , items2 = fake_synchrones ()
429+ with pytest .raises (TypeError ):
430+ Syndynes (items + items2 )
431+
432+ # test empty syndynes objects
433+ assert len (Syndynes ([])) == 0
434+
435+ def test_getitem (self ):
436+ _ , _ , _ , _ , items = fake_syndynes ()
437+ syndynes = Syndynes (items )
438+
439+ # get a single Syndyne
440+ assert isinstance (syndynes [2 ], Syndyne )
441+ assert syndynes [2 ] is items [2 ]
442+
443+ # get Syndynes from a slice
444+ assert len (syndynes [:3 ]) == 3
445+ assert isinstance (syndynes [:3 ], Syndynes )
446+
447+ # get Syndynes from a tuple
448+ assert isinstance (syndynes [(0 , 1 )], Syndynes )
449+
450+ def test_repr (self ):
451+ _ , _ , _ , _ , items = fake_syndynes ()
452+ syndynes = Syndynes (items )
453+ assert repr (syndynes ) == "<Syndynes: betas=[1. 0.1 0.01 0.001]>"
454+
455+ def test_to_ephem (self ):
456+ comet , _ , _ , _ , items = fake_syndynes ()
457+ syndynes = Syndynes (items )
458+
459+ eph = syndynes .to_ephem ()
460+ assert len (eph ) == 40
461+ assert all (eph .meta ["source" ]["r" ] == comet .r )
462+
463+ # [1] is 2nd index of 1st syndyne
464+ assert eph ["x" ][1 ] == items [0 ].x [1 ]
465+
466+ # [13] is 4th index of 2nd syndyne
467+ assert eph ["vz" ][13 ] == items [1 ].v_z [3 ]
468+
469+ assert len (Syndynes ([]).to_ephem ()) == 0
470+
471+ def test_plot (self ):
472+ plt = pytest .importorskip ("matplotlib.pyplot" )
473+
474+ comet , observer , betas , ages , items = fake_syndynes ()
475+ syndynes = Syndynes (items )
476+
477+ _ , ax = plt .subplots ()
478+ syndynes .plot ()
479+
480+ # observer is sqrt(2) au away, particles are up to 1e-4 au = 14959 km =
481+ # 15"
482+ for i , line in enumerate (ax .get_lines ()):
483+ assert line .get_label () == f"$\\ beta={ betas [i ]} $"
484+ assert all (line .get_xdata () <= 15 * betas [i ] * u .arcsec )
485+ assert all (line .get_ydata () <= 15 * betas [i ] * u .arcsec )
486+
487+ coords = observer .observe (comet )
488+
489+ # 1 deg/pix
490+ wcs = WCS ()
491+ wcs .wcs .ctype = "RA---TAN" , "DEC--TAN"
492+ wcs .wcs .crval = coords .ra .deg , coords .dec .deg
493+
494+ syndynes .plot (wcs = wcs )
495+
496+ # 15" / (1 deg/pix) = 0.00417 pix
497+ for i , line in enumerate (ax .get_lines ()[len (syndynes ) :]):
498+ assert all (line .get_xdata () <= 4.17e-3 * betas [i ])
499+ assert all (line .get_ydata () <= 4.17e-3 * betas [i ])
500+
501+ # cannot plot relative to source when observer is None
502+ state = fake_state (comet , [1 ], ages )
503+ syndyne = Syndyne (
504+ comet , [1 ], ages , state .r , state .v , state .t , comet , observer = None
329505 )
506+ with pytest .raises (ValueError ):
507+ syndyne .plot ()
330508
331- syndynes = Syndynes (items )
332- assert syndynes [2 ] is items [2 ]
333- assert len (syndynes [:3 ]) == 3
334- assert repr (syndynes ) == "<Syndynes: betas=[0. 0.5 1. ]>"
335509
336- eph = syndynes .to_ephem ()
337- assert len (eph ) == 30
338- assert all (eph .meta ["source" ]["r" ] == comet .r )
510+ class TestSynchrones :
511+ # most of the relevant tests are covered by TestSyndynes
339512
340- syndynes2 = Syndynes (syndynes )
341- assert np .all (syndynes2 [0 ].r == syndynes [0 ].r )
342- syndynes3 = Syndynes (tuple (items ))
343- assert np .all (syndynes3 [0 ].r == syndynes [0 ].r )
513+ def test_init (self ):
514+ _ , _ , _ , _ , items = fake_synchrones ()
515+ synchrones = Synchrones (items )
344516
345- particles = random_state (len (betas ))
346- synchrone = Synchrones (
347- [
348- Synchrone (
349- comet , betas , ages [0 ], particles .r , particles .v , particles .t , particles
350- )
351- ]
352- )
353- with pytest .raises (TypeError ):
354- Syndynes (items + [synchrone ])
517+ assert synchrones [2 ] is items [2 ]
518+ assert len (synchrones ) == len (items )
355519
356- # test emptpy syndynes objects
357- assert len ( Syndynes ([])) == 0
358- assert len ( Syndynes ([]). to_ephem ()) == 0
520+ def test_getitem ( self ):
521+ _ , _ , _ , _ , items = fake_synchrones ()
522+ synchrones = Synchrones ( items )
359523
524+ # index with integer returns one synchrone
525+ assert synchrones [2 ] is items [2 ]
526+ assert isinstance (synchrones [0 ], Synchrone )
360527
361- def test_synchrones ():
362- comet = random_state ()
363- betas = np .linspace (0 , 1 , 3 )
364- ages = np .arange (10 ) * u .day
528+ # index with slice returns synchrone collection
529+ assert isinstance (synchrones [:3 ], Synchrones )
365530
366- items = []
367- for age in ages :
368- particles = random_state (len (ages ))
369- initial = random_state (len (ages ))
370- items .append (
371- Synchrone (
372- comet ,
373- betas ,
374- age ,
375- particles .r ,
376- particles .v ,
377- particles .t ,
378- initial ,
379- )
531+ # index with tuple returns synchrone collection
532+ assert isinstance (synchrones [(0 , 1 )], Synchrones )
533+
534+ def test_repr (self ):
535+ _ , _ , _ , _ , items = fake_synchrones ()
536+ synchrones = Synchrones (items )
537+ assert (
538+ repr (synchrones ) == "<Synchrones: ages=[0. 1. 2. 3. 4. 5. 6. 7. 8. 9.] d>"
380539 )
381540
382- synchrones = Synchrones (items )
383- assert synchrones [2 ] is items [2 ]
384- assert len (synchrones [:3 ]) == 3
385- assert repr (synchrones ) == "<Synchrones: ages=[0. 1. 2. 3. 4. 5. 6. 7. 8. 9.] d>"
541+ def test_plot (self ):
542+ plt = pytest .importorskip ("matplotlib.pyplot" )
543+
544+ comet , observer , betas , ages , items = fake_synchrones ()
545+ synchrones = Synchrones (items )
546+
547+ _ , ax = plt .subplots ()
548+ synchrones .plot ()
549+
550+ for i , line in enumerate (ax .get_lines ()):
551+ assert line .get_label () == f"$\\ Delta t={ ages [i ]} $"
552+
553+
554+ class TestSourceOrbit :
555+ def test_dt (self ):
556+ comet = State ([1 , 0 , 0 ] * u .au , [0 , 30 , 0 ] * u .km / u .s , Time ("2023-12-07" ))
557+ dt = [- 1 , 0 , 1 ] * u .day
558+ ages = - dt
559+ state = fake_state (comet , [0 , 1 , 2 ], ages )
560+ orbit = SourceOrbit (comet , dt , state .r , state .v , comet .t + dt )
561+
562+ assert all (orbit .dt == dt )
563+ assert all (orbit .epoch == comet .t + dt )
386564
387565
388566class TestSynGenerator :
0 commit comments