Skip to content

Commit 4b3d7df

Browse files
committed
Add analysis and caching
1 parent 088ced9 commit 4b3d7df

11 files changed

Lines changed: 377 additions & 51 deletions

File tree

tilemaker/analysis/core.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""
2+
Analysis caching and producing services.
3+
"""
4+
5+
import uuid
6+
from abc import ABC, abstractmethod
7+
8+
import structlog
9+
from structlog.types import FilteringBoundLogger
10+
11+
from tilemaker.metadata.definitions import MapGroup
12+
from tilemaker.providers.core import Tiles
13+
14+
from .types import SLUG_TO_TYPE
15+
16+
17+
class ProductNotFoundError(Exception):
18+
pass
19+
20+
21+
class AnalysisProvider(ABC):
22+
internal_provider_id: str
23+
logger: FilteringBoundLogger
24+
25+
def __init__(self, internal_provider_id: str | None):
26+
self.internal_provider_id = internal_provider_id or str(uuid.uuid4())
27+
self.logger = structlog.get_logger()
28+
29+
@abstractmethod
30+
def pull(self, analysis_id: str, grants: set[str]):
31+
return
32+
33+
@abstractmethod
34+
def push(self, product: "AnalysisProduct"):
35+
return
36+
37+
38+
class Analyses:
39+
pullable: list[AnalysisProvider]
40+
pushable: list[AnalysisProvider]
41+
tiles: Tiles
42+
metadata: list[MapGroup]
43+
44+
def __init__(
45+
self,
46+
pullable: list[AnalysisProvider],
47+
pushable: list[AnalysisProvider],
48+
tiles: Tiles,
49+
metadata: list[MapGroup],
50+
):
51+
self.pullable = pullable
52+
self.pushable = pushable
53+
self.tiles = tiles
54+
self.metadata = metadata
55+
56+
def pull(self, analysis_id: str, grants: set[str]) -> "AnalysisProduct":
57+
for provider in self.pullable:
58+
try:
59+
product = provider.pull(analysis_id=analysis_id, grants=grants)
60+
61+
if product.grant and product.grant not in grants:
62+
raise ProductNotFoundError(f"Product {analysis_id} not found")
63+
except ProductNotFoundError:
64+
continue
65+
66+
# We couldn't find it. Try building it?
67+
for slug, analysis_type in SLUG_TO_TYPE.items():
68+
if slug in analysis_id:
69+
product = analysis_type.build(
70+
tiles=self.tiles, metadata=self.metadata, analysis_id=analysis_id
71+
)
72+
73+
self.push(product)
74+
75+
if product.grant and product.grant not in grants:
76+
raise ProductNotFoundError(f"Product {analysis_id} not found")
77+
78+
return product
79+
80+
def push(self, product: "AnalysisProduct"):
81+
for provider in self.pushable:
82+
provider.push(product)
83+
84+
return

tilemaker/analysis/histogram.py

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""
2+
Histogram generation and storage.
3+
"""
4+
5+
from time import perf_counter
6+
7+
import numpy as np
8+
import structlog
9+
10+
from tilemaker.metadata.definitions import MapGroup
11+
from tilemaker.providers.core import PullableTile, TileNotFoundError, Tiles
12+
13+
from .products import AnalysisProduct
14+
15+
16+
class HistogramProduct(AnalysisProduct):
17+
layer_id: str
18+
19+
counts: list[int]
20+
edges: list[float]
21+
22+
@property
23+
def hash(self):
24+
return f"hist-{self.layer_id}"
25+
26+
@classmethod
27+
def build(
28+
cls, tiles: Tiles, metadata: list[MapGroup], analysis_id: str
29+
) -> "HistogramProduct":
30+
log = structlog.get_logger()
31+
32+
layer_id = analysis_id.replace("hist-", "")
33+
34+
log = log.bind(layer_id=layer_id)
35+
36+
for map_group in metadata:
37+
layer = map_group.get_layer(layer_id)
38+
if layer is not None:
39+
break
40+
41+
if layer is None:
42+
log.info("histogram.layer_not_found")
43+
raise TileNotFoundError(f"Layer {layer_id} not found")
44+
45+
timing_start = perf_counter()
46+
47+
start = layer.vmin * 4
48+
end = layer.vmax * 4
49+
bins = 128
50+
51+
log = log.bind(start=start, end=end, bins=bins)
52+
53+
edges = np.linspace(start, end, bins + 1)
54+
counts = np.zeros(bins)
55+
56+
for tile_x in [0, 1]:
57+
tile, pushable = tiles.pull(
58+
PullableTile(
59+
layer_id=layer_id,
60+
x=tile_x,
61+
y=0,
62+
level=0,
63+
# Bypass auth for this generation process
64+
grants=set(layer.grant) if layer.grant is not None else None,
65+
)
66+
)
67+
68+
tiles.push(pushable)
69+
70+
if tile.data is not None:
71+
counts += np.histogram(tile.data, bins=edges)[0]
72+
73+
timing_end = perf_counter()
74+
log = log.bind(dt=timing_end - timing_start)
75+
log.info("histogram.built")
76+
77+
return cls(
78+
layer_id=layer_id,
79+
counts=counts,
80+
edges=edges,
81+
grant=layer.grant,
82+
)

tilemaker/analysis/products.py

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
from abc import ABC, abstractmethod
2+
3+
from pydantic import BaseModel
4+
5+
6+
class AnalysisProduct(BaseModel, ABC):
7+
layer_id: str
8+
grant: str | None
9+
source: str | None = None
10+
11+
@property
12+
@abstractmethod
13+
def hash(self):
14+
return
15+
16+
@classmethod
17+
@abstractmethod
18+
def build(cls, tiles: "Tiles", metadata: list["MapGroup"], layer_id: str):
19+
return

