Refactor radar system
This commit is contained in:
@@ -4,18 +4,69 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeVar
|
||||
import shutil
|
||||
import tempfile
|
||||
from typing import Any, Iterator
|
||||
|
||||
import numpy as np
|
||||
|
||||
from python_app.models.dataset_model import ResultCollection, SweepCollection
|
||||
from python_app.models.dataset_model import ResultCollection, ResultPayload, SweepCollection
|
||||
from python_app.storage.npz.paths import collection_dir_name, sanitize_path_component
|
||||
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection
|
||||
from python_app.storage.npz.serialize import serialize_result_collection, serialize_trace_collection, validate_trace_collection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
TCollection = TypeVar("TCollection")
|
||||
|
||||
@contextmanager
|
||||
def atomic_snapshot_directory(destination: Path) -> Iterator[Path]:
|
||||
"""Publish a complete export directory; discard staging files after a failed write."""
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.exists():
|
||||
raise FileExistsError(f"Snapshot directory already exists: {destination}")
|
||||
staging = Path(tempfile.mkdtemp(prefix=f".{destination.name}.", dir=destination.parent))
|
||||
try:
|
||||
yield staging
|
||||
if destination.exists():
|
||||
raise FileExistsError(f"Snapshot directory already exists: {destination}")
|
||||
staging.rename(destination)
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
|
||||
def _matching_collection(
|
||||
history: list[SweepCollection], anchor: SweepCollection | ResultCollection,
|
||||
used: set[int], *, same_timestamp: bool,
|
||||
) -> SweepCollection | None:
|
||||
"""Match preprocessing identities exactly and raw sweeps by their capture window.
|
||||
|
||||
Producers stamp raw/preprocessed collections at different instants. Their capture
|
||||
window is copied unchanged; results keep the preprocessed timestamp. Old files
|
||||
without timing fall back to the latest preceding collection with the same id.
|
||||
"""
|
||||
candidates = [
|
||||
(index, item) for index, item in enumerate(history)
|
||||
if index not in used and int(item.collection_id) == int(anchor.collection_id)
|
||||
]
|
||||
timestamp = int(anchor.monotonic_ns)
|
||||
start_ns = int(getattr(anchor, "capture_start_ns", 0))
|
||||
end_ns = int(getattr(anchor, "capture_end_ns", 0))
|
||||
if same_timestamp and timestamp:
|
||||
candidates = [(index, item) for index, item in candidates if int(item.monotonic_ns) == timestamp]
|
||||
elif start_ns or end_ns:
|
||||
candidates = [
|
||||
(index, item) for index, item in candidates
|
||||
if (int(item.capture_start_ns), int(item.capture_end_ns)) == (start_ns, end_ns)
|
||||
]
|
||||
elif timestamp:
|
||||
candidates = [(index, item) for index, item in candidates if int(item.monotonic_ns) <= timestamp]
|
||||
if not candidates:
|
||||
return None
|
||||
index, item = max(candidates, key=lambda pair: (int(pair[1].monotonic_ns), pair[0]))
|
||||
used.add(index)
|
||||
return item
|
||||
|
||||
|
||||
def select_aligned_histories(
|
||||
@@ -23,89 +74,60 @@ def select_aligned_histories(
|
||||
preprocessed_history: list[SweepCollection],
|
||||
result_history: list[ResultCollection],
|
||||
last_n: int,
|
||||
*,
|
||||
include_unaligned: bool = False,
|
||||
) -> tuple[list[SweepCollection], list[SweepCollection], list[ResultCollection], dict[str, Any]]:
|
||||
"""Select aligned tails with `results` as preferred anchor stage."""
|
||||
raw_index, raw_pos = _index_by_collection_sequence(raw_history)
|
||||
pre_index, pre_pos = _index_by_collection_sequence(preprocessed_history)
|
||||
result_index, result_pos = _index_by_collection_sequence(result_history)
|
||||
|
||||
if result_index:
|
||||
ordered_result_keys = sorted(result_index, key=lambda key: result_pos[key])
|
||||
selected_keys = ordered_result_keys[-last_n:]
|
||||
selected_raw = [raw_index[key] for key in selected_keys if key in raw_index]
|
||||
selected_pre = [pre_index[key] for key in selected_keys if key in pre_index]
|
||||
selected_results = [result_index[key] for key in selected_keys]
|
||||
raw_missing = len(selected_keys) - len(selected_raw)
|
||||
pre_missing = len(selected_keys) - len(selected_pre)
|
||||
"""Select aligned tails, or retain every stage independently for complete exports."""
|
||||
if last_n <= 0:
|
||||
raise ValueError("last_n must be > 0")
|
||||
if include_unaligned:
|
||||
return (
|
||||
selected_raw,
|
||||
selected_pre,
|
||||
selected_results,
|
||||
{
|
||||
"selection_mode": "result_tail_with_optional_alignment",
|
||||
"anchor_stage": "results",
|
||||
"selected_collection_ids": [int(key[0]) for key in selected_keys],
|
||||
"aligned_key_count": len(selected_keys),
|
||||
"raw_missing_count": raw_missing,
|
||||
"preprocessed_missing_count": pre_missing,
|
||||
},
|
||||
raw_history[-last_n:], preprocessed_history[-last_n:], result_history[-last_n:],
|
||||
{"selection_mode": "independent_tail", "anchor_stage": "none",
|
||||
"selected_collection_ids": [int(item.collection_id) for item in result_history[-last_n:]],
|
||||
"aligned_key_count": 0},
|
||||
)
|
||||
|
||||
if raw_index:
|
||||
ordered_raw_keys = sorted(raw_index, key=lambda key: raw_pos[key])
|
||||
selected_keys = ordered_raw_keys[-last_n:]
|
||||
selected_raw = [raw_index[key] for key in selected_keys]
|
||||
selected_pre = [pre_index[key] for key in selected_keys if key in pre_index]
|
||||
selected_results = [result_index[key] for key in selected_keys if key in result_index]
|
||||
pre_missing = len(selected_keys) - len(selected_pre)
|
||||
result_missing = len(selected_keys) - len(selected_results)
|
||||
return (
|
||||
selected_raw,
|
||||
selected_pre,
|
||||
selected_results,
|
||||
{
|
||||
"selection_mode": "raw_tail_with_optional_alignment",
|
||||
"anchor_stage": "raw",
|
||||
"selected_collection_ids": [int(key[0]) for key in selected_keys],
|
||||
"aligned_key_count": len(selected_keys),
|
||||
"preprocessed_missing_count": pre_missing,
|
||||
"result_missing_count": result_missing,
|
||||
},
|
||||
)
|
||||
selected_raw: list[SweepCollection] = []
|
||||
selected_pre: list[SweepCollection] = []
|
||||
selected_results = result_history[-last_n:]
|
||||
used_raw: set[int] = set()
|
||||
used_pre: set[int] = set()
|
||||
if selected_results:
|
||||
anchors = selected_results
|
||||
anchor_stage = "results"
|
||||
for result in reversed(selected_results):
|
||||
pre = _matching_collection(preprocessed_history, result, used_pre, same_timestamp=True)
|
||||
raw = _matching_collection(raw_history, pre or result, used_raw, same_timestamp=False)
|
||||
if pre is not None:
|
||||
selected_pre.append(pre)
|
||||
if raw is not None:
|
||||
selected_raw.append(raw)
|
||||
selected_pre.reverse()
|
||||
selected_raw.reverse()
|
||||
elif preprocessed_history:
|
||||
selected_pre = preprocessed_history[-last_n:]
|
||||
anchors = selected_pre
|
||||
anchor_stage = "preprocessed"
|
||||
for pre in reversed(selected_pre):
|
||||
raw = _matching_collection(raw_history, pre, used_raw, same_timestamp=False)
|
||||
if raw is not None:
|
||||
selected_raw.append(raw)
|
||||
selected_raw.reverse()
|
||||
else:
|
||||
selected_raw = raw_history[-last_n:]
|
||||
anchors = selected_raw
|
||||
anchor_stage = "raw" if anchors else "none"
|
||||
|
||||
if pre_index:
|
||||
ordered_pre_keys = sorted(pre_index, key=lambda key: pre_pos[key])
|
||||
selected_keys = ordered_pre_keys[-last_n:]
|
||||
selected_raw = [raw_index[key] for key in selected_keys if key in raw_index]
|
||||
selected_pre = [pre_index[key] for key in selected_keys]
|
||||
selected_results = [result_index[key] for key in selected_keys if key in result_index]
|
||||
raw_missing = len(selected_keys) - len(selected_raw)
|
||||
result_missing = len(selected_keys) - len(selected_results)
|
||||
return (
|
||||
selected_raw,
|
||||
selected_pre,
|
||||
selected_results,
|
||||
{
|
||||
"selection_mode": "preprocessed_tail_with_optional_alignment",
|
||||
"anchor_stage": "preprocessed",
|
||||
"selected_collection_ids": [int(key[0]) for key in selected_keys],
|
||||
"aligned_key_count": len(selected_keys),
|
||||
"raw_missing_count": raw_missing,
|
||||
"result_missing_count": result_missing,
|
||||
},
|
||||
)
|
||||
|
||||
return (
|
||||
raw_history[-last_n:],
|
||||
preprocessed_history[-last_n:],
|
||||
result_history[-last_n:],
|
||||
{
|
||||
"selection_mode": "independent_tail",
|
||||
"anchor_stage": "none",
|
||||
"selected_collection_ids": [],
|
||||
"aligned_key_count": 0,
|
||||
},
|
||||
)
|
||||
return selected_raw, selected_pre, selected_results, {
|
||||
"selection_mode": f"{anchor_stage}_tail_with_optional_alignment",
|
||||
"anchor_stage": anchor_stage,
|
||||
"selected_collection_ids": [int(item.collection_id) for item in anchors],
|
||||
"aligned_key_count": len(anchors),
|
||||
"raw_missing_count": len(anchors) - len(selected_raw),
|
||||
"preprocessed_missing_count": len(anchors) - len(selected_pre),
|
||||
"result_missing_count": len(anchors) - len(selected_results),
|
||||
}
|
||||
|
||||
|
||||
def save_trace_history_binary(stage_dir: Path, history: list[SweepCollection], magic: int) -> None:
|
||||
@@ -155,6 +177,7 @@ def save_result_history_binary(stage_dir: Path, history: list[ResultCollection])
|
||||
{
|
||||
"collection_id": collection.collection_id,
|
||||
"monotonic_ns": collection.monotonic_ns,
|
||||
"processing_duration_ns": int(collection.processing_duration_ns),
|
||||
"collection_payload_count": len(collection.collection_payloads),
|
||||
"block_count": len(collection.blocks),
|
||||
},
|
||||
@@ -173,6 +196,8 @@ def save_trace_history_numpy(
|
||||
streaming recorder can append successive chunks into the same stage directory
|
||||
without colliding or restarting the index.
|
||||
"""
|
||||
for collection in history:
|
||||
validate_trace_collection(collection)
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug("Writing %d NumPy trace collection(s) to %s", len(history), stage_dir)
|
||||
for index, collection in enumerate(history, start=index_offset):
|
||||
@@ -219,197 +244,84 @@ def save_trace_history_numpy(
|
||||
)
|
||||
|
||||
|
||||
def _save_result_payload(directory: Path, base_name: str, payload: ResultPayload) -> dict[str, Any]:
|
||||
"""Persist every supported payload kind identically at collection and combo scope."""
|
||||
meta: dict[str, Any] = {"kind": int(payload.kind), "name": payload.processing_name}
|
||||
|
||||
def save_array(field: str, array: np.ndarray) -> None:
|
||||
filename = f"{base_name}_{field}.npy"
|
||||
np.save(directory / filename, array, allow_pickle=False)
|
||||
meta[f"{field}_file"] = filename
|
||||
|
||||
if payload.kind == 1:
|
||||
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
trace = np.asarray(payload.trace, dtype=np.complex64)
|
||||
if freq.ndim != 1 or trace.ndim != 1 or freq.shape != trace.shape:
|
||||
raise ValueError("Result trace frequency and values must be equally sized 1D arrays")
|
||||
save_array("freq", freq)
|
||||
save_array("trace", trace)
|
||||
meta["points"] = int(freq.size)
|
||||
elif payload.kind == 2:
|
||||
save_array("scalar", np.asarray([payload.scalar_value], dtype=np.float32))
|
||||
meta["scalar_value"] = float(payload.scalar_value)
|
||||
elif payload.kind == 3:
|
||||
x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
|
||||
y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
|
||||
image = np.asarray(payload.image, dtype=np.float32)
|
||||
if x_axis.ndim != 1 or y_axis.ndim != 1 or image.shape != (y_axis.size, x_axis.size):
|
||||
raise ValueError("Result image axis sizes must match its 2D matrix shape")
|
||||
save_array("x_axis", x_axis)
|
||||
save_array("y_axis", y_axis)
|
||||
save_array("image", image)
|
||||
meta.update(x_points=int(x_axis.size), y_points=int(y_axis.size))
|
||||
elif payload.kind == 4:
|
||||
table = np.asarray(payload.table, dtype=np.float32)
|
||||
if table.ndim != 2:
|
||||
raise ValueError("Result table payload must be a 2D matrix")
|
||||
save_array("table", table)
|
||||
meta.update(rows=int(table.shape[0]), columns=int(table.shape[1]))
|
||||
else:
|
||||
raise ValueError(f"Unsupported payload kind: {payload.kind}")
|
||||
return meta
|
||||
|
||||
|
||||
def save_result_history_numpy(
|
||||
stage_dir: Path, history: list[ResultCollection], *, index_offset: int = 0
|
||||
) -> None:
|
||||
"""Write processed result collections as a NumPy directory tree.
|
||||
|
||||
``index_offset`` continues the per-collection directory numbering across calls (see
|
||||
:func:`save_trace_history_numpy`) so streamed chunks append cleanly.
|
||||
"""
|
||||
"""Write result payloads, preserving per-stage indices across streaming chunks."""
|
||||
stage_dir.mkdir(parents=True, exist_ok=True)
|
||||
logger.debug("Writing %d NumPy result collection(s) to %s", len(history), stage_dir)
|
||||
for index, collection in enumerate(history, start=index_offset):
|
||||
collection_dir = stage_dir / collection_dir_name(index, collection.collection_id, collection.monotonic_ns)
|
||||
collection_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
collection_payload_meta: list[dict[str, int | str | float]] = []
|
||||
collection_payload_meta = []
|
||||
for payload_index, payload in enumerate(collection.collection_payloads):
|
||||
safe_name = sanitize_path_component(payload.processing_name or "processor")
|
||||
base_name = f"collection_{payload_index:03d}_{safe_name}_kind{payload.kind}"
|
||||
if payload.kind == 1:
|
||||
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
trace = np.asarray(payload.trace, dtype=np.complex64)
|
||||
np.save(collection_dir / f"{base_name}_freq.npy", freq)
|
||||
np.save(collection_dir / f"{base_name}_trace.npy", trace)
|
||||
collection_payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"points": int(freq.size),
|
||||
"freq_file": f"{base_name}_freq.npy",
|
||||
"trace_file": f"{base_name}_trace.npy",
|
||||
}
|
||||
)
|
||||
elif payload.kind == 2:
|
||||
scalar = np.asarray([float(payload.scalar_value)], dtype=np.float32)
|
||||
np.save(collection_dir / f"{base_name}_scalar.npy", scalar)
|
||||
collection_payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"scalar_file": f"{base_name}_scalar.npy",
|
||||
"scalar_value": float(payload.scalar_value),
|
||||
}
|
||||
)
|
||||
elif payload.kind == 3:
|
||||
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
|
||||
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
|
||||
image = np.asarray(payload.image, dtype=np.float32)
|
||||
np.save(collection_dir / f"{base_name}_x_axis.npy", image_x_axis)
|
||||
np.save(collection_dir / f"{base_name}_y_axis.npy", image_y_axis)
|
||||
np.save(collection_dir / f"{base_name}_image.npy", image)
|
||||
collection_payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"x_points": int(image_x_axis.size),
|
||||
"y_points": int(image_y_axis.size),
|
||||
"x_axis_file": f"{base_name}_x_axis.npy",
|
||||
"y_axis_file": f"{base_name}_y_axis.npy",
|
||||
"image_file": f"{base_name}_image.npy",
|
||||
}
|
||||
)
|
||||
elif payload.kind == 4:
|
||||
table = np.asarray(payload.table, dtype=np.float32)
|
||||
np.save(collection_dir / f"{base_name}_table.npy", table)
|
||||
collection_payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
|
||||
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
|
||||
"table_file": f"{base_name}_table.npy",
|
||||
}
|
||||
)
|
||||
name = sanitize_path_component(payload.processing_name or "processor")
|
||||
base_name = f"collection_{payload_index:03d}_{name}_kind{payload.kind}"
|
||||
collection_payload_meta.append(_save_result_payload(collection_dir, base_name, payload))
|
||||
|
||||
blocks_meta: list[dict[str, int | str | list[dict[str, int | str | float]]]] = []
|
||||
blocks_meta = []
|
||||
for block_index, block in enumerate(collection.blocks):
|
||||
block_dir = collection_dir / f"block_{block_index:03d}_i{block.combo.input}_o{block.combo.output}"
|
||||
block_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
payload_meta: list[dict[str, int | str | float]] = []
|
||||
payload_meta = []
|
||||
for payload_index, payload in enumerate(block.payloads):
|
||||
safe_name = sanitize_path_component(payload.processing_name or "processor")
|
||||
base_name = f"{payload_index:03d}_{safe_name}_kind{payload.kind}"
|
||||
if payload.kind == 1:
|
||||
freq = np.asarray(payload.frequency_hz, dtype=np.float32)
|
||||
trace = np.asarray(payload.trace, dtype=np.complex64)
|
||||
np.save(block_dir / f"{base_name}_freq.npy", freq)
|
||||
np.save(block_dir / f"{base_name}_trace.npy", trace)
|
||||
payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"points": int(freq.size),
|
||||
"freq_file": f"{base_name}_freq.npy",
|
||||
"trace_file": f"{base_name}_trace.npy",
|
||||
}
|
||||
)
|
||||
elif payload.kind == 2:
|
||||
scalar = np.asarray([float(payload.scalar_value)], dtype=np.float32)
|
||||
np.save(block_dir / f"{base_name}_scalar.npy", scalar)
|
||||
payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"scalar_file": f"{base_name}_scalar.npy",
|
||||
"scalar_value": float(payload.scalar_value),
|
||||
}
|
||||
)
|
||||
elif payload.kind == 3:
|
||||
image_x_axis = np.asarray(payload.image_x_axis, dtype=np.float32)
|
||||
image_y_axis = np.asarray(payload.image_y_axis, dtype=np.float32)
|
||||
image = np.asarray(payload.image, dtype=np.float32)
|
||||
np.save(block_dir / f"{base_name}_x_axis.npy", image_x_axis)
|
||||
np.save(block_dir / f"{base_name}_y_axis.npy", image_y_axis)
|
||||
np.save(block_dir / f"{base_name}_image.npy", image)
|
||||
payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"x_points": int(image_x_axis.size),
|
||||
"y_points": int(image_y_axis.size),
|
||||
"x_axis_file": f"{base_name}_x_axis.npy",
|
||||
"y_axis_file": f"{base_name}_y_axis.npy",
|
||||
"image_file": f"{base_name}_image.npy",
|
||||
}
|
||||
)
|
||||
elif payload.kind == 4:
|
||||
table = np.asarray(payload.table, dtype=np.float32)
|
||||
np.save(block_dir / f"{base_name}_table.npy", table)
|
||||
payload_meta.append(
|
||||
{
|
||||
"kind": int(payload.kind),
|
||||
"name": payload.processing_name,
|
||||
"rows": int(table.shape[0]) if table.ndim == 2 else 0,
|
||||
"columns": int(table.shape[1]) if table.ndim == 2 else 0,
|
||||
"table_file": f"{base_name}_table.npy",
|
||||
}
|
||||
)
|
||||
|
||||
blocks_meta.append(
|
||||
{
|
||||
"input": int(block.combo.input),
|
||||
"output": int(block.combo.output),
|
||||
"payload_count": len(block.payloads),
|
||||
"dir": block_dir.name,
|
||||
"payloads": payload_meta,
|
||||
}
|
||||
)
|
||||
name = sanitize_path_component(payload.processing_name or "processor")
|
||||
base_name = f"{payload_index:03d}_{name}_kind{payload.kind}"
|
||||
payload_meta.append(_save_result_payload(block_dir, base_name, payload))
|
||||
blocks_meta.append({
|
||||
"input": int(block.combo.input), "output": int(block.combo.output),
|
||||
"payload_count": len(block.payloads), "dir": block_dir.name, "payloads": payload_meta,
|
||||
})
|
||||
|
||||
(collection_dir / "meta.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"collection_id": int(collection.collection_id),
|
||||
"monotonic_ns": int(collection.monotonic_ns),
|
||||
"collection_payload_count": len(collection.collection_payloads),
|
||||
"collection_payloads": collection_payload_meta,
|
||||
"block_count": len(collection.blocks),
|
||||
"blocks": blocks_meta,
|
||||
},
|
||||
indent=2,
|
||||
),
|
||||
json.dumps({
|
||||
"collection_id": int(collection.collection_id),
|
||||
"monotonic_ns": int(collection.monotonic_ns),
|
||||
"processing_duration_ns": int(collection.processing_duration_ns),
|
||||
"collection_payload_count": len(collection.collection_payloads),
|
||||
"collection_payloads": collection_payload_meta,
|
||||
"block_count": len(collection.blocks), "blocks": blocks_meta,
|
||||
}, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def validate_snapshot_name(snapshot_name: str) -> str:
|
||||
"""Normalize snapshot directory stem."""
|
||||
return sanitize_path_component(snapshot_name)
|
||||
|
||||
|
||||
def _index_by_collection_sequence(
|
||||
history: list[TCollection],
|
||||
) -> tuple[dict[tuple[int, int], TCollection], dict[tuple[int, int], int]]:
|
||||
"""Index collections by latest-first `(collection_id, occurrence)` preserving history position.
|
||||
|
||||
Occurrence is counted from the tail (newest item is occurrence `0` for its
|
||||
collection id). This avoids cross-run misalignment when collection ids are
|
||||
reused after restarts and one stage keeps longer history than another.
|
||||
"""
|
||||
indexed: dict[tuple[int, int], TCollection] = {}
|
||||
positions: dict[tuple[int, int], int] = {}
|
||||
seen_count_from_tail_by_id: dict[int, int] = {}
|
||||
|
||||
# Walk from newest to oldest so occurrence=0 always means the most recent
|
||||
# instance for the given collection id.
|
||||
for index in range(len(history) - 1, -1, -1):
|
||||
item = history[index]
|
||||
collection_id = int(getattr(item, "collection_id"))
|
||||
occurrence = seen_count_from_tail_by_id.get(collection_id, 0)
|
||||
key = (collection_id, occurrence)
|
||||
indexed[key] = item
|
||||
positions[key] = index
|
||||
seen_count_from_tail_by_id[collection_id] = occurrence + 1
|
||||
return indexed, positions
|
||||
|
||||
Reference in New Issue
Block a user