Skip to content

Commit 9691d4f

Browse files
committed
add biomark data to the correct s3 bucket
1 parent 688aeee commit 9691d4f

4 files changed

Lines changed: 295 additions & 1 deletion

File tree

pyproject.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,9 @@ dependencies = [
1515
"numpy>=2.2.6",
1616
"structlog>=25.5.0",
1717
"rich>=14.2.0",
18-
"typer>=0.20.0"
18+
"typer>=0.20.0",
19+
"click>=8.3.0",
20+
"requests>=2.32.5"
1921
]
2022
description = ""
2123
license = "GPL-3.0+"
Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,277 @@
1+
#!/usr/bin/env python3
2+
3+
"""Main script."""
4+
5+
from datetime import datetime, timedelta
6+
7+
import click
8+
import dlt
9+
import environ
10+
import requests
11+
from dlt.destinations.impl.filesystem.factory import filesystem
12+
from dlt.sources.credentials import AwsCredentials
13+
from dlt.sources.rest_api import rest_api_source
14+
15+
from .settings import (
16+
BIOMARK_ACCESS_KEY,
17+
BIOMARK_API_EMAIL,
18+
BIOMARK_API_PWD,
19+
BIOMARK_AWS_ENDPOINT,
20+
BIOMARK_BASE_URL,
21+
BIOMARK_BUCKET,
22+
BIOMARK_PREFIX,
23+
BIOMARK_REGION,
24+
BIOMARK_SECRET_KEY,
25+
)
26+
from .settings import log as logger
27+
28+
env = environ.Env()
29+
30+
31+
def hex_to_decimal_tag(hex_tag):
32+
"""
33+
Convert hexadecimal PIT tag format to ISO decimal format.
34+
35+
Args:
36+
hex_tag (str): Hex tag in format like '3DD.003E550755'
37+
38+
Returns:
39+
str: ISO decimal tag like '989.001045759829'
40+
"""
41+
if not hex_tag or not isinstance(hex_tag, str):
42+
return None
43+
44+
try:
45+
# Split on the dot
46+
if "." not in hex_tag:
47+
return None
48+
left_hex, right_hex = hex_tag.split(".")
49+
# convert left part (manufacturer code) to decimal
50+
left_decimal = int(left_hex, 16)
51+
# convert right part to decimal and format as fractional part
52+
right_decimal = int(right_hex, 16)
53+
# combine with proper formatting
54+
iso_decimal = f"{left_decimal}.{right_decimal:012d}"
55+
return iso_decimal
56+
except (ValueError, TypeError):
57+
return None
58+
59+
60+
DEBUG = env.bool("DEBUG", default=False)
61+
DUCK_DB_NAME = env("DUCK_DB_NAME", default="pit_data_v4")
62+
63+
64+
SITES = {
65+
"kongsfjord": "0NK",
66+
"sylte": "0NS",
67+
"vigda": "0NV",
68+
"agdenes": "0NA",
69+
"vatne": "0NO",
70+
}
71+
72+
73+
@click.command()
74+
@click.option(
75+
"--place",
76+
help=(
77+
"Site location to download data from (kongsfjord, sylte, vigda, agdenes, vatne)"
78+
"Not required if --all-locations is used."
79+
),
80+
type=click.Choice(list(SITES.keys())),
81+
)
82+
@click.option("--begin_date", help="Start date for data download in YYYY-MM-DD format")
83+
@click.option("--end_date", help="End date for data download in YYYY-MM-DD format")
84+
@click.option("--tags", is_flag=True, help="Download only tags data")
85+
@click.option("--readers", is_flag=True, help="Download only readers voltage data")
86+
@click.option("--environment", is_flag=True, help="Download only environment data")
87+
@click.option(
88+
"--all_locations", is_flag=True, help="Download data from all accessible locations"
89+
)
90+
@click.option(
91+
"--skip_errors",
92+
is_flag=True,
93+
help="Continue processing other locations if one fails",
94+
)
95+
def main(
96+
place, begin_date, end_date, tags, readers, environment, all_locations, skip_errors
97+
) -> None:
98+
"""Start the application."""
99+
100+
# validate that either place or all_locations is specified
101+
if not all_locations and not place:
102+
raise click.UsageError(
103+
"Either --place must be specified or --all-locations flag must be used"
104+
)
105+
106+
# validate that at least one data type is selected
107+
if not any([tags, readers, environment]):
108+
raise click.UsageError(
109+
"At least one data type must be selected: "
110+
"--tags, --readers, or --environment"
111+
)
112+
113+
token = get_bearer_token()
114+
115+
# if there is no date set, take current day
116+
if begin_date is None or end_date is None:
117+
logger.info("Start date or end date is None, setting to today")
118+
begin_date = (datetime.today() - timedelta(days=1)).strftime("%Y-%m-%d")
119+
end_date = datetime.today().strftime("%Y-%m-%d")
120+
121+
# determine which locations to process
122+
if all_locations:
123+
# skip 'vatne' (0NO) as it returns 403 Forbidden
124+
accessible_sites = {k: v for k, v in SITES.items() if k != "vatne"}
125+
locations_to_process = list(accessible_sites.items())
126+
logger.info(
127+
"Processing accessible locations: %s", list(accessible_sites.keys())
128+
)
129+
if skip_errors:
130+
logger.info("Error handling enabled - will skip failed locations")
131+
else:
132+
locations_to_process = [(place, SITES[place])]
133+
logger.info("Processing single location: %s", place)
134+
135+
# create resources for all selected locations
136+
all_resources = []
137+
138+
for location_name, location_code in locations_to_process:
139+
try:
140+
if tags:
141+
all_resources.append(
142+
{
143+
"name": f"tags_{location_name}",
144+
"write_disposition": "merge",
145+
"primary_key": ["tag", "detected_at"],
146+
"endpoint": {
147+
"path": f"tags/{location_code}",
148+
"params": {
149+
"begin_dt": begin_date,
150+
"end_dt": end_date,
151+
},
152+
},
153+
}
154+
)
155+
156+
if readers:
157+
all_resources.append(
158+
{
159+
"name": f"readers_voltage_{location_name}",
160+
"write_disposition": "merge",
161+
"primary_key": ["read_at"],
162+
"endpoint": {
163+
"path": f"reader/{location_code}",
164+
"params": {
165+
"begin_dt": begin_date,
166+
"end_dt": end_date,
167+
},
168+
},
169+
}
170+
)
171+
172+
if environment:
173+
all_resources.append(
174+
{
175+
"name": f"environment_data_{location_name}",
176+
"write_disposition": "merge",
177+
"primary_key": ["read_at"],
178+
"endpoint": {
179+
"path": f"enviro/{location_code}",
180+
"params": {
181+
"begin_dt": begin_date,
182+
"end_dt": end_date,
183+
},
184+
},
185+
}
186+
)
187+
188+
except Exception as e:
189+
if skip_errors:
190+
logger.warning(f"Skipping {location_name} due to error: {e}")
191+
continue
192+
else:
193+
raise
194+
195+
source = rest_api_source(
196+
{
197+
"client": {
198+
"base_url": BIOMARK_BASE_URL,
199+
"paginator": "single_page",
200+
"auth": {
201+
"type": "bearer",
202+
"token": token,
203+
},
204+
},
205+
"resources": all_resources,
206+
}
207+
)
208+
209+
credentials = AwsCredentials(
210+
s3_url_style="path",
211+
endpoint_url=BIOMARK_AWS_ENDPOINT,
212+
aws_secret_access_key=BIOMARK_SECRET_KEY,
213+
aws_access_key_id=BIOMARK_ACCESS_KEY,
214+
region_name=BIOMARK_REGION,
215+
)
216+
217+
pipeline = dlt.pipeline(
218+
pipeline_name="biomark_pit_registering_salmon",
219+
destination=filesystem(
220+
bucket_url=f"s3://{BIOMARK_BUCKET}/" + BIOMARK_PREFIX,
221+
credentials=credentials,
222+
layout="{table_name}.{ext}",
223+
),
224+
dataset_name="main",
225+
progress="log",
226+
)
227+
228+
if tags:
229+
230+
@dlt.transformer(primary_key=["tag", "detected_at"])
231+
def add_decimal_tags(items):
232+
"""Transform tags to include decimal format."""
233+
for item in items:
234+
if isinstance(item, dict) and "tag" in item:
235+
item["tag_decimal"] = hex_to_decimal_tag(item["tag"])
236+
yield item
237+
238+
# create list of resources, transforming tag resources
239+
final_resources = []
240+
for name, resource in source.resources.items():
241+
if name.startswith("tags"):
242+
# transform tag resources and keep the original name
243+
transformed = resource | add_decimal_tags.with_name(name)
244+
final_resources.append(transformed)
245+
else:
246+
final_resources.append(resource)
247+
248+
load_info = pipeline.run(final_resources)
249+
else:
250+
load_info = pipeline.run(source)
251+
252+
logger.info(load_info)
253+
254+
255+
def get_bearer_token():
256+
"""Get bearer token from API."""
257+
258+
url = BIOMARK_BASE_URL + "token/"
259+
260+
header = {
261+
"Content-Type": "application/json",
262+
}
263+
payload = {
264+
"email": BIOMARK_API_EMAIL,
265+
"password": BIOMARK_API_PWD,
266+
}
267+
268+
logger.info(payload)
269+
270+
response = requests.post(url, json=payload, headers=header, timeout=10)
271+
response.raise_for_status()
272+
token = response.json().get("access")
273+
return token
274+
275+
276+
if __name__ == "__main__":
277+
main()