tilemaker/analysis/providers.py

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
from cachetools import LFUCache
2+
from pymemcache.client.base import Client
3+
4+
from .core import AnalysisProvider, ProductNotFoundError
5+
from .products import AnalysisProduct
6+
from .types import AnalysisType
7+
8+
9+
class InMemoryAnalysisCache(AnalysisProvider):
10+
"""
11+
A simple in-memory cache for tiles.
12+
"""
13+
14+
cache: LFUCache
15+
16+
def __init__(self, cache_size: int = 8192, internal_provider_id: str | None = None):
17+
self.cache = LFUCache(maxsize=cache_size)
18+
super().__init__(internal_provider_id=internal_provider_id)
19+
20+
def pull(self, analysis_id: str, grants: set[str]):
21+
log = self.logger.bind(analysis_id=analysis_id)
22+
23+
cached = self.cache.get(analysis_id, None)
24+
25+
if cached is None:
26+
log.debug("inmemory.miss")
27+
raise ProductNotFoundError(f"Product {analysis_id} not found in cache")
28+
29+
if cached.grant and cached.grant not in grants:
30+
log = log.bind(product_grant=cached.grant, user_grants=grants)
31+
log.debug("inmemory.proprietary_hidden")
32+
raise ProductNotFoundError(f"Product {analysis_id} not found in cache")
33+
34+
log.debug("inmemory.pulled")
35+
return cached
36+
37+
def push(self, product: AnalysisType):
38+
log = self.logger.bind(analysis_id=product.hash)
39+
40+
if product.source == self.internal_provider_id:
41+
log.debug("inmemory.present")
42+
43+
product.source = self.internal_provider_id
44+
self.cache[product.hash] = product
45+
log.debug("inmemory.pushed")
46+
47+
48+
class MemcachedAnalysisCache(AnalysisProvider):
49+
"""
50+
A cache that uses Memcached for storing tiles.
51+
"""
52+
53+
client: Client
54+
55+
def __init__(self, client: Client, internal_provider_id: str | None = None):
56+
self.client = client
57+
super().__init__(
58+
internal_provider_id=internal_provider_id or "memcached-analysis"
59+
)
60+
61+
def pull(self, analysis_id: str, grants: set[str]):
62+
log = self.logger.bind(analysis_id=analysis_id)
63+
64+
res = self.client.get(analysis_id, None)
65+
66+
if res is None:
67+
log.debug("memcached.miss")
68+
raise ProductNotFoundError(f"Product {analysis_id} not found in cache")
69+
70+
res = AnalysisType.model_validate_json(res)
71+
72+
if res.grant and res.grant not in grants:
73+
log = log.bind(product_grant=res.grant, user_grants=grants)
74+
log.debug("memcached.proprietary_hidden")
75+
raise ProductNotFoundError(f"Product {analysis_id} not found in cache")
76+
77+
log.debug("memcached.pulled")
78+
79+
return res
80+
81+
def push(self, product: AnalysisProduct):
82+
log = self.logger.bind(analysis_id=product.hash)
83+
84+
if product.source == self.internal_provider_id:
85+
log.debug("memcached.present")
86+
87+
product.source = self.internal_provider_id
88+
self.client.set(product.hash, product.model_dump_json(), noreply=True)
89+
log.debug("memcached.pushed")

tilemaker/analysis/types.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
from .histogram import HistogramProduct
2+
3+
SLUG_TO_TYPE = {"hist": HistogramProduct}
4+
5+
AnalysisType = HistogramProduct

tilemaker/metadata/definitions.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,18 +71,25 @@
7171
from pathlib import Path
7272
from typing import Literal
7373

74+
import structlog
7475
from astropy import units
7576
from astropy.io import fits
7677
from astropy.wcs import WCS
7778
from pydantic import BaseModel, RootModel
7879

7980

8081
def parse_config(config: Path) -> list["MapGroup"]:
82+
log = structlog.get_logger()
83+
log = log.bind(config_path=str(config))
84+
8185
MapGroupList = RootModel[list[MapGroup]]
8286

8387
with open(config, "r") as handle:
8488
mgl = MapGroupList.model_validate_json(handle.read()).root
8589

90+
log = log.bind(number_of_groups=len(mgl))
91+
log.info("config.parsed")
92+
8693
return mgl
8794

8895

@@ -235,3 +242,12 @@ class MapGroup(AuthenticatedModel):
235242
description: str
236243

237244
maps: list[Map]
245+
246+
def get_layer(self, layer_id: str) -> Layer | None:
247+
for map in self.maps:
248+
for band in map.bands:
249+
for layer in band.layers:
250+
if layer.layer_id == layer_id:
251+
return layer
252+
253+
return None

tilemaker/metadata/generation.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,8 +41,6 @@ def map_group_from_fits(
4141
)
4242
)
4343

44-
print(maps)
45-
4644
return MapGroup(
4745
name="Auto-Populated", description="No description provided", maps=maps
4846
)
@@ -54,7 +52,7 @@ def layers_from_fits(
5452
unit_override: str | None = None,
5553
) -> list[Layer]:
5654
log = structlog.get_logger()
57-
log = log.bind(filename=filename)
55+
log = log.bind(filename=str(filename))
5856
data = fits.open(filename)
5957

6058
if force:

tilemaker/providers/core.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ class PullableTile(BaseModel):
2020
x: int
2121
y: int
2222
level: int
23-
grants: set[str]
23+
grants: set[str] | None
2424

2525
@property
2626
def hash(self) -> str:

0 commit comments

Comments
 (0)