From 783cd81ff358f5eab12f86ae9a417c1d7a60298d Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 14:47:56 +0100 Subject: [PATCH 01/14] Update README --- README.md | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 185 insertions(+) diff --git a/README.md b/README.md index a5a28b4..7b20daa 100644 --- a/README.md +++ b/README.md @@ -8,4 +8,189 @@ This repository contains the code source of the Fink REST API used to access obj ## Tests +## Profiling a route + +To profile a route, simply use: + +```bash +./profile_route.sh --route apps/routes/ +``` + +Depending on the route, you will see the details of the timings and a summary similar to: + +```python +File: /home/centos/fink-object-api/apps/routes/objects/utils.py +Function: extract_object_data at line 24 + +Line # Hits Time Per Hit % Time Line Contents +============================================================== + 24 @profile + 25 def extract_object_data(payload: dict) -> pd.DataFrame: + 26 """Extract data returned by HBase and format it in a Pandas data… + 27 + 28 Data is from /api/v1/objects + 29 + 30 Parameters + 31 ---------- + 32 payload: dict + 33 See https://fink-portal.org/api/v1/objects + 34 + 35 Return + 36 ---------- + 37 out: pandas dataframe + 38 """ + 39 1 1.4 1.4 0.0 if "columns" in payload: + 40 cols = payload["columns"].replace(" ", "") + 41 else: + 42 1 0.6 0.6 0.0 cols = "*" + 43 + 44 1 0.8 0.8 0.0 if "," in payload["objectId"]: + 45 # multi-objects search + 46 splitids = payload["objectId"].split(",") + 47 objectids = [f"key:key:{i.strip()}" for i in splitids] + 48 else: + 49 # single object search + 50 1 2.3 2.3 0.0 objectids = ["key:key:{}".format(payload["objectId"])] + 51 + 52 1 0.4 0.4 0.0 if "withcutouts" in payload and str(payload["withcutouts"]) == "… + 53 withcutouts = True + 54 else: + 55 1 0.4 0.4 0.0 withcutouts = False + 56 + 57 1 1.5 1.5 0.0 if "withupperlim" in payload and str(payload["withupperlim"]) ==… + 58 1 0.3 0.3 0.0 withupperlim = True + 59 else: + 60 withupperlim = False + 61 + 62 1 0.4 0.4 0.0 if cols == "*": + 63 1 0.3 0.3 0.0 truncated = False + 64 else: + 65 truncated = True + 66 + 67 1 3241740.4 3e+06 79.7 client = connect_to_hbase_table("ztf") + 68 + 69 # Get data from the main table + 70 1 0.7 0.7 0.0 results = {} + 71 2 2.9 1.4 0.0 for to_evaluate in objectids: + 72 2 189018.6 94509.3 4.6 result = client.scan( + 73 1 0.5 0.5 0.0 "", + 74 1 0.4 0.4 0.0 to_evaluate, + 75 1 0.5 0.5 0.0 cols, + 76 1 0.4 0.4 0.0 0, + 77 1 0.4 0.4 0.0 True, + 78 1 0.2 0.2 0.0 True, + 79 ) + 80 1 89598.4 89598.4 2.2 results.update(result) + 81 + 82 1 1104.8 1104.8 0.0 schema_client = client.schema() + 83 + 84 2 334126.7 167063.3 8.2 pdf = format_hbase_output( + 85 1 0.5 0.5 0.0 results, + 86 1 0.6 0.6 0.0 schema_client, + 87 1 0.8 0.8 0.0 group_alerts=False, + 88 1 0.2 0.2 0.0 truncated=truncated, + 89 ) + 90 + 91 1 0.5 0.5 0.0 if withcutouts: + 92 # Default `None` returns all 3 cutouts + 93 cutout_kind = payload.get("cutout-kind", "All") + 94 + 95 if cutout_kind == "All": + 96 cols = [ + 97 "b:cutoutScience_stampData", + 98 "b:cutoutTemplate_stampData", + 99 "b:cutoutDifference_stampData", + 100 ] + 101 pdf[cols] = pdf[["i:objectId", "i:candid"]].apply( + 102 lambda x: pd.Series(download_cutout(x.iloc[0], x.ilo… + 103 axis=1, + 104 ) + 105 else: + 106 colname = "b:cutout{}_stampData".format(cutout_kind) + 107 pdf[colname] = pdf[["i:objectId", "i:candid"]].apply( + 108 lambda x: pd.Series( + 109 [download_cutout(x.iloc[0], x.iloc[1], cutout_ki… + 110 ), + 111 axis=1, + 112 ) + 113 + 114 1 0.3 0.3 0.0 if withupperlim: + 115 1 71884.1 71884.1 1.8 clientU = connect_to_hbase_table("ztf.upper") + 116 # upper limits + 117 1 0.9 0.9 0.0 resultsU = {} + 118 2 3.7 1.9 0.0 for to_evaluate in objectids: + 119 2 15889.3 7944.6 0.4 resultU = clientU.scan( + 120 1 0.6 0.6 0.0 "", + 121 1 0.8 0.8 0.0 to_evaluate, + 122 1 0.9 0.9 0.0 "*", + 123 1 0.7 0.7 0.0 0, + 124 1 0.4 0.4 0.0 False, + 125 1 0.2 0.2 0.0 False, + 126 ) + 127 1 305.0 305.0 0.0 resultsU.update(resultU) + 128 + 129 # bad quality + 130 1 50285.9 50285.9 1.2 clientUV = connect_to_hbase_table("ztf.uppervalid") + 131 1 0.8 0.8 0.0 resultsUP = {} + 132 2 2.1 1.1 0.0 for to_evaluate in objectids: + 133 2 30262.9 15131.4 0.7 resultUP = clientUV.scan( + 134 1 0.6 0.6 0.0 "", + 135 1 0.7 0.7 0.0 to_evaluate, + 136 1 0.4 0.4 0.0 "*", + 137 1 0.3 0.3 0.0 0, + 138 1 0.3 0.3 0.0 False, + 139 1 0.2 0.2 0.0 False, + 140 ) + 141 1 243.3 243.3 0.0 resultsUP.update(resultUP) + 142 + 143 1 1729.8 1729.8 0.0 pdfU = pd.DataFrame.from_dict(hbase_to_dict(resultsU), orien… + 144 1 1379.2 1379.2 0.0 pdfUP = pd.DataFrame.from_dict(hbase_to_dict(resultsUP), ori… + 145 + 146 1 564.8 564.8 0.0 pdf["d:tag"] = "valid" + 147 1 514.7 514.7 0.0 pdfU["d:tag"] = "upperlim" + 148 1 462.0 462.0 0.0 pdfUP["d:tag"] = "badquality" + 149 + 150 1 33.3 33.3 0.0 if "i:jd" in pdfUP.columns: + 151 # workaround -- see https://github.com/astrolabsoftware/… + 152 2 5.5 2.7 0.0 mask = nparray( + 153 2 595.8 297.9 0.0 [ + 154 False if float(i) in pdf["i:jd"].to_numpy() else… + 155 1 171.4 171.4 0.0 for i in pdfUP["i:jd"].to_numpy() + 156 ] + 157 ) + 158 1 415.6 415.6 0.0 pdfUP = pdfUP[mask] + 159 + 160 # Hacky way to avoid converting concatenated column to float + 161 1 482.1 482.1 0.0 pdfU["i:candid"] = -1 # None + 162 1 417.1 417.1 0.0 pdfUP["i:candid"] = -1 # None + 163 + 164 1 24291.7 24291.7 0.6 pdf_ = pd.concat((pdf, pdfU, pdfUP), axis=0) + 165 + 166 # replace + 167 1 8.9 8.9 0.0 if "i:jd" in pdf_.columns: + 168 1 454.0 454.0 0.0 pdf_["i:jd"] = pdf_["i:jd"].astype(float) + 169 1 4052.8 4052.8 0.1 pdf = pdf_.sort_values("i:jd", ascending=False) + 170 else: + 171 pdf = pdf_ + 172 + 173 1 3326.7 3326.7 0.1 clientU.close() + 174 1 1364.8 1364.8 0.0 clientUV.close() + 175 + 176 1 1946.0 1946.0 0.0 client.close() + 177 + 178 1 0.7 0.7 0.0 return pdf + + + 0.00 seconds - /home/centos/fink-object-api/apps/utils/utils.py:49 - download_cutout + 0.00 seconds - /home/centos/fink-object-api/apps/utils/client.py:101 - create_or_update_hbase_table + 0.04 seconds - /home/centos/fink-object-api/apps/utils/decoding.py:175 - extract_rate_and_color + 0.07 seconds - /home/centos/fink-object-api/apps/utils/decoding.py:144 - hbase_to_dict + 0.32 seconds - /home/centos/fink-object-api/apps/utils/client.py:28 - initialise_jvm + 0.33 seconds - /home/centos/fink-object-api/apps/utils/decoding.py:40 - format_hbase_output + 3.36 seconds - /home/centos/fink-object-api/apps/utils/client.py:48 - connect_to_hbase_table + 4.07 seconds - /home/centos/fink-object-api/apps/routes/objects/utils.py:24 - extract_object_data +``` + ## Adding a new route + +You find a [template](apps/routes/template) route to start a new route. Just copy this folder, and modify it with your new route. Alternatively, you can see how other routes are structured to get inspiration. Do not forget to add tests in the [test folder](tests/)! From 5e62ae085350698ef13ebde71bf596585dd5f4ef Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 15:59:00 +0100 Subject: [PATCH 02/14] Update doc --- README.md | 247 +++++++++++++++++------------------------------------- 1 file changed, 79 insertions(+), 168 deletions(-) diff --git a/README.md b/README.md index 7b20daa..ed37720 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,76 @@ This repository contains the code source of the Fink REST API used to access object data stored in tables in Apache HBase. -## Installation +## Requirements and installation + +You will need Python installed (>=3.11) with requirements listed in [requirements.txt](requirements.txt). You will also need [fink-cutout-api](https://github.com/astrolabsoftware/fink-cutout-api) fully installed (which implies Hadoop installed on the machine, and Java 11 at least). For the full installation and deployment, refer as to the [procedure](install/README.md). + +## Configuration + +First you need to configure the parameters in [config.yml](config.yml): + +```yml +# Host and port of the application +HOST: localhost +PORT: 32000 + +# URL of the fink_cutout_api +CUTOUTAPIURL: http://localhost + +# HBase configuration +HBASEIP: localhost +ZOOPORT: 2183 + +# Table schema (schema_{fink_broker}_{fink_science}) +SCHEMAVER: schema_3.1_5.21.14 + +# Maximum number of rows to +# return in one call +NLIMIT: 10000 +``` + +Make sure that the `SCHEMAVER` is the same you use for your tables in HBase. + +TODO: +- [ ] Find a way to automatically sync schema with tables. ## Deployment +### Debug + +After starting `fink-cutout-api`, you can simply test the API using: + +```bash +python app.py +``` + +### Production + +The application is managed by `gunicorn` and `systemd` (see [install](install/README.md)), and you can simply manage it using: + +```bash +# start the application +systemctl start fink_object_api + +# reload the application if code changed +systemctl restart fink_object_api + +# stop the application +systemctl stop fink_object_api +``` + +TODO: +- [ ] Add nginx management +- [ ] Add bash scripts under `bin/` to manage both nginx and gunicorn + ## Tests +All the routes are extensively tested. To trigger a test, simply run: + +```bash + +``` + ## Profiling a route To profile a route, simply use: @@ -19,176 +83,23 @@ To profile a route, simply use: Depending on the route, you will see the details of the timings and a summary similar to: ```python -File: /home/centos/fink-object-api/apps/routes/objects/utils.py -Function: extract_object_data at line 24 +Wrote profile results to profiling.py.lprof +Inspect results with: +python -m line_profiler -rmt "profiling.py.lprof" +Timer unit: 1e-06 s + +Total time: 0.000241599 s +File: /home/peloton/codes/fink-object-api/apps/routes/template/utils.py +Function: my_function at line 19 Line # Hits Time Per Hit % Time Line Contents ============================================================== - 24 @profile - 25 def extract_object_data(payload: dict) -> pd.DataFrame: - 26 """Extract data returned by HBase and format it in a Pandas data… - 27 - 28 Data is from /api/v1/objects - 29 - 30 Parameters - 31 ---------- - 32 payload: dict - 33 See https://fink-portal.org/api/v1/objects - 34 - 35 Return - 36 ---------- - 37 out: pandas dataframe - 38 """ - 39 1 1.4 1.4 0.0 if "columns" in payload: - 40 cols = payload["columns"].replace(" ", "") - 41 else: - 42 1 0.6 0.6 0.0 cols = "*" - 43 - 44 1 0.8 0.8 0.0 if "," in payload["objectId"]: - 45 # multi-objects search - 46 splitids = payload["objectId"].split(",") - 47 objectids = [f"key:key:{i.strip()}" for i in splitids] - 48 else: - 49 # single object search - 50 1 2.3 2.3 0.0 objectids = ["key:key:{}".format(payload["objectId"])] - 51 - 52 1 0.4 0.4 0.0 if "withcutouts" in payload and str(payload["withcutouts"]) == "… - 53 withcutouts = True - 54 else: - 55 1 0.4 0.4 0.0 withcutouts = False - 56 - 57 1 1.5 1.5 0.0 if "withupperlim" in payload and str(payload["withupperlim"]) ==… - 58 1 0.3 0.3 0.0 withupperlim = True - 59 else: - 60 withupperlim = False - 61 - 62 1 0.4 0.4 0.0 if cols == "*": - 63 1 0.3 0.3 0.0 truncated = False - 64 else: - 65 truncated = True - 66 - 67 1 3241740.4 3e+06 79.7 client = connect_to_hbase_table("ztf") - 68 - 69 # Get data from the main table - 70 1 0.7 0.7 0.0 results = {} - 71 2 2.9 1.4 0.0 for to_evaluate in objectids: - 72 2 189018.6 94509.3 4.6 result = client.scan( - 73 1 0.5 0.5 0.0 "", - 74 1 0.4 0.4 0.0 to_evaluate, - 75 1 0.5 0.5 0.0 cols, - 76 1 0.4 0.4 0.0 0, - 77 1 0.4 0.4 0.0 True, - 78 1 0.2 0.2 0.0 True, - 79 ) - 80 1 89598.4 89598.4 2.2 results.update(result) - 81 - 82 1 1104.8 1104.8 0.0 schema_client = client.schema() - 83 - 84 2 334126.7 167063.3 8.2 pdf = format_hbase_output( - 85 1 0.5 0.5 0.0 results, - 86 1 0.6 0.6 0.0 schema_client, - 87 1 0.8 0.8 0.0 group_alerts=False, - 88 1 0.2 0.2 0.0 truncated=truncated, - 89 ) - 90 - 91 1 0.5 0.5 0.0 if withcutouts: - 92 # Default `None` returns all 3 cutouts - 93 cutout_kind = payload.get("cutout-kind", "All") - 94 - 95 if cutout_kind == "All": - 96 cols = [ - 97 "b:cutoutScience_stampData", - 98 "b:cutoutTemplate_stampData", - 99 "b:cutoutDifference_stampData", - 100 ] - 101 pdf[cols] = pdf[["i:objectId", "i:candid"]].apply( - 102 lambda x: pd.Series(download_cutout(x.iloc[0], x.ilo… - 103 axis=1, - 104 ) - 105 else: - 106 colname = "b:cutout{}_stampData".format(cutout_kind) - 107 pdf[colname] = pdf[["i:objectId", "i:candid"]].apply( - 108 lambda x: pd.Series( - 109 [download_cutout(x.iloc[0], x.iloc[1], cutout_ki… - 110 ), - 111 axis=1, - 112 ) - 113 - 114 1 0.3 0.3 0.0 if withupperlim: - 115 1 71884.1 71884.1 1.8 clientU = connect_to_hbase_table("ztf.upper") - 116 # upper limits - 117 1 0.9 0.9 0.0 resultsU = {} - 118 2 3.7 1.9 0.0 for to_evaluate in objectids: - 119 2 15889.3 7944.6 0.4 resultU = clientU.scan( - 120 1 0.6 0.6 0.0 "", - 121 1 0.8 0.8 0.0 to_evaluate, - 122 1 0.9 0.9 0.0 "*", - 123 1 0.7 0.7 0.0 0, - 124 1 0.4 0.4 0.0 False, - 125 1 0.2 0.2 0.0 False, - 126 ) - 127 1 305.0 305.0 0.0 resultsU.update(resultU) - 128 - 129 # bad quality - 130 1 50285.9 50285.9 1.2 clientUV = connect_to_hbase_table("ztf.uppervalid") - 131 1 0.8 0.8 0.0 resultsUP = {} - 132 2 2.1 1.1 0.0 for to_evaluate in objectids: - 133 2 30262.9 15131.4 0.7 resultUP = clientUV.scan( - 134 1 0.6 0.6 0.0 "", - 135 1 0.7 0.7 0.0 to_evaluate, - 136 1 0.4 0.4 0.0 "*", - 137 1 0.3 0.3 0.0 0, - 138 1 0.3 0.3 0.0 False, - 139 1 0.2 0.2 0.0 False, - 140 ) - 141 1 243.3 243.3 0.0 resultsUP.update(resultUP) - 142 - 143 1 1729.8 1729.8 0.0 pdfU = pd.DataFrame.from_dict(hbase_to_dict(resultsU), orien… - 144 1 1379.2 1379.2 0.0 pdfUP = pd.DataFrame.from_dict(hbase_to_dict(resultsUP), ori… - 145 - 146 1 564.8 564.8 0.0 pdf["d:tag"] = "valid" - 147 1 514.7 514.7 0.0 pdfU["d:tag"] = "upperlim" - 148 1 462.0 462.0 0.0 pdfUP["d:tag"] = "badquality" - 149 - 150 1 33.3 33.3 0.0 if "i:jd" in pdfUP.columns: - 151 # workaround -- see https://github.com/astrolabsoftware/… - 152 2 5.5 2.7 0.0 mask = nparray( - 153 2 595.8 297.9 0.0 [ - 154 False if float(i) in pdf["i:jd"].to_numpy() else… - 155 1 171.4 171.4 0.0 for i in pdfUP["i:jd"].to_numpy() - 156 ] - 157 ) - 158 1 415.6 415.6 0.0 pdfUP = pdfUP[mask] - 159 - 160 # Hacky way to avoid converting concatenated column to float - 161 1 482.1 482.1 0.0 pdfU["i:candid"] = -1 # None - 162 1 417.1 417.1 0.0 pdfUP["i:candid"] = -1 # None - 163 - 164 1 24291.7 24291.7 0.6 pdf_ = pd.concat((pdf, pdfU, pdfUP), axis=0) - 165 - 166 # replace - 167 1 8.9 8.9 0.0 if "i:jd" in pdf_.columns: - 168 1 454.0 454.0 0.0 pdf_["i:jd"] = pdf_["i:jd"].astype(float) - 169 1 4052.8 4052.8 0.1 pdf = pdf_.sort_values("i:jd", ascending=False) - 170 else: - 171 pdf = pdf_ - 172 - 173 1 3326.7 3326.7 0.1 clientU.close() - 174 1 1364.8 1364.8 0.0 clientUV.close() - 175 - 176 1 1946.0 1946.0 0.0 client.close() - 177 - 178 1 0.7 0.7 0.0 return pdf - - - 0.00 seconds - /home/centos/fink-object-api/apps/utils/utils.py:49 - download_cutout - 0.00 seconds - /home/centos/fink-object-api/apps/utils/client.py:101 - create_or_update_hbase_table - 0.04 seconds - /home/centos/fink-object-api/apps/utils/decoding.py:175 - extract_rate_and_color - 0.07 seconds - /home/centos/fink-object-api/apps/utils/decoding.py:144 - hbase_to_dict - 0.32 seconds - /home/centos/fink-object-api/apps/utils/client.py:28 - initialise_jvm - 0.33 seconds - /home/centos/fink-object-api/apps/utils/decoding.py:40 - format_hbase_output - 3.36 seconds - /home/centos/fink-object-api/apps/utils/client.py:48 - connect_to_hbase_table - 4.07 seconds - /home/centos/fink-object-api/apps/routes/objects/utils.py:24 - extract_object_data + 19 @profile + 20 def my_function(payload): + 21 1 241.6 241.6 100.0 return pd.DataFrame({payload["arg1"]: [1, 2, 3]}) + + + 0.00 seconds - /home/peloton/codes/fink-object-api/apps/routes/template/utils.py:19 - my_function ``` ## Adding a new route From 6a0040266695f1c0cdf7033825e1b7fb9602e692 Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 15:59:11 +0100 Subject: [PATCH 03/14] Add profiling to template --- apps/routes/template/utils.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/routes/template/utils.py b/apps/routes/template/utils.py index 89e5767..d223eaa 100644 --- a/apps/routes/template/utils.py +++ b/apps/routes/template/utils.py @@ -14,6 +14,8 @@ # limitations under the License. import pandas as pd +from line_profiler import profile +@profile def my_function(payload): return pd.DataFrame({payload["arg1"]: [1, 2, 3]}) From be8d5f8d98e428013a7e41137a0ea1f9f5ed8900 Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 16:04:26 +0100 Subject: [PATCH 04/14] Move test into routes --- apps/routes/cutouts/test.py | 196 +++++++++++++++++++++++++++++++ apps/routes/objects/test.py | 224 ++++++++++++++++++++++++++++++++++++ run_tests.sh | 3 +- 3 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 apps/routes/cutouts/test.py create mode 100644 apps/routes/objects/test.py diff --git a/apps/routes/cutouts/test.py b/apps/routes/cutouts/test.py new file mode 100644 index 0000000..8cf4aae --- /dev/null +++ b/apps/routes/cutouts/test.py @@ -0,0 +1,196 @@ +# Copyright 2022-2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import requests +import numpy as np + +from astropy.io import fits + +from PIL import Image + +import io +import sys + +APIURL = sys.argv[1] + + +def cutouttest( + objectId="ZTF21aaxtctv", + kind="Science", + stretch="sigmoid", + colormap="viridis", + pmin=0.5, + pmax=99.5, + convolution_kernel=None, + output_format="PNG", + candid=None, +): + """Perform a cutout search in the Science Portal using the Fink REST API""" + payload = { + "objectId": objectId, + "kind": kind, # Science, Template, Difference + "stretch": stretch, # sigmoid[default], linear, sqrt, power, log, asinh + "colormap": colormap, # Valid matplotlib colormap name (see matplotlib.cm). Default is grayscale. + "pmin": pmin, # The percentile value used to determine the pixel value of minimum cut level. Default is 0.5. No effect for sigmoid. + "pmax": pmax, # The percentile value used to determine the pixel value of maximum cut level. Default is 99.5. No effect for sigmoid. + "output-format": output_format, + } + + if candid is not None: + payload.update({"candid": candid}) + + # Convolve the image with a kernel (gauss or box). Default is None (not specified). + if convolution_kernel is not None: + payload.update({"convolution_kernel": convolution_kernel}) + + r = requests.post("{}/api/v1/cutouts".format(APIURL), json=payload) + + assert r.status_code == 200, r.content + + if output_format == "PNG": + # Format output in a DataFrame + data = Image.open(io.BytesIO(r.content)) + elif output_format == "FITS": + data = fits.open(io.BytesIO(r.content), ignore_missing_simple=True) + elif output_format == "array": + data = r.json()["b:cutout{}_stampData".format(kind)] + + return data + + +def test_png_cutout() -> None: + """ + Examples + -------- + >>> test_png_cutout() + """ + data = cutouttest() + + assert data.format == "PNG" + assert data.size == (63, 63) + + +def test_fits_cutout() -> None: + """ + Examples + -------- + >>> test_fits_cutout() + """ + data = cutouttest(output_format="FITS") + + assert len(data) == 1 + assert np.shape(data[0].data) == (63, 63) + + +def test_array_cutout() -> None: + """ + Examples + -------- + >>> test_array_cutout() + """ + data = cutouttest(output_format="array") + + assert np.shape(data) == (63, 63), data + assert isinstance(data, list) + + +def test_kind_cutout() -> None: + """ + Examples + -------- + >>> test_kind_cutout() + """ + data1 = cutouttest(kind="Science", output_format="array") + data2 = cutouttest(kind="Template", output_format="array") + data3 = cutouttest(kind="Difference", output_format="array") + + assert data1 != data2 + assert data2 != data3 + + +def test_pvalues_cutout() -> None: + """ + Examples + -------- + >>> test_pvalues_cutout() + """ + # pmin and pmax have no effect if stretch = sigmoid + data1 = cutouttest() + data2 = cutouttest(pmin=0.1, pmax=0.5) + + assert data1.getextrema() == data2.getextrema() + + # pmin and pmax have an effect otherwise + data1 = cutouttest() + data2 = cutouttest(pmin=0.1, pmax=0.5, stretch="linear") + + assert data1.getextrema() != data2.getextrema() + + +def test_stretch_cutout() -> None: + """ + Examples + -------- + >>> test_stretch_cutout() + """ + # pmin and pmax have no effect if stretch = sigmoid + data1 = cutouttest(stretch="sigmoid") + + for stretch in ["linear", "sqrt", "power", "log"]: + data2 = cutouttest(stretch=stretch) + assert data1.getextrema() != data2.getextrema() + + +def test_colormap_cutout() -> None: + """ + Examples + -------- + >>> test_colormap_cutout() + """ + data1 = cutouttest() + data2 = cutouttest(colormap="Greys") + + assert data1.getextrema() != data2.getextrema() + + +def test_convolution_kernel_cutout() -> None: + """ + Examples + -------- + >>> test_convolution_kernel_cutout() + """ + data1 = cutouttest() + data2 = cutouttest(convolution_kernel="gauss") + + assert data1.getextrema() != data2.getextrema() + + +def test_candid_cutout() -> None: + """ + Examples + -------- + >>> test_candid_cutout() + """ + data1 = cutouttest() + data2 = cutouttest(candid="1622215345315015012") + + assert data1.getextrema() != data2.getextrema() + + +if __name__ == "__main__": + """ Execute the test suite """ + import sys + import doctest + + sys.exit(doctest.testmod()[0]) diff --git a/apps/routes/objects/test.py b/apps/routes/objects/test.py new file mode 100644 index 0000000..480481f --- /dev/null +++ b/apps/routes/objects/test.py @@ -0,0 +1,224 @@ +# Copyright 2022-2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +import requests +import pandas as pd +import numpy as np + +import io +import sys + +APIURL = sys.argv[1] + +# Implement random name generator +OID = "ZTF21abfmbix" + + +def get_an_object( + oid="ZTF21abfmbix", + output_format="json", + columns="*", + withupperlim=False, + withcutouts=False, + cutout_kind=None, +): + """Query an object from the Science Portal using the Fink REST API""" + payload = { + "objectId": oid, + "columns": columns, + "output-format": output_format, + "withupperlim": withupperlim, + "withcutouts": withcutouts, + } + + if cutout_kind is not None: + payload.update({"cutout-kind": cutout_kind}) + + r = requests.post("{}/api/v1/objects".format(APIURL), json=payload) + + assert r.status_code == 200, r.content + + if output_format == "json": + # Format output in a DataFrame + pdf = pd.read_json(io.BytesIO(r.content)) + elif output_format == "csv": + pdf = pd.read_csv(io.BytesIO(r.content)) + elif output_format == "parquet": + pdf = pd.read_parquet(io.BytesIO(r.content)) + + return pdf + + +def test_single_object() -> None: + """ + Examples + -------- + >>> test_single_object() + """ + pdf = get_an_object(oid=OID) + + assert not pdf.empty + + +def test_single_object_csv() -> None: + """ + Examples + -------- + >>> test_single_object_csv() + """ + pdf = get_an_object(oid=OID, output_format="csv") + + assert not pdf.empty + + +def test_single_object_parquet() -> None: + """ + Examples + -------- + >>> test_single_object_parquet() + """ + pdf = get_an_object(oid=OID, output_format="parquet") + + assert not pdf.empty + + +def test_column_selection() -> None: + """ + Examples + -------- + >>> test_column_selection() + """ + pdf = get_an_object(oid=OID, columns="i:jd,i:magpsf") + + assert len(pdf.columns) == 2, "I count {} columns".format(len(pdf.columns)) + + +def test_column_length() -> None: + """ + Examples + -------- + >>> test_column_length() + """ + pdf = get_an_object(oid=OID) + + assert len(pdf.columns) == 129, "I count {} columns".format(len(pdf.columns)) + + +def test_withupperlim() -> None: + """ + Examples + -------- + >>> test_withupperlim() + """ + pdf = get_an_object(oid=OID, withupperlim=True) + assert "d:tag" in pdf.columns + + +def test_withcutouts() -> None: + """ + Examples + -------- + >>> test_withcutouts() + """ + pdf = get_an_object(oid=OID, withcutouts=True) + + assert isinstance(pdf["b:cutoutScience_stampData"].to_numpy()[0], list) + assert isinstance(pdf["b:cutoutTemplate_stampData"].to_numpy()[0], list) + assert isinstance(pdf["b:cutoutDifference_stampData"].to_numpy()[0], list) + + +def test_withcutouts_single_field() -> None: + """ + Examples + -------- + >>> test_withcutouts_single_field() + """ + pdf = get_an_object(oid=OID, withcutouts=True, cutout_kind="Science") + + assert isinstance(pdf["b:cutoutScience_stampData"].to_numpy()[0], list) + assert "b:cutoutTemplate_stampData" not in pdf.columns + + +def test_formatting() -> None: + """ + Examples + -------- + >>> test_formatting() + """ + pdf = get_an_object(oid=OID) + + # stupid python cast... + assert isinstance(pdf["i:fid"].to_numpy()[0], np.int64), type( + pdf["i:fid"].to_numpy()[0] + ) + assert isinstance(pdf["i:magpsf"].to_numpy()[0], np.double), type( + pdf["i:magpsf"].to_numpy()[0] + ) + + +def test_misc() -> None: + """ + Examples + -------- + >>> test_misc() + """ + pdf = get_an_object(oid=OID) + assert np.all(pdf["i:fid"].to_numpy() > 0) + assert np.all(pdf["i:magpsf"].to_numpy() > 6) + + +def test_bad_request() -> None: + """ + Examples + -------- + >>> test_bad_request() + """ + pdf = get_an_object(oid="ldfksjflkdsjf") + + assert pdf.empty + + +def test_multiple_objects() -> None: + """ + Examples + -------- + >>> test_multiple_objects() + """ + OIDS_ = ["ZTF21abfmbix", "ZTF21aaxtctv", "ZTF21abfaohe"] + OIDS = ",".join(OIDS_) + pdf = get_an_object(oid=OIDS) + + n_oids = len(np.unique(pdf.groupby("i:objectId").count()["i:ra"])) + assert n_oids == 3 + + n_oids_single = 0 + len_object = 0 + for oid in OIDS_: + pdf_ = get_an_object(oid=oid) + n_oid = len(np.unique(pdf_.groupby("i:objectId").count()["i:ra"])) + n_oids_single += n_oid + len_object += len(pdf_) + + assert n_oids == n_oids_single, "{} is not equal to {}".format( + n_oids, n_oids_single + ) + assert len_object == len(pdf), "{} is not equal to {}".format(len_object, len(pdf)) + + +if __name__ == "__main__": + """ Execute the test suite """ + import sys + import doctest + + sys.exit(doctest.testmod()[0]) diff --git a/run_tests.sh b/run_tests.sh index 526cf0c..c00c3a3 100755 --- a/run_tests.sh +++ b/run_tests.sh @@ -41,8 +41,7 @@ if [[ -f $URL ]]; then fi # Run the test suite on the utilities -cd tests -for filename in ./*.py +for filename in apps/routes/*/test.py do echo $filename # Run test suite From 5590ae2261c3be3d0038658f4b9aa429ba931df3 Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 16:05:07 +0100 Subject: [PATCH 05/14] Add profiling capacity --- apps/routes/objects/profiling.py | 24 +++++++++++++++++ apps/routes/template/profiling.py | 23 +++++++++++++++++ profile_route.sh | 43 +++++++++++++++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 apps/routes/objects/profiling.py create mode 100644 apps/routes/template/profiling.py create mode 100755 profile_route.sh diff --git a/apps/routes/objects/profiling.py b/apps/routes/objects/profiling.py new file mode 100644 index 0000000..eb45612 --- /dev/null +++ b/apps/routes/objects/profiling.py @@ -0,0 +1,24 @@ +# Copyright 2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Call extract_object_data""" +from apps.routes.objects.utils import extract_object_data + +payload = { + "objectId": "ZTF21abfmbix", + "withupperlim": True, + #"withcutouts": True, +} + +extract_object_data(payload) diff --git a/apps/routes/template/profiling.py b/apps/routes/template/profiling.py new file mode 100644 index 0000000..6bac004 --- /dev/null +++ b/apps/routes/template/profiling.py @@ -0,0 +1,23 @@ +# Copyright 2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Call extract_object_data""" +from apps.routes.template.utils import my_function + +payload = { + "arg1": "toto", +} + +my_function(payload) + diff --git a/profile_route.sh b/profile_route.sh new file mode 100755 index 0000000..dc8b71c --- /dev/null +++ b/profile_route.sh @@ -0,0 +1,43 @@ +#!/bin/bash +# Copyright 2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +## Script to launch the python test suite and measure the coverage. +## Must be launched as fink_test +set -e +message_help=""" +Profile a route\n\n +Usage:\n + \t./profile_route.sh --route \n\n +""" + +export ROOTPATH=`pwd` +# Grab the command line arguments +NO_SPARK=false +while [ "$#" -gt 0 ]; do + case "$1" in + --route) + ROUTE_PATH=$2 + shift 2 + ;; + -h) + echo -e $message_help + exit + ;; + esac +done + +kernprof -l $ROUTE_PATH/profiling.py +python -m line_profiler -rmt "profiling.py.lprof" + From 6a13a78ecedb6d566f4e9e32ebb19a797187864b Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 16:10:26 +0100 Subject: [PATCH 06/14] update the README --- install/README.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) create mode 100644 install/README.md diff --git a/install/README.md b/install/README.md new file mode 100644 index 0000000..efa92cf --- /dev/null +++ b/install/README.md @@ -0,0 +1,45 @@ +# API installation and deployment + +Fire a Virtual Machine, and follow instructions. Work perfectly on recent AlmaLinux. + +## Python dependencies + +Clone this repository, and install all python dependencies: + +```bash +pip install -r requirements.txt +``` + +## Fink cutout API installation + +Follow instructions in the [fink-cutout-api](https://github.com/astrolabsoftware/fink-cutout-api/blob/main/install/README.md). + +## Systemctl and gunicorn + +Install a new unit for systemd under `/etc/systemd/system/fink_object_api.service`: + +```bash +[Unit] +Description=gunicorn daemon for fink_object_api +After=network.target + +[Service] +User=root +Group=root +WorkingDirectory=/home/centos/fink-object-api + +ExecStart=/bin/sh -c 'source /root/.bashrc; exec /root/miniconda/bin/gunicorn --log-file=/tmp/fink_object_api.log app:app -b localhost:PORT2 --workers=1 --threads=8 --timeout 180 --chdir /home/centos/fink-object-api --bind unix:/run/fink_object_api.sock 2>&1 >> /tmp/fink_object_api.out' + +[Install] +WantedBy=multi-user.target +``` + +Make sure you change `PORT2` with your actual port. Reload units and launch the application: + +```bash +systemctl daemon-reload +systemctl start fink_object_api +``` + + +You are ready to use the API! From fd34cbfa73daf246e669c5aa90e72b3ffe7a3684 Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 16:28:44 +0100 Subject: [PATCH 07/14] Update documentation --- README.md | 14 +++++++++++++- install/README.md | 2 +- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ed37720..afcbadb 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,22 @@ TODO: ## Tests -All the routes are extensively tested. To trigger a test, simply run: +All the routes are extensively tested. To trigger a test on a route, simply run: ```bash +python apps/routes/objects/test.py $HOST:$PORT +``` + +By replacing `HOST` and `$PORT` with their values (could be the main API instance). If the program exits with no error or message, the test has been successful. + +TODO: +- [ ] Make tests more verbose, even is successful. +Alternatively, you can launch all tests using: + + +```bash +./run_tests.sh --url $HOST:$PORT ``` ## Profiling a route diff --git a/install/README.md b/install/README.md index efa92cf..2fa082b 100644 --- a/install/README.md +++ b/install/README.md @@ -34,7 +34,7 @@ ExecStart=/bin/sh -c 'source /root/.bashrc; exec /root/miniconda/bin/gunicorn -- WantedBy=multi-user.target ``` -Make sure you change `PORT2` with your actual port. Reload units and launch the application: +Make sure you change `PORT2` with your actual port. Make sure also to update path to `gunicorn`. Reload units and launch the application: ```bash systemctl daemon-reload From 8b07634c64b49d04bbc4ef12993022ce69925e7f Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Tue, 3 Dec 2024 16:31:53 +0100 Subject: [PATCH 08/14] PEP8 --- apps/routes/objects/profiling.py | 3 ++- apps/routes/template/profiling.py | 2 +- apps/routes/template/utils.py | 1 + 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/routes/objects/profiling.py b/apps/routes/objects/profiling.py index eb45612..2ebfb86 100644 --- a/apps/routes/objects/profiling.py +++ b/apps/routes/objects/profiling.py @@ -13,12 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. """Call extract_object_data""" + from apps.routes.objects.utils import extract_object_data payload = { "objectId": "ZTF21abfmbix", "withupperlim": True, - #"withcutouts": True, + # "withcutouts": True, } extract_object_data(payload) diff --git a/apps/routes/template/profiling.py b/apps/routes/template/profiling.py index 6bac004..b70718b 100644 --- a/apps/routes/template/profiling.py +++ b/apps/routes/template/profiling.py @@ -13,6 +13,7 @@ # See the License for the specific language governing permissions and # limitations under the License. """Call extract_object_data""" + from apps.routes.template.utils import my_function payload = { @@ -20,4 +21,3 @@ } my_function(payload) - diff --git a/apps/routes/template/utils.py b/apps/routes/template/utils.py index d223eaa..30eae36 100644 --- a/apps/routes/template/utils.py +++ b/apps/routes/template/utils.py @@ -16,6 +16,7 @@ from line_profiler import profile + @profile def my_function(payload): return pd.DataFrame({payload["arg1"]: [1, 2, 3]}) From 31121a9b1ab608d9d1a39e7394634fe03003878a Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Wed, 4 Dec 2024 09:19:33 +0100 Subject: [PATCH 09/14] Update doc --- .github/API_fink.png | Bin 0 -> 56036 bytes README.md | 8 ++++++-- requirements.txt | 2 ++ 3 files changed, 8 insertions(+), 2 deletions(-) create mode 100644 .github/API_fink.png diff --git a/.github/API_fink.png b/.github/API_fink.png new file mode 100644 index 0000000000000000000000000000000000000000..e33cf28e13227b3ae0375668563d368746001d92 GIT binary patch literal 56036 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

