Skip to content

Commit 5b627e6

Browse files
Add sfHG1G2 model (#177)
* Add sfHG1G2 model * Fix test * Disable Horizons test for time being because certificate problem in the Docker container.
1 parent 2b3f465 commit 5b627e6

File tree

2 files changed

+320
-6
lines changed

2 files changed

+320
-6
lines changed

fink_utils/sso/spins.py

Lines changed: 170 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
import astropy.units as u
2323

2424
from fink_utils.sso.utils import estimate_axes_ratio
25+
from fink_utils.sso.utils import get_opposition, split_dataframe_per_apparition
2526
from fink_utils.tester import regular_unit_tests
2627

2728

@@ -343,6 +344,51 @@ def func_sshg1g2(pha, h, g1, g2, alpha0, delta0, period, a_b, a_c, phi0):
343344
return func1 + func2
344345

345346

347+
def sfhg1g2_multiple(phas, g1, g2, *args):
348+
"""HG1G2 model in the case of simultaneous fit
349+
350+
Parameters
351+
----------
352+
phas: np.array
353+
(N, M_o) array of phase angles. N is the number
354+
of opposition, M_o is the number of observations
355+
per opposition. Phase is radians.
356+
g1: float
357+
G1 parameter (no unit)
358+
g2: float
359+
G2 parameter (no unit)
360+
args: float
361+
List of Hs, one per opposition.
362+
363+
Returns
364+
-------
365+
out: np.array
366+
Magnitude as predicted by `func_hg1g2`.
367+
"""
368+
fl = []
369+
for alpha, h in zip(phas, args[0][:]):
370+
fl.append(func_hg1g2(alpha, h, g1, g2))
371+
return np.concatenate(fl)
372+
373+
374+
def sfhg1g2_error_fun(params, phas, mags):
375+
"""Difference between sfHG1G2 predictions and observations
376+
377+
Parameters
378+
----------
379+
params: list
380+
[G1, G2, *H_i], where H_i are the Hs, one per opposition
381+
phas: np.array
382+
(N, M_o) array of phase angles. N is the number
383+
of opposition, M_o is the number of observations
384+
per opposition. Must be sorted by time. Phase is radians.
385+
mags: np.array
386+
Reduced magnitude, that is m_obs - 5 * np.log10('Dobs' * 'Dhelio')
387+
Sorted by time.
388+
"""
389+
return sfhg1g2_multiple(phas, params[0], params[1], params[2:]) - mags
390+
391+
346392
def color_correction_to_V(): # noqa: N802
347393
"""Color correction from band to V
348394
@@ -864,12 +910,7 @@ def fit_legacy_models(
864910
865911
Returns
866912
-------
867-
popt: list
868-
Estimated parameters for `func`
869-
perr: list
870-
Error estimates on popt elements
871-
chi2_red: float
872-
Reduced chi2
913+
outdic: dict
873914
"""
874915
if p0 is None:
875916
p0 = [15, 0.15, 0.15]
@@ -994,6 +1035,129 @@ def fit_legacy_models(
9941035
return outdic
9951036

9961037

1038+
def fit_sfhg1g2(
1039+
ssnamenr,
1040+
magpsf_red,
1041+
sigmapsf,
1042+
jds,
1043+
phase,
1044+
filters,
1045+
):
1046+
"""Fit for phase curve parameters for sfHG1G2
1047+
1048+
Notes
1049+
-----
1050+
Unlike other models, it returns less information, and
1051+
only per-band.
1052+
1053+
Parameters
1054+
----------
1055+
ssnamenr: str
1056+
SSO name/number
1057+
magpsf_red: array
1058+
Reduced magnitude, that is m_obs - 5 * np.log10('Dobs' * 'Dhelio')
1059+
sigmapsf: array
1060+
Error estimates on magpsf_red
1061+
jds: array
1062+
Julian Dates
1063+
phase: array
1064+
Phase angle [rad]
1065+
filters: array
1066+
Filter name for each measurement
1067+
1068+
Returns
1069+
-------
1070+
outdic: dict
1071+
Dictionary containing reduced chi2, and estimated parameters and
1072+
error on each parameters.
1073+
"""
1074+
# exit if NaN values
1075+
if not np.all([i == i for i in magpsf_red]):
1076+
outdic = {"fit": 1, "status": -2}
1077+
return outdic
1078+
1079+
pdf = pd.DataFrame({
1080+
"i:magpsf_red": magpsf_red,
1081+
"i:sigmapsf": sigmapsf,
1082+
"Phase": phase,
1083+
"i:jd": jds,
1084+
"i:fid": filters,
1085+
})
1086+
pdf = pdf.sort_values("i:jd")
1087+
1088+
# Get oppositions
1089+
pdf[["elong", "elongFlag"]] = get_opposition(pdf["i:jd"].to_numpy(), ssnamenr)
1090+
1091+
# loop over filters
1092+
ufilters = np.unique(pdf["i:fid"].to_numpy())
1093+
outdics = {}
1094+
for filt in ufilters:
1095+
outdic = {}
1096+
1097+
# Select data for a filter
1098+
sub = pdf[pdf["i:fid"] == filt].copy()
1099+
1100+
# Compute apparitions
1101+
splitted = split_dataframe_per_apparition(sub, "elongFlag", "i:jd")
1102+
napparition = len(splitted)
1103+
1104+
# H for all apparitions plus G1, G2
1105+
params_ = ["G1", "G2", *["H{}".format(i) for i in range(napparition)]]
1106+
params = [i + "_{}".format(str(filt)) for i in params_]
1107+
1108+
# Split phase
1109+
phase_list = [df["Phase"].to_numpy().tolist() for df in splitted]
1110+
1111+
# Fit
1112+
res_lsq = least_squares(
1113+
sfhg1g2_error_fun,
1114+
x0=[0.15, 0.15] + [15] * napparition,
1115+
bounds=(
1116+
[0, 0] + [-3] * napparition,
1117+
[1, 1] + [30] * napparition,
1118+
),
1119+
loss="huber",
1120+
method="trf",
1121+
args=[
1122+
phase_list,
1123+
sub["i:magpsf_red"].to_numpy().tolist(),
1124+
],
1125+
xtol=1e-20,
1126+
gtol=1e-17,
1127+
)
1128+
1129+
# Result
1130+
popt = res_lsq.x
1131+
1132+
# estimate covariance matrix using the jacobian
1133+
try:
1134+
cov = linalg.inv(res_lsq.jac.T @ res_lsq.jac)
1135+
chi2dof = np.sum(res_lsq.fun**2) / (res_lsq.fun.size - res_lsq.x.size)
1136+
cov *= chi2dof
1137+
1138+
# 1sigma uncertainty on fitted parameters
1139+
perr = np.sqrt(np.diag(cov))
1140+
except np.linalg.LinAlgError:
1141+
# raised if jacobian is degenerated
1142+
outdic = {"fit": 4, "status": res_lsq.status}
1143+
return outdic
1144+
1145+
chisq = np.sum((res_lsq.fun / sub["i:sigmapsf"]) ** 2)
1146+
chisq_red = chisq / (res_lsq.fun.size - res_lsq.x.size - 1)
1147+
outdic["chi2red_{}".format(filt)] = chisq_red
1148+
outdic["rms_{}".format(filt)] = np.sqrt(np.mean(res_lsq.fun**2))
1149+
outdic["n_obs_{}".format(filt)] = len(sub)
1150+
outdic["n_app_{}".format(filt)] = napparition
1151+
1152+
for i in range(len(params)):
1153+
outdic[params[i]] = popt[i]
1154+
outdic["err_" + params[i]] = perr[i]
1155+
1156+
outdics.update(outdic)
1157+
1158+
return outdics
1159+
1160+
9971161
def fit_spin(
9981162
magpsf_red,
9991163
sigmapsf,

fink_utils/sso/utils.py

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,9 @@
2121
import numpy as np
2222

2323
import astropy.constants as const
24+
from astropy.time import Time
25+
from astroquery.jplhorizons import Horizons
26+
2427

2528
from scipy import signal
2629

@@ -385,6 +388,153 @@ def retrieve_first_date_of_next_month(mydate):
385388
return (mydate.replace(day=1) + datetime.timedelta(days=32)).replace(day=1)
386389

387390

391+
def split_dataframe_per_apparition(df, column_name, time_column):
392+
"""Split a dataframe per apparition
393+
394+
Notes
395+
-----
396+
Function from M. Colazo
397+
398+
Parameters
399+
----------
400+
df: Pandas DataFrame
401+
Input DataFrame
402+
column_name: str
403+
Name of column containing information on oppositions.
404+
This information comes from horizons.
405+
time_column: str
406+
Name of the column containing times, in JD.
407+
408+
Returns
409+
-------
410+
out: list of DataFrames
411+
Initial DataFrame splitted. Each DataFrame
412+
contains data for a given opposition.
413+
414+
Examples
415+
--------
416+
>>> pdf = pd.DataFrame({"elongFlag": ["/L", "/L", "/T", "/T"], "jd": [1, 2, 2000, 4000]})
417+
>>> splitted = split_dataframe_per_apparition(pdf, "elongFlag", "jd")
418+
>>> len(splitted)
419+
2
420+
"""
421+
df_list = []
422+
temp_df = None
423+
prev_value = None
424+
prev_time = None
425+
426+
for _, row in df.iterrows():
427+
current_value = row[column_name]
428+
current_time = row[time_column]
429+
430+
if current_value.startswith("/L") and (
431+
prev_value is None or prev_value.startswith("/T")
432+
):
433+
if temp_df is not None and not temp_df.empty:
434+
df_list.append(temp_df)
435+
temp_df = pd.DataFrame(columns=df.columns)
436+
elif current_value.startswith("/T") and (
437+
prev_value is None or prev_value.startswith("/L")
438+
):
439+
if temp_df is None:
440+
temp_df = pd.DataFrame(columns=df.columns)
441+
elif current_value.startswith("/T") and prev_value.startswith("/T"):
442+
current_time = row[
443+
time_column
444+
] # Extract the Julian date from the current row.
445+
if prev_time is not None and (current_time - prev_time) > 182.5:
446+
# If the difference is greater than 6 months, a new apparition begins.
447+
df_list.append(temp_df)
448+
temp_df = pd.DataFrame(columns=df.columns)
449+
if temp_df is None:
450+
temp_df = pd.DataFrame(columns=df.columns)
451+
452+
if temp_df is not None:
453+
temp_df = pd.concat([temp_df, row.to_frame().T], ignore_index=True)
454+
455+
# Update previous values
456+
prev_value = current_value
457+
prev_time = current_time
458+
459+
if temp_df is not None and not temp_df.empty:
460+
df_list.append(temp_df)
461+
462+
return df_list
463+
464+
465+
def get_opposition(jds, ssnamenr, location="I41"):
466+
"""Get vector of opposition information
467+
468+
Notes
469+
-----
470+
Function from M. Colazo
471+
472+
Parameters
473+
----------
474+
jds: np.array
475+
Array of JD values (float).
476+
Must be UTC, and sorted.
477+
478+
Returns
479+
-------
480+
elong: np.array of float
481+
Elongation angle in degree
482+
elongFlag: np.array of str
483+
Elongation flag (/T or /L)
484+
485+
Examples
486+
--------
487+
# >>> import io
488+
# >>> import requests
489+
# >>> import pandas as pd
490+
491+
# >>> r = requests.post(
492+
# ... 'https://api.fink-portal.org/api/v1/sso',
493+
# ... json={
494+
# ... 'n_or_d': "8467",
495+
# ... 'withEphem': True,
496+
# ... 'output-format': 'json'
497+
# ... }
498+
# ... )
499+
# >>> pdf = pd.read_json(io.BytesIO(r.content))
500+
501+
# # estimate number of oppositions
502+
# >>> pdf = pdf.sort_values("i:jd")
503+
# >>> pdf[["elong", "elongFlag"]] = get_opposition(pdf["i:jd"].to_numpy(), "8467")
504+
"""
505+
# Get min and max TDB Julian dates
506+
jd_min, jd_max = min(jds), max(jds)
507+
508+
# Convert to calendar date format (YYYY-MM-DD)
509+
date_min = Time(jd_min, format="jd", scale="utc").iso[:10]
510+
date_max = Time(jd_max, format="jd", scale="utc").iso[:10]
511+
512+
# Query Horizons using the converted time range
513+
obj = Horizons(
514+
id=ssnamenr,
515+
location=location,
516+
epochs={"start": date_min, "stop": date_max, "step": "1d"},
517+
id_type="smallbody",
518+
)
519+
eph = obj.ephemerides()
520+
521+
# Convert ephemeris data to Pandas DataFrame
522+
eph_df = eph.to_pandas()
523+
524+
pdf = pd.DataFrame({"jds": jds})
525+
526+
# Merge on the closest Julian date
527+
pdf = pd.merge_asof(
528+
pdf.sort_values("jds"),
529+
eph_df[["datetime_jd", "elongFlag", "elong"]].sort_values("datetime_jd"),
530+
left_on="jds",
531+
right_on="datetime_jd",
532+
direction="nearest",
533+
)
534+
535+
return pdf[["elong", "elongFlag"]].to_numpy()
536+
537+
388538
if __name__ == "__main__":
389539
"""Execute the unit test suite"""
390540

0 commit comments

Comments
 (0)