repeaterbook 0.1.1__tar.gz → 0.2.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/PKG-INFO +1 -1
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/CHANGELOG.md +8 -0
- repeaterbook-0.2.0/playground/examples.ipynb +107 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/pyproject.toml +1 -1
- repeaterbook-0.2.0/src/repeaterbook/__init__.py +11 -0
- repeaterbook-0.2.0/src/repeaterbook/database.py +74 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/src/repeaterbook/models.py +15 -15
- repeaterbook-0.2.0/src/repeaterbook/queries.py +113 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/src/repeaterbook/services.py +5 -88
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/src/repeaterbook/utils.py +22 -7
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/uv.lock +1 -1
- repeaterbook-0.1.1/playground/examples.ipynb +0 -63
- repeaterbook-0.1.1/src/repeaterbook/__init__.py +0 -1
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.all-contributorsrc +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.cruft.json +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.editorconfig +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.github/ISSUE_TEMPLATE/bug_report.md +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.github/ISSUE_TEMPLATE/config.yml +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.github/ISSUE_TEMPLATE/feature_request.md +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.github/workflows/ci.yml +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.github/workflows/codecov_action.yml +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.github/workflows/semantic-release.yml +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.gitignore +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.pre-commit-config.yaml +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.python-version +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.python-versions +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/.readthedocs.yaml +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/LICENSE +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/asv.conf.json +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/benchmarks/__init__.py +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/benchmarks/benchmarks.py +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/CODE_OF_CONDUCT.md +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/CONTRIBUTING.md +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/Makefile +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/README.md +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/__init__.py +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/_static/.gitignore +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/_templates/.gitignore +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/conf.py +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/index.rst +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/make.bat +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/reference.md +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/docs/wordlist.txt +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/noxfile.py +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/playground/.gitignore +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/src/repeaterbook/py.typed +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/tests/__init__.py +0 -0
- {repeaterbook-0.1.1 → repeaterbook-0.2.0}/tests/test_repeaterbook.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: repeaterbook
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.2.0
|
|
4
4
|
Summary: Python utility to work with data from RepeaterBook.
|
|
5
5
|
Project-URL: homepage, https://github.com/MicaelJarniac/repeaterbook
|
|
6
6
|
Project-URL: source, https://github.com/MicaelJarniac/repeaterbook
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cells": [
|
|
3
|
+
{
|
|
4
|
+
"cell_type": "code",
|
|
5
|
+
"execution_count": null,
|
|
6
|
+
"metadata": {},
|
|
7
|
+
"outputs": [],
|
|
8
|
+
"source": [
|
|
9
|
+
"import pycountry\n",
|
|
10
|
+
"from anyio import Path\n",
|
|
11
|
+
"\n",
|
|
12
|
+
"from repeaterbook.models import ExportQuery\n",
|
|
13
|
+
"from repeaterbook.services import RepeaterBookAPI\n",
|
|
14
|
+
"\n",
|
|
15
|
+
"rb_api = RepeaterBookAPI(\n",
|
|
16
|
+
" app_name=\"RepeaterBook Python SDK\",\n",
|
|
17
|
+
" app_email=\"micael@jarniac.dev\",\n",
|
|
18
|
+
" working_dir=Path(),\n",
|
|
19
|
+
")\n",
|
|
20
|
+
"repeaters = await rb_api.download(\n",
|
|
21
|
+
" query=ExportQuery(countries={pycountry.countries.get(name=\"Brazil\")})\n",
|
|
22
|
+
")"
|
|
23
|
+
]
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"cell_type": "code",
|
|
27
|
+
"execution_count": null,
|
|
28
|
+
"metadata": {},
|
|
29
|
+
"outputs": [],
|
|
30
|
+
"source": [
|
|
31
|
+
"from repeaterbook import RepeaterBook\n",
|
|
32
|
+
"\n",
|
|
33
|
+
"rb = RepeaterBook(\n",
|
|
34
|
+
" working_dir=Path(),\n",
|
|
35
|
+
")\n",
|
|
36
|
+
"rb.populate(repeaters)"
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
"cell_type": "code",
|
|
41
|
+
"execution_count": null,
|
|
42
|
+
"metadata": {},
|
|
43
|
+
"outputs": [],
|
|
44
|
+
"source": [
|
|
45
|
+
"from haversine import Unit\n",
|
|
46
|
+
"from rich import print as pprint\n",
|
|
47
|
+
"\n",
|
|
48
|
+
"from repeaterbook import queries\n",
|
|
49
|
+
"from repeaterbook.models import Repeater\n",
|
|
50
|
+
"from repeaterbook.utils import LatLon, Radius\n",
|
|
51
|
+
"\n",
|
|
52
|
+
"radius = Radius(\n",
|
|
53
|
+
" origin=LatLon(\n",
|
|
54
|
+
" lat=-22.4000,\n",
|
|
55
|
+
" lon=-46.9000,\n",
|
|
56
|
+
" ),\n",
|
|
57
|
+
" distance=50,\n",
|
|
58
|
+
" unit=Unit.KILOMETERS,\n",
|
|
59
|
+
")\n",
|
|
60
|
+
"\n",
|
|
61
|
+
"filtered_repeaters = queries.filter_radius(\n",
|
|
62
|
+
" rb.query(*queries.square(radius), Repeater.dmr_capable), radius\n",
|
|
63
|
+
")\n",
|
|
64
|
+
"pprint(filtered_repeaters)"
|
|
65
|
+
]
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
"cell_type": "code",
|
|
69
|
+
"execution_count": null,
|
|
70
|
+
"metadata": {},
|
|
71
|
+
"outputs": [],
|
|
72
|
+
"source": [
|
|
73
|
+
"from repeaterbook import queries\n",
|
|
74
|
+
"from repeaterbook.models import Status, Use\n",
|
|
75
|
+
"from repeaterbook.queries import Bands\n",
|
|
76
|
+
"\n",
|
|
77
|
+
"rb.query(\n",
|
|
78
|
+
" Repeater.dmr_capable | Repeater.analog_capable,\n",
|
|
79
|
+
" Repeater.operational_status == Status.ON_AIR,\n",
|
|
80
|
+
" Repeater.use_membership == Use.OPEN,\n",
|
|
81
|
+
" queries.band(Bands.M_2, Bands.CM_70),\n",
|
|
82
|
+
")"
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
],
|
|
86
|
+
"metadata": {
|
|
87
|
+
"kernelspec": {
|
|
88
|
+
"display_name": ".venv",
|
|
89
|
+
"language": "python",
|
|
90
|
+
"name": "python3"
|
|
91
|
+
},
|
|
92
|
+
"language_info": {
|
|
93
|
+
"codemirror_mode": {
|
|
94
|
+
"name": "ipython",
|
|
95
|
+
"version": 3
|
|
96
|
+
},
|
|
97
|
+
"file_extension": ".py",
|
|
98
|
+
"mimetype": "text/x-python",
|
|
99
|
+
"name": "python",
|
|
100
|
+
"nbconvert_exporter": "python",
|
|
101
|
+
"pygments_lexer": "ipython3",
|
|
102
|
+
"version": "3.13.2"
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
"nbformat": 4,
|
|
106
|
+
"nbformat_minor": 2
|
|
107
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Python utility to work with data from RepeaterBook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__: tuple[str, ...] = (
|
|
6
|
+
"Repeater",
|
|
7
|
+
"RepeaterBook",
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from repeaterbook.database import RepeaterBook
|
|
11
|
+
from repeaterbook.models import Repeater
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Internal database module for RepeaterBook."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__: tuple[str, ...] = ("RepeaterBook",)
|
|
6
|
+
|
|
7
|
+
from functools import cached_property
|
|
8
|
+
from typing import TYPE_CHECKING
|
|
9
|
+
|
|
10
|
+
import attrs
|
|
11
|
+
from anyio import Path
|
|
12
|
+
from loguru import logger
|
|
13
|
+
from sqlmodel import Session, SQLModel, create_engine, select
|
|
14
|
+
|
|
15
|
+
from repeaterbook.models import (
|
|
16
|
+
Repeater,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
20
|
+
from collections.abc import Iterable, Sequence
|
|
21
|
+
|
|
22
|
+
from sqlalchemy import Engine
|
|
23
|
+
from sqlalchemy.sql._typing import _ColumnExpressionArgument
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@attrs.frozen
|
|
27
|
+
class RepeaterBook:
|
|
28
|
+
"""RepeaterBook API client."""
|
|
29
|
+
|
|
30
|
+
working_dir: Path = attrs.Factory(Path)
|
|
31
|
+
database: str = "repeaterbook.db"
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def database_path(self) -> Path:
|
|
35
|
+
"""Database path."""
|
|
36
|
+
return self.working_dir / self.database
|
|
37
|
+
|
|
38
|
+
@property
|
|
39
|
+
def database_uri(self) -> str:
|
|
40
|
+
"""Database URI."""
|
|
41
|
+
return f"sqlite:///{self.database_path}"
|
|
42
|
+
|
|
43
|
+
@cached_property
|
|
44
|
+
def engine(self) -> Engine:
|
|
45
|
+
"""Create database engine."""
|
|
46
|
+
return create_engine(self.database_uri)
|
|
47
|
+
|
|
48
|
+
def init_db(self) -> None:
|
|
49
|
+
"""Initialize database."""
|
|
50
|
+
SQLModel.metadata.create_all(self.engine)
|
|
51
|
+
|
|
52
|
+
def populate(self, repeaters: Iterable[Repeater]) -> None:
|
|
53
|
+
"""Populate internal database."""
|
|
54
|
+
self.init_db()
|
|
55
|
+
|
|
56
|
+
with Session(self.engine) as session:
|
|
57
|
+
for repeater in repeaters:
|
|
58
|
+
session.merge(repeater)
|
|
59
|
+
session.commit()
|
|
60
|
+
|
|
61
|
+
logger.info("Populated repeaters.")
|
|
62
|
+
|
|
63
|
+
def query(
|
|
64
|
+
self,
|
|
65
|
+
*where: _ColumnExpressionArgument[bool] | bool,
|
|
66
|
+
) -> Sequence[Repeater]:
|
|
67
|
+
"""Query the database."""
|
|
68
|
+
with Session(self.engine) as session:
|
|
69
|
+
statement = select(Repeater).where(*where)
|
|
70
|
+
repeaters = session.exec(statement).all()
|
|
71
|
+
|
|
72
|
+
logger.info(f"Found {len(repeaters)} repeaters.")
|
|
73
|
+
|
|
74
|
+
return repeaters
|
|
@@ -83,22 +83,22 @@ class Repeater(SQLModel, table=True):
|
|
|
83
83
|
|
|
84
84
|
state_id: str = Field(primary_key=True)
|
|
85
85
|
repeater_id: int = Field(primary_key=True)
|
|
86
|
-
frequency: Decimal
|
|
87
|
-
input_frequency: Decimal
|
|
86
|
+
frequency: Decimal = Field(index=True)
|
|
87
|
+
input_frequency: Decimal = Field(index=True)
|
|
88
88
|
pl_ctcss_uplink: str | None
|
|
89
89
|
pl_ctcss_tsq_downlink: str | None
|
|
90
90
|
location_nearest_city: str
|
|
91
91
|
landmark: str | None
|
|
92
92
|
region: str | None
|
|
93
|
-
country: str | None
|
|
93
|
+
country: str | None = Field(index=True)
|
|
94
94
|
county: str | None
|
|
95
95
|
state: str | None
|
|
96
|
-
latitude: Decimal
|
|
97
|
-
longitude: Decimal
|
|
96
|
+
latitude: Decimal = Field(index=True)
|
|
97
|
+
longitude: Decimal = Field(index=True)
|
|
98
98
|
precise: bool
|
|
99
99
|
callsign: str | None
|
|
100
|
-
use_membership: Use
|
|
101
|
-
operational_status: Status
|
|
100
|
+
use_membership: Use = Field(index=True)
|
|
101
|
+
operational_status: Status = Field(index=True)
|
|
102
102
|
ares: str | None
|
|
103
103
|
races: str | None
|
|
104
104
|
skywarn: str | None
|
|
@@ -108,23 +108,23 @@ class Repeater(SQLModel, table=True):
|
|
|
108
108
|
echolink_node: str | None
|
|
109
109
|
irlp_node: str | None
|
|
110
110
|
wires_node: str | None
|
|
111
|
-
dmr_capable: bool
|
|
111
|
+
dmr_capable: bool = Field(index=True)
|
|
112
112
|
dmr_id: str | None
|
|
113
113
|
dmr_color_code: str | None
|
|
114
|
-
d_star_capable: bool
|
|
115
|
-
nxdn_capable: bool
|
|
116
|
-
apco_p_25_capable: bool
|
|
114
|
+
d_star_capable: bool = Field(index=True)
|
|
115
|
+
nxdn_capable: bool = Field(index=True)
|
|
116
|
+
apco_p_25_capable: bool = Field(index=True)
|
|
117
117
|
p_25_nac: str | None
|
|
118
|
-
m17_capable: bool
|
|
118
|
+
m17_capable: bool = Field(index=True)
|
|
119
119
|
m17_can: str | None
|
|
120
|
-
tetra_capable: bool
|
|
120
|
+
tetra_capable: bool = Field(index=True)
|
|
121
121
|
tetra_mcc: str | None
|
|
122
122
|
tetra_mnc: str | None
|
|
123
|
-
yaesu_system_fusion_capable: bool
|
|
123
|
+
yaesu_system_fusion_capable: bool = Field(index=True)
|
|
124
124
|
ysf_digital_id_uplink: str | None
|
|
125
125
|
ysf_digital_id_downlink: str | None
|
|
126
126
|
ysf_dsc: str | None
|
|
127
|
-
analog_capable: bool
|
|
127
|
+
analog_capable: bool = Field(index=True)
|
|
128
128
|
fm_bandwidth: Decimal | None
|
|
129
129
|
notes: str | None
|
|
130
130
|
last_update: date
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Queries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__: tuple[str, ...] = (
|
|
6
|
+
"Band",
|
|
7
|
+
"Bands",
|
|
8
|
+
"band",
|
|
9
|
+
"filter_radius",
|
|
10
|
+
"square",
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
from decimal import Decimal
|
|
14
|
+
from enum import Enum
|
|
15
|
+
from typing import TYPE_CHECKING, NamedTuple
|
|
16
|
+
|
|
17
|
+
from haversine import haversine # type: ignore[import-untyped]
|
|
18
|
+
from loguru import logger
|
|
19
|
+
from sqlmodel import or_
|
|
20
|
+
|
|
21
|
+
from repeaterbook.models import Repeater
|
|
22
|
+
from repeaterbook.utils import Radius, square_bounds
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING: # pragma: no cover
|
|
25
|
+
from collections.abc import Iterable
|
|
26
|
+
|
|
27
|
+
from sqlalchemy.sql._typing import _ColumnExpressionArgument
|
|
28
|
+
from sqlalchemy.sql.elements import ColumnElement
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def square(radius: Radius) -> tuple[_ColumnExpressionArgument[bool] | bool, ...]:
|
|
32
|
+
"""Return a query for repeaters within a given square.
|
|
33
|
+
|
|
34
|
+
Note: This is a square, not a circle. Use `filter_radius` afterwards.
|
|
35
|
+
"""
|
|
36
|
+
bounds = square_bounds(radius=radius)
|
|
37
|
+
return (
|
|
38
|
+
Repeater.latitude >= bounds.south,
|
|
39
|
+
Repeater.latitude <= bounds.north,
|
|
40
|
+
Repeater.longitude >= bounds.west,
|
|
41
|
+
Repeater.longitude <= bounds.east,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def filter_radius(
|
|
46
|
+
repeaters: Iterable[Repeater],
|
|
47
|
+
radius: Radius,
|
|
48
|
+
) -> list[Repeater]:
|
|
49
|
+
"""Filter repeaters within a given radius, and sort by distance.
|
|
50
|
+
|
|
51
|
+
Use after `square` to limit the number of repeaters to check.
|
|
52
|
+
This is a brute-force search, so it should be used with care.
|
|
53
|
+
"""
|
|
54
|
+
|
|
55
|
+
class RepDist(NamedTuple):
|
|
56
|
+
"""Repeater distance."""
|
|
57
|
+
|
|
58
|
+
repeater: Repeater
|
|
59
|
+
distance: float
|
|
60
|
+
|
|
61
|
+
rep_dists: list[RepDist] = []
|
|
62
|
+
for repeater in repeaters:
|
|
63
|
+
# Calculate the distance to the repeater.
|
|
64
|
+
distance = haversine(
|
|
65
|
+
radius.origin,
|
|
66
|
+
(repeater.latitude, repeater.longitude),
|
|
67
|
+
unit=radius.unit,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
if distance <= radius.distance:
|
|
71
|
+
rep_dists.append(RepDist(repeater=repeater, distance=distance))
|
|
72
|
+
|
|
73
|
+
# Sort by distance.
|
|
74
|
+
rep_dists.sort(key=lambda x: x.distance)
|
|
75
|
+
|
|
76
|
+
# Log the number of repeaters found.
|
|
77
|
+
logger.info(
|
|
78
|
+
f"Found {len(rep_dists)} repeaters within {radius.distance} {radius.unit.name}."
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
# Convert to a list of repeaters.
|
|
82
|
+
return [rep_dist.repeater for rep_dist in rep_dists]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class Band(NamedTuple):
|
|
86
|
+
"""Band."""
|
|
87
|
+
|
|
88
|
+
low: Decimal
|
|
89
|
+
high: Decimal
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Bands(Band, Enum):
|
|
93
|
+
"""Bands."""
|
|
94
|
+
|
|
95
|
+
M_10 = Band(low=Decimal("28.0"), high=Decimal("29.7"))
|
|
96
|
+
M_6 = Band(low=Decimal("50.0"), high=Decimal("54.0"))
|
|
97
|
+
M_4 = Band(low=Decimal("70.0"), high=Decimal("72.0"))
|
|
98
|
+
M_2 = Band(low=Decimal("144.0"), high=Decimal("148.0"))
|
|
99
|
+
CM_70 = Band(low=Decimal("420.0"), high=Decimal("450.0"))
|
|
100
|
+
CM_33 = Band(low=Decimal("902.0"), high=Decimal("928.0"))
|
|
101
|
+
CM_23 = Band(low=Decimal("1240.0"), high=Decimal("1300.0"))
|
|
102
|
+
CM_13 = Band(low=Decimal("2300.0"), high=Decimal("2450.0"))
|
|
103
|
+
CM_3 = Band(low=Decimal("10000.0"), high=Decimal("10500.0"))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def band(*bands: Band) -> ColumnElement[bool]:
|
|
107
|
+
"""Return a query for repeaters within a given band."""
|
|
108
|
+
return or_(
|
|
109
|
+
*(
|
|
110
|
+
(Repeater.frequency >= band.low) & (Repeater.frequency <= band.high)
|
|
111
|
+
for band in bands
|
|
112
|
+
)
|
|
113
|
+
)
|
|
@@ -6,7 +6,7 @@ __all__: tuple[str, ...] = (
|
|
|
6
6
|
"BOOL_MAP",
|
|
7
7
|
"STATUS_MAP",
|
|
8
8
|
"USE_MAP",
|
|
9
|
-
"
|
|
9
|
+
"RepeaterBookAPI",
|
|
10
10
|
"fetch_json",
|
|
11
11
|
"json_to_model",
|
|
12
12
|
)
|
|
@@ -16,15 +16,12 @@ import hashlib
|
|
|
16
16
|
import json
|
|
17
17
|
import time
|
|
18
18
|
from datetime import date, timedelta
|
|
19
|
-
from
|
|
20
|
-
from typing import TYPE_CHECKING, Any, ClassVar, Final, NamedTuple, cast
|
|
19
|
+
from typing import Any, ClassVar, Final, cast
|
|
21
20
|
|
|
22
21
|
import aiohttp
|
|
23
22
|
import attrs
|
|
24
23
|
from anyio import Path
|
|
25
|
-
from haversine import Unit, haversine # type: ignore[import-untyped]
|
|
26
24
|
from loguru import logger
|
|
27
|
-
from sqlmodel import Session, SQLModel, create_engine, select
|
|
28
25
|
from tqdm import tqdm
|
|
29
26
|
from yarl import URL
|
|
30
27
|
|
|
@@ -45,10 +42,6 @@ from repeaterbook.models import (
|
|
|
45
42
|
Status,
|
|
46
43
|
Use,
|
|
47
44
|
)
|
|
48
|
-
from repeaterbook.utils import LatLon, square_bounds
|
|
49
|
-
|
|
50
|
-
if TYPE_CHECKING: # pragma: no cover
|
|
51
|
-
from sqlalchemy import Engine
|
|
52
45
|
|
|
53
46
|
|
|
54
47
|
async def fetch_json(
|
|
@@ -178,7 +171,7 @@ def json_to_model(j: RepeaterJSON, /) -> Repeater:
|
|
|
178
171
|
|
|
179
172
|
|
|
180
173
|
@attrs.frozen
|
|
181
|
-
class
|
|
174
|
+
class RepeaterBookAPI:
|
|
182
175
|
"""RepeaterBook API client."""
|
|
183
176
|
|
|
184
177
|
base_url: URL = attrs.Factory(lambda: URL("https://repeaterbook.com"))
|
|
@@ -186,7 +179,6 @@ class RepeaterBook:
|
|
|
186
179
|
app_email: str = "micael@jarniac.dev"
|
|
187
180
|
|
|
188
181
|
working_dir: Path = attrs.Factory(Path)
|
|
189
|
-
database: str = "repeaterbook.db"
|
|
190
182
|
|
|
191
183
|
MAX_COUNT: ClassVar[int] = 3500
|
|
192
184
|
|
|
@@ -202,25 +194,6 @@ class RepeaterBook:
|
|
|
202
194
|
await gitignore.write_text("*\n", encoding="utf-8")
|
|
203
195
|
return cache
|
|
204
196
|
|
|
205
|
-
@property
|
|
206
|
-
def database_path(self) -> Path:
|
|
207
|
-
"""Database path."""
|
|
208
|
-
return self.working_dir / self.database
|
|
209
|
-
|
|
210
|
-
@property
|
|
211
|
-
def database_uri(self) -> str:
|
|
212
|
-
"""Database URI."""
|
|
213
|
-
return f"sqlite:///{self.database_path}"
|
|
214
|
-
|
|
215
|
-
@cached_property
|
|
216
|
-
def engine(self) -> Engine:
|
|
217
|
-
"""Create database engine."""
|
|
218
|
-
return create_engine(self.database_uri)
|
|
219
|
-
|
|
220
|
-
def init_db(self) -> None:
|
|
221
|
-
"""Initialize database."""
|
|
222
|
-
SQLModel.metadata.create_all(self.engine)
|
|
223
|
-
|
|
224
197
|
@property
|
|
225
198
|
def url_api(self) -> URL:
|
|
226
199
|
"""RepeaterBook API base URL."""
|
|
@@ -327,70 +300,14 @@ class RepeaterBook:
|
|
|
327
300
|
return await asyncio.gather(*tasks)
|
|
328
301
|
|
|
329
302
|
async def download(self, query: ExportQuery) -> list[Repeater]:
|
|
330
|
-
"""Download
|
|
303
|
+
"""Download repeaters."""
|
|
331
304
|
data = await self.export_multi_json(self.urls_export(query))
|
|
332
305
|
|
|
333
306
|
results: list[RepeaterJSON] = []
|
|
334
307
|
for export in data:
|
|
335
308
|
results.extend(export["results"])
|
|
336
309
|
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
repeaters: list[Repeater] = []
|
|
340
|
-
with Session(self.engine) as session:
|
|
341
|
-
for result in results:
|
|
342
|
-
repeater = json_to_model(result)
|
|
343
|
-
session.merge(repeater)
|
|
344
|
-
repeaters.append(repeater)
|
|
345
|
-
session.commit()
|
|
310
|
+
repeaters = [json_to_model(result) for result in results]
|
|
346
311
|
|
|
347
312
|
logger.info(f"Downloaded {len(repeaters)} repeaters.")
|
|
348
313
|
return repeaters
|
|
349
|
-
|
|
350
|
-
def find_nearest(
|
|
351
|
-
self,
|
|
352
|
-
latitude: float,
|
|
353
|
-
longitude: float,
|
|
354
|
-
*,
|
|
355
|
-
max_distance: float = 80.0,
|
|
356
|
-
unit: Unit = Unit.KILOMETERS,
|
|
357
|
-
) -> list[Repeater]:
|
|
358
|
-
"""Find repeaters within a given distance."""
|
|
359
|
-
|
|
360
|
-
class RepDist(NamedTuple):
|
|
361
|
-
"""Repeater distance."""
|
|
362
|
-
|
|
363
|
-
repeater: Repeater
|
|
364
|
-
distance: float
|
|
365
|
-
|
|
366
|
-
rep_dists: list[RepDist] = []
|
|
367
|
-
with Session(self.engine) as session:
|
|
368
|
-
# Calculate the square bounds for the given distance.
|
|
369
|
-
bounds = square_bounds(LatLon(latitude, longitude), max_distance, unit=unit)
|
|
370
|
-
statement = select(Repeater).where(
|
|
371
|
-
Repeater.latitude >= bounds.south,
|
|
372
|
-
Repeater.latitude <= bounds.north,
|
|
373
|
-
Repeater.longitude >= bounds.west,
|
|
374
|
-
Repeater.longitude <= bounds.east,
|
|
375
|
-
)
|
|
376
|
-
for repeater in session.exec(statement):
|
|
377
|
-
# Calculate the distance to the repeater.
|
|
378
|
-
distance = haversine(
|
|
379
|
-
(latitude, longitude),
|
|
380
|
-
(repeater.latitude, repeater.longitude),
|
|
381
|
-
unit=unit,
|
|
382
|
-
)
|
|
383
|
-
|
|
384
|
-
if distance <= max_distance:
|
|
385
|
-
rep_dists.append(RepDist(repeater=repeater, distance=distance))
|
|
386
|
-
|
|
387
|
-
# Sort by distance.
|
|
388
|
-
rep_dists.sort(key=lambda x: x.distance)
|
|
389
|
-
|
|
390
|
-
# Log the number of repeaters found.
|
|
391
|
-
logger.info(
|
|
392
|
-
f"Found {len(rep_dists)} repeaters within {max_distance} {unit.name}."
|
|
393
|
-
)
|
|
394
|
-
|
|
395
|
-
# Convert to a list of repeaters.
|
|
396
|
-
return [rep_dist.repeater for rep_dist in rep_dists]
|
|
@@ -4,6 +4,7 @@ from __future__ import annotations
|
|
|
4
4
|
|
|
5
5
|
__all__: tuple[str, ...] = (
|
|
6
6
|
"LatLon",
|
|
7
|
+
"Radius",
|
|
7
8
|
"SquareBounds",
|
|
8
9
|
"square_bounds",
|
|
9
10
|
)
|
|
@@ -20,6 +21,14 @@ class LatLon(NamedTuple):
|
|
|
20
21
|
lon: float
|
|
21
22
|
|
|
22
23
|
|
|
24
|
+
class Radius(NamedTuple):
|
|
25
|
+
"""Radius."""
|
|
26
|
+
|
|
27
|
+
origin: LatLon
|
|
28
|
+
distance: float
|
|
29
|
+
unit: Unit = Unit.KILOMETERS
|
|
30
|
+
|
|
31
|
+
|
|
23
32
|
class SquareBounds(NamedTuple):
|
|
24
33
|
"""Square bounds."""
|
|
25
34
|
|
|
@@ -29,14 +38,20 @@ class SquareBounds(NamedTuple):
|
|
|
29
38
|
west: float
|
|
30
39
|
|
|
31
40
|
|
|
32
|
-
def square_bounds(
|
|
33
|
-
origin: LatLon, distance: float, unit: Unit = Unit.KILOMETERS
|
|
34
|
-
) -> SquareBounds:
|
|
41
|
+
def square_bounds(radius: Radius) -> SquareBounds:
|
|
35
42
|
"""Get square bounds around a point."""
|
|
36
|
-
north = inverse_haversine(
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
north = inverse_haversine(
|
|
44
|
+
radius.origin, radius.distance, Direction.NORTH, unit=radius.unit
|
|
45
|
+
)[0]
|
|
46
|
+
south = inverse_haversine(
|
|
47
|
+
radius.origin, radius.distance, Direction.SOUTH, unit=radius.unit
|
|
48
|
+
)[0]
|
|
49
|
+
east = inverse_haversine(
|
|
50
|
+
radius.origin, radius.distance, Direction.EAST, unit=radius.unit
|
|
51
|
+
)[1]
|
|
52
|
+
west = inverse_haversine(
|
|
53
|
+
radius.origin, radius.distance, Direction.WEST, unit=radius.unit
|
|
54
|
+
)[1]
|
|
40
55
|
|
|
41
56
|
# If we've gone all the way around, things get messy. Just open it up to everything.
|
|
42
57
|
if south > north:
|
|
@@ -1,63 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"cells": [
|
|
3
|
-
{
|
|
4
|
-
"cell_type": "code",
|
|
5
|
-
"execution_count": null,
|
|
6
|
-
"metadata": {},
|
|
7
|
-
"outputs": [],
|
|
8
|
-
"source": [
|
|
9
|
-
"import pycountry\n",
|
|
10
|
-
"from anyio import Path\n",
|
|
11
|
-
"\n",
|
|
12
|
-
"from repeaterbook.models import ExportQuery\n",
|
|
13
|
-
"from repeaterbook.services import RepeaterBook\n",
|
|
14
|
-
"\n",
|
|
15
|
-
"rb = RepeaterBook(\n",
|
|
16
|
-
" app_name=\"RepeaterBook Python SDK\",\n",
|
|
17
|
-
" app_email=\"micael@jarniac.dev\",\n",
|
|
18
|
-
" working_dir=Path(),\n",
|
|
19
|
-
")\n",
|
|
20
|
-
"await rb.download(query=ExportQuery(countries={pycountry.countries.get(name=\"Brazil\")}))"
|
|
21
|
-
]
|
|
22
|
-
},
|
|
23
|
-
{
|
|
24
|
-
"cell_type": "code",
|
|
25
|
-
"execution_count": null,
|
|
26
|
-
"metadata": {},
|
|
27
|
-
"outputs": [],
|
|
28
|
-
"source": [
|
|
29
|
-
"from haversine import Unit\n",
|
|
30
|
-
"from rich import print as pprint\n",
|
|
31
|
-
"\n",
|
|
32
|
-
"repeaters = rb.find_nearest(\n",
|
|
33
|
-
" latitude=-22.4000,\n",
|
|
34
|
-
" longitude=-46.9000,\n",
|
|
35
|
-
" max_distance=50,\n",
|
|
36
|
-
" unit=Unit.KILOMETERS,\n",
|
|
37
|
-
")\n",
|
|
38
|
-
"pprint(repeaters)"
|
|
39
|
-
]
|
|
40
|
-
}
|
|
41
|
-
],
|
|
42
|
-
"metadata": {
|
|
43
|
-
"kernelspec": {
|
|
44
|
-
"display_name": ".venv",
|
|
45
|
-
"language": "python",
|
|
46
|
-
"name": "python3"
|
|
47
|
-
},
|
|
48
|
-
"language_info": {
|
|
49
|
-
"codemirror_mode": {
|
|
50
|
-
"name": "ipython",
|
|
51
|
-
"version": 3
|
|
52
|
-
},
|
|
53
|
-
"file_extension": ".py",
|
|
54
|
-
"mimetype": "text/x-python",
|
|
55
|
-
"name": "python",
|
|
56
|
-
"nbconvert_exporter": "python",
|
|
57
|
-
"pygments_lexer": "ipython3",
|
|
58
|
-
"version": "3.13.2"
|
|
59
|
-
}
|
|
60
|
-
},
|
|
61
|
-
"nbformat": 4,
|
|
62
|
-
"nbformat_minor": 2
|
|
63
|
-
}
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
"""Python utility to work with data from RepeaterBook."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|