r&`0-C@ojn`o=~W8NVgzwH;xrUzkh_ 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 z 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 D!}WF~ literal 0 HcmV?d00001 diff --git a/README.md b/README.md index afcbadb..bf36f8f 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,14 @@ # Fink object API +[![Sentinel](https://github.com/astrolabsoftware/fink-object-api/workflows/Sentinel/badge.svg)](https://github.com/astrolabsoftware/fink-object-api/actions?query=workflow%3ASentinel) + +![structure](.github/API_fink.png) + This repository contains the code source of the Fink REST API used to access object data stored in tables in Apache HBase. ## Requirements and installation -You will need Python installed (>=3.11) with requirements listed in [requirements.txt](requirements.txt). You will also need [fink-cutout-api](https://github.com/astrolabsoftware/fink-cutout-api) fully installed (which implies Hadoop installed on the machine, and Java 11 at least). For the full installation and deployment, refer as to the [procedure](install/README.md). +You will need Python installed (>=3.11) with requirements listed in [requirements.txt](requirements.txt). You will also need [fink-cutout-api](https://github.com/astrolabsoftware/fink-cutout-api) fully installed (which implies Hadoop installed on the machine, and Java 11/17). For the full installation and deployment, refer as to the [procedure](install/README.md). ## Configuration @@ -39,7 +43,7 @@ TODO: ### Debug -After starting `fink-cutout-api`, you can simply test the API using: +After starting [fink-cutout-api](https://github.com/astrolabsoftware/fink-cutout-api), you can simply test the API using: ```bash python app.py diff --git a/requirements.txt b/requirements.txt index 834c7f4..eddf18e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,3 +8,5 @@ line_profiler requests pyarrow matplotlib +JPype1 +PyYAML From 8f4a01ee6a9bb8f839aad867672a9f2d1ae3684f Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Wed, 4 Dec 2024 14:39:28 +0100 Subject: [PATCH 10/14] Update requirement list --- requirements.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/requirements.txt b/requirements.txt index eddf18e..e1b8ecc 100644 --- a/requirements.txt +++ b/requirements.txt @@ -10,3 +10,4 @@ pyarrow matplotlib JPype1 PyYAML +pyspark From b915925c17616657abaf0af170fdf4a356ac5760 Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Wed, 4 Dec 2024 15:15:57 +0100 Subject: [PATCH 11/14] Update installation procedure --- install/README.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/install/README.md b/install/README.md index 2fa082b..c628460 100644 --- a/install/README.md +++ b/install/README.md @@ -24,21 +24,21 @@ Description=gunicorn daemon for fink_object_api After=network.target [Service] -User=root -Group=root -WorkingDirectory=/home/centos/fink-object-api +User=almalinux +Group=almalinux +WorkingDirectory=/home/almalinux/fink-object-api -ExecStart=/bin/sh -c 'source /root/.bashrc; exec /root/miniconda/bin/gunicorn --log-file=/tmp/fink_object_api.log app:app -b localhost:PORT2 --workers=1 --threads=8 --timeout 180 --chdir /home/centos/fink-object-api --bind unix:/run/fink_object_api.sock 2>&1 >> /tmp/fink_object_api.out' +ExecStart=/bin/sh -c 'source /home/almalinux/.bashrc; exec /home/almalinux/fink-env/bin/gunicorn --log-file=/tmp/fink_object_api.log app:app -b localhost:PORT2 --workers=1 --threads=8 --timeout 180 --chdir /home/almalinux/fink-object-api --bind unix:/home/almalinux/fink_object_api.sock 2>&1 >> /tmp/fink_object_api.out' [Install] WantedBy=multi-user.target ``` -Make sure you change `PORT2` with your actual port. Make sure also to update path to `gunicorn`. Reload units and launch the application: +Make sure you change `PORT2` with your actual port, and `localhost` with your domain. Make sure also to update path to `gunicorn`. Update the `config.yml`, reload units and launch the application: ```bash -systemctl daemon-reload -systemctl start fink_object_api +sudo systemctl daemon-reload +sudo systemctl start fink_object_api ``` From f76c820a2e2e26ce947300113028d55162ed729a Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Wed, 4 Dec 2024 16:27:47 +0100 Subject: [PATCH 12/14] Remove unused tests --- tests/api_cutouts_test.py | 196 ---------------------------- tests/api_single_object_test.py | 224 -------------------------------- 2 files changed, 420 deletions(-) delete mode 100644 tests/api_cutouts_test.py delete mode 100644 tests/api_single_object_test.py diff --git a/tests/api_cutouts_test.py b/tests/api_cutouts_test.py deleted file mode 100644 index 8cf4aae..0000000 --- a/tests/api_cutouts_test.py +++ /dev/null @@ -1,196 +0,0 @@ -# Copyright 2022-2024 AstroLab Software -# Author: Julien Peloton -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import requests -import numpy as np - -from astropy.io import fits - -from PIL import Image - -import io -import sys - -APIURL = sys.argv[1] - - -def cutouttest( - objectId="ZTF21aaxtctv", - kind="Science", - stretch="sigmoid", - colormap="viridis", - pmin=0.5, - pmax=99.5, - convolution_kernel=None, - output_format="PNG", - candid=None, -): - """Perform a cutout search in the Science Portal using the Fink REST API""" - payload = { - "objectId": objectId, - "kind": kind, # Science, Template, Difference - "stretch": stretch, # sigmoid[default], linear, sqrt, power, log, asinh - "colormap": colormap, # Valid matplotlib colormap name (see matplotlib.cm). Default is grayscale. - "pmin": pmin, # The percentile value used to determine the pixel value of minimum cut level. Default is 0.5. No effect for sigmoid. - "pmax": pmax, # The percentile value used to determine the pixel value of maximum cut level. Default is 99.5. No effect for sigmoid. - "output-format": output_format, - } - - if candid is not None: - payload.update({"candid": candid}) - - # Convolve the image with a kernel (gauss or box). Default is None (not specified). - if convolution_kernel is not None: - payload.update({"convolution_kernel": convolution_kernel}) - - r = requests.post("{}/api/v1/cutouts".format(APIURL), json=payload) - - assert r.status_code == 200, r.content - - if output_format == "PNG": - # Format output in a DataFrame - data = Image.open(io.BytesIO(r.content)) - elif output_format == "FITS": - data = fits.open(io.BytesIO(r.content), ignore_missing_simple=True) - elif output_format == "array": - data = r.json()["b:cutout{}_stampData".format(kind)] - - return data - - -def test_png_cutout() -> None: - """ - Examples - -------- - >>> test_png_cutout() - """ - data = cutouttest() - - assert data.format == "PNG" - assert data.size == (63, 63) - - -def test_fits_cutout() -> None: - """ - Examples - -------- - >>> test_fits_cutout() - """ - data = cutouttest(output_format="FITS") - - assert len(data) == 1 - assert np.shape(data[0].data) == (63, 63) - - -def test_array_cutout() -> None: - """ - Examples - -------- - >>> test_array_cutout() - """ - data = cutouttest(output_format="array") - - assert np.shape(data) == (63, 63), data - assert isinstance(data, list) - - -def test_kind_cutout() -> None: - """ - Examples - -------- - >>> test_kind_cutout() - """ - data1 = cutouttest(kind="Science", output_format="array") - data2 = cutouttest(kind="Template", output_format="array") - data3 = cutouttest(kind="Difference", output_format="array") - - assert data1 != data2 - assert data2 != data3 - - -def test_pvalues_cutout() -> None: - """ - Examples - -------- - >>> test_pvalues_cutout() - """ - # pmin and pmax have no effect if stretch = sigmoid - data1 = cutouttest() - data2 = cutouttest(pmin=0.1, pmax=0.5) - - assert data1.getextrema() == data2.getextrema() - - # pmin and pmax have an effect otherwise - data1 = cutouttest() - data2 = cutouttest(pmin=0.1, pmax=0.5, stretch="linear") - - assert data1.getextrema() != data2.getextrema() - - -def test_stretch_cutout() -> None: - """ - Examples - -------- - >>> test_stretch_cutout() - """ - # pmin and pmax have no effect if stretch = sigmoid - data1 = cutouttest(stretch="sigmoid") - - for stretch in ["linear", "sqrt", "power", "log"]: - data2 = cutouttest(stretch=stretch) - assert data1.getextrema() != data2.getextrema() - - -def test_colormap_cutout() -> None: - """ - Examples - -------- - >>> test_colormap_cutout() - """ - data1 = cutouttest() - data2 = cutouttest(colormap="Greys") - - assert data1.getextrema() != data2.getextrema() - - -def test_convolution_kernel_cutout() -> None: - """ - Examples - -------- - >>> test_convolution_kernel_cutout() - """ - data1 = cutouttest() - data2 = cutouttest(convolution_kernel="gauss") - - assert data1.getextrema() != data2.getextrema() - - -def test_candid_cutout() -> None: - """ - Examples - -------- - >>> test_candid_cutout() - """ - data1 = cutouttest() - data2 = cutouttest(candid="1622215345315015012") - - assert data1.getextrema() != data2.getextrema() - - -if __name__ == "__main__": - """ Execute the test suite """ - import sys - import doctest - - sys.exit(doctest.testmod()[0]) diff --git a/tests/api_single_object_test.py b/tests/api_single_object_test.py deleted file mode 100644 index 480481f..0000000 --- a/tests/api_single_object_test.py +++ /dev/null @@ -1,224 +0,0 @@ -# Copyright 2022-2024 AstroLab Software -# Author: Julien Peloton -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -import requests -import pandas as pd -import numpy as np - -import io -import sys - -APIURL = sys.argv[1] - -# Implement random name generator -OID = "ZTF21abfmbix" - - -def get_an_object( - oid="ZTF21abfmbix", - output_format="json", - columns="*", - withupperlim=False, - withcutouts=False, - cutout_kind=None, -): - """Query an object from the Science Portal using the Fink REST API""" - payload = { - "objectId": oid, - "columns": columns, - "output-format": output_format, - "withupperlim": withupperlim, - "withcutouts": withcutouts, - } - - if cutout_kind is not None: - payload.update({"cutout-kind": cutout_kind}) - - r = requests.post("{}/api/v1/objects".format(APIURL), json=payload) - - assert r.status_code == 200, r.content - - if output_format == "json": - # Format output in a DataFrame - pdf = pd.read_json(io.BytesIO(r.content)) - elif output_format == "csv": - pdf = pd.read_csv(io.BytesIO(r.content)) - elif output_format == "parquet": - pdf = pd.read_parquet(io.BytesIO(r.content)) - - return pdf - - -def test_single_object() -> None: - """ - Examples - -------- - >>> test_single_object() - """ - pdf = get_an_object(oid=OID) - - assert not pdf.empty - - -def test_single_object_csv() -> None: - """ - Examples - -------- - >>> test_single_object_csv() - """ - pdf = get_an_object(oid=OID, output_format="csv") - - assert not pdf.empty - - -def test_single_object_parquet() -> None: - """ - Examples - -------- - >>> test_single_object_parquet() - """ - pdf = get_an_object(oid=OID, output_format="parquet") - - assert not pdf.empty - - -def test_column_selection() -> None: - """ - Examples - -------- - >>> test_column_selection() - """ - pdf = get_an_object(oid=OID, columns="i:jd,i:magpsf") - - assert len(pdf.columns) == 2, "I count {} columns".format(len(pdf.columns)) - - -def test_column_length() -> None: - """ - Examples - -------- - >>> test_column_length() - """ - pdf = get_an_object(oid=OID) - - assert len(pdf.columns) == 129, "I count {} columns".format(len(pdf.columns)) - - -def test_withupperlim() -> None: - """ - Examples - -------- - >>> test_withupperlim() - """ - pdf = get_an_object(oid=OID, withupperlim=True) - assert "d:tag" in pdf.columns - - -def test_withcutouts() -> None: - """ - Examples - -------- - >>> test_withcutouts() - """ - pdf = get_an_object(oid=OID, withcutouts=True) - - assert isinstance(pdf["b:cutoutScience_stampData"].to_numpy()[0], list) - assert isinstance(pdf["b:cutoutTemplate_stampData"].to_numpy()[0], list) - assert isinstance(pdf["b:cutoutDifference_stampData"].to_numpy()[0], list) - - -def test_withcutouts_single_field() -> None: - """ - Examples - -------- - >>> test_withcutouts_single_field() - """ - pdf = get_an_object(oid=OID, withcutouts=True, cutout_kind="Science") - - assert isinstance(pdf["b:cutoutScience_stampData"].to_numpy()[0], list) - assert "b:cutoutTemplate_stampData" not in pdf.columns - - -def test_formatting() -> None: - """ - Examples - -------- - >>> test_formatting() - """ - pdf = get_an_object(oid=OID) - - # stupid python cast... - assert isinstance(pdf["i:fid"].to_numpy()[0], np.int64), type( - pdf["i:fid"].to_numpy()[0] - ) - assert isinstance(pdf["i:magpsf"].to_numpy()[0], np.double), type( - pdf["i:magpsf"].to_numpy()[0] - ) - - -def test_misc() -> None: - """ - Examples - -------- - >>> test_misc() - """ - pdf = get_an_object(oid=OID) - assert np.all(pdf["i:fid"].to_numpy() > 0) - assert np.all(pdf["i:magpsf"].to_numpy() > 6) - - -def test_bad_request() -> None: - """ - Examples - -------- - >>> test_bad_request() - """ - pdf = get_an_object(oid="ldfksjflkdsjf") - - assert pdf.empty - - -def test_multiple_objects() -> None: - """ - Examples - -------- - >>> test_multiple_objects() - """ - OIDS_ = ["ZTF21abfmbix", "ZTF21aaxtctv", "ZTF21abfaohe"] - OIDS = ",".join(OIDS_) - pdf = get_an_object(oid=OIDS) - - n_oids = len(np.unique(pdf.groupby("i:objectId").count()["i:ra"])) - assert n_oids == 3 - - n_oids_single = 0 - len_object = 0 - for oid in OIDS_: - pdf_ = get_an_object(oid=oid) - n_oid = len(np.unique(pdf_.groupby("i:objectId").count()["i:ra"])) - n_oids_single += n_oid - len_object += len(pdf_) - - assert n_oids == n_oids_single, "{} is not equal to {}".format( - n_oids, n_oids_single - ) - assert len_object == len(pdf), "{} is not equal to {}".format(len_object, len(pdf)) - - -if __name__ == "__main__": - """ Execute the test suite """ - import sys - import doctest - - sys.exit(doctest.testmod()[0]) From fa2e250cc6cc70b0a933e7d600f5e87178c669fe Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Wed, 4 Dec 2024 16:31:35 +0100 Subject: [PATCH 13/14] Add profiling for cutouts --- apps/routes/cutouts/profiling.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 apps/routes/cutouts/profiling.py diff --git a/apps/routes/cutouts/profiling.py b/apps/routes/cutouts/profiling.py new file mode 100644 index 0000000..cee0a71 --- /dev/null +++ b/apps/routes/cutouts/profiling.py @@ -0,0 +1,25 @@ +# Copyright 2024 AstroLab Software +# Author: Julien Peloton +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Call format_and_send_cutout""" + +from apps.routes.cutouts.utils import format_and_send_cutout + +payload = { + "objectId": "ZTF21abfmbix", + "kind": "All", + "output-format": "array", +} + +format_and_send_cutout(payload) From c782d0e9ac789c0532c9c9e9a70ec5768d341908 Mon Sep 17 00:00:00 2001 From: JulienPeloton Date: Thu, 5 Dec 2024 07:55:34 +0100 Subject: [PATCH 14/14] Update linter path --- .github/workflows/linter.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.github/workflows/linter.yml b/.github/workflows/linter.yml index 54fc11d..1c1d1af 100644 --- a/.github/workflows/linter.yml +++ b/.github/workflows/linter.yml @@ -28,9 +28,7 @@ jobs: run: | ruff check --statistics *.py ruff check --statistics apps/ - ruff check --ignore D205 tests/ - name: Format run: | ruff format --check *.py ruff format --check apps/ - ruff format --check tests/