src/datasync/settings.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,17 @@
1313
logging.DEBUG if env.bool("DEBUG", default=False) else logging.INFO
1414
)
1515

16+
# PIT-REGISTERING-SALMON
17+
BIOMARK_BASE_URL = env("BIOMARK_BASE_URL", default="https://data3.biomark.com/api/v1/")
18+
BIOMARK_API_EMAIL = env("BIOMARK_API_EMAIL", default="")
19+
BIOMARK_API_PWD = env("BIOMARK_API_PWD", default="")
20+
BIOMARK_AWS_ENDPOINT = env("BIOMARK_AWS_ENDPOINT", default="")
21+
BIOMARK_BUCKET = env("BIOMARK_BUCKET", default="pit-registering-salmon")
22+
BIOMARK_PREFIX = env("BIOMARK_PREFIX", default="data")
23+
BIOMARK_REGION = env("BIOMARK_REGION", default="us-east-1")
24+
BIOMARK_ACCESS_KEY = env("BIOMARK_ACCESS_KEY", default="")
25+
BIOMARK_SECRET_KEY = env("BIOMARK_SECRET_KEY", default="")
26+
1627

1728
# NVA
1829
NVA_BASE_URL = env("NVA_BASE_URL", default="https://api.nva.unit.no/")

uv.lock

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)