裸视频可分析:生成工作区、设置提示、逐帧力与按来源选画笔
pass 只认 frames.csv 与 session.json,裸视频此前因此只能播放;现在为它在应用自己的目录里生成 这两份文件并复用既有链路,链路重算的力按帧存入结果并标注为「重算」,box 的空白箭头画布改按帧 来源走共享画笔。 apps/studio/tests 497 项、shared/tests 471 项(2 个已知墙钟失败)、autocal 358 项、ui_check 634 项 均 0 失败,lint_gate 与 doc_lint 无发现;真实 GL 快照见 .scratch/bare-video/snapshots。
This commit is contained in:
@@ -39,6 +39,7 @@ from orisys_app_shared.analysis.model import (
|
||||
KEYFRAME_HEADER,
|
||||
KEYFRAME_MAGIC,
|
||||
KEYFRAME_MASK,
|
||||
ForceSample,
|
||||
FramePayload,
|
||||
KeyframeRef,
|
||||
Provenance,
|
||||
@@ -56,6 +57,7 @@ __all__ = [
|
||||
"AnalysisNotFoundError",
|
||||
"AnalysisReader",
|
||||
"AnalysisResultError",
|
||||
"ForceTable",
|
||||
]
|
||||
|
||||
#: How many keyframe payloads to keep decoded. One grid and one mask is the norm; the
|
||||
@@ -63,6 +65,36 @@ __all__ = [
|
||||
_GRID_CACHE = 16
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ForceTable:
|
||||
"""A whole result's force trace, in the shape a curve source reads.
|
||||
|
||||
``times`` is the recording's own signal time, one row per stored frame; ``raw`` is the
|
||||
device triplet and ``newtons`` the calibrated one, both with ``NaN`` where the chain
|
||||
had no value — a gap in the trace, never a zero in its place. ``calibrated`` says
|
||||
whether *any* frame came from a calibration curve, so a chart can offer a unit only
|
||||
when there is something to show in it.
|
||||
"""
|
||||
|
||||
times: np.ndarray
|
||||
raw: np.ndarray
|
||||
newtons: np.ndarray
|
||||
valid: np.ndarray
|
||||
frame_ids: np.ndarray
|
||||
calibrated: bool = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
return int(self.times.size)
|
||||
|
||||
@property
|
||||
def axis_available(self) -> np.ndarray:
|
||||
"""Per axis: whether any row carries a finite value."""
|
||||
return np.isfinite(self.raw).any(axis=0) | np.isfinite(self.newtons).any(axis=0)
|
||||
|
||||
def value_for_unit(self, unit: str) -> np.ndarray:
|
||||
return self.newtons if str(unit).upper().startswith("N") else self.raw
|
||||
|
||||
|
||||
class AnalysisResultError(RuntimeError):
|
||||
"""Base for every reason a stored result cannot be served."""
|
||||
|
||||
@@ -157,6 +189,8 @@ class AnalysisReader:
|
||||
self._chunk_index: dict[int, _ChunkIndex] = {}
|
||||
self._mask_cache: np.ndarray | None = None
|
||||
self._mask_loaded = False
|
||||
self._forces: ForceTable | None = None
|
||||
self._forces_loaded = False
|
||||
self._grid_cache: OrderedDict[int, tuple[np.ndarray, np.ndarray]] = (
|
||||
OrderedDict()
|
||||
)
|
||||
@@ -498,8 +532,69 @@ class AnalysisReader:
|
||||
depth_min=depth_min,
|
||||
depth_max=depth_max,
|
||||
depth_stored_bytes=depth_stored,
|
||||
force=decoded.force,
|
||||
)
|
||||
|
||||
def force(self, frame_id: int) -> ForceSample | None:
|
||||
"""One frame's stored force sample, or ``None`` when the frame carries none."""
|
||||
return self.frame(int(frame_id)).force
|
||||
|
||||
@property
|
||||
def has_force(self) -> bool:
|
||||
"""Whether *any* frame in this result carries a force sample.
|
||||
|
||||
The provenance is the cheap answer, but it is the pass's intent rather than the
|
||||
file's content; a result whose force the writer never received would otherwise be
|
||||
offered a chart from a promise. The first frame is enough to answer it: a pass
|
||||
either collected force for every frame it wrote or for none of them.
|
||||
"""
|
||||
if "forces" not in self.provenance.sources:
|
||||
return False
|
||||
ids = self.frame_ids
|
||||
if ids.size == 0:
|
||||
return False
|
||||
return self.force(int(ids[0])) is not None
|
||||
|
||||
def forces(self) -> ForceTable | None:
|
||||
"""Every stored force sample, as one table, or ``None`` when there is none.
|
||||
|
||||
Read once and cached: the charts ask for a window, an envelope and a set of
|
||||
statistics of the same trace, and scanning the chunk files three times to answer
|
||||
the same question is the difference between a redraw and a pause. The cost is six
|
||||
float32 values per frame — 0.4 MB for a ten-minute recording at 30 fps.
|
||||
"""
|
||||
if self._forces_loaded:
|
||||
return self._forces
|
||||
self._forces_loaded = True
|
||||
ids = self.frame_ids
|
||||
if ids.size == 0 or not self.has_force:
|
||||
self._forces = None
|
||||
return None
|
||||
times = np.empty(ids.size, dtype=np.float64)
|
||||
raw = np.full((ids.size, 3), np.nan, dtype=np.float64)
|
||||
newtons = np.full((ids.size, 3), np.nan, dtype=np.float64)
|
||||
valid = np.zeros(ids.size, dtype=bool)
|
||||
calibrated = False
|
||||
for slot, frame_id in enumerate(ids):
|
||||
payload = self.frame(int(frame_id))
|
||||
sample = payload.force
|
||||
times[slot] = float(payload.t_rel_s)
|
||||
if sample is None:
|
||||
continue
|
||||
raw[slot] = sample.raw()
|
||||
newtons[slot] = sample.newtons()
|
||||
valid[slot] = bool(sample.valid)
|
||||
calibrated = calibrated or bool(sample.calibrated)
|
||||
self._forces = ForceTable(
|
||||
times=times,
|
||||
raw=raw,
|
||||
newtons=newtons,
|
||||
valid=valid,
|
||||
frame_ids=ids.astype(np.int64),
|
||||
calibrated=calibrated,
|
||||
)
|
||||
return self._forces
|
||||
|
||||
def frame_at(self, t_rel_s: float) -> FramePayload | None:
|
||||
"""The stored frame at or just before ``t_rel_s``, or ``None`` for an empty result.
|
||||
|
||||
|
||||
@@ -164,6 +164,182 @@ class StoreCurveSource:
|
||||
)
|
||||
|
||||
|
||||
class AnalysisCurveSource:
|
||||
"""A stored analysis result's **recomputed** force trace, as a curve source.
|
||||
|
||||
A bare video has no ``forces.csv``: the only force there is the one the vision chain
|
||||
computes from the frames, which the pass stores per frame (see
|
||||
``analysis.model.FRAME_FLAG_FORCE``). This reads that trace back through the same
|
||||
interface the recording's CSV is read through, so the charts need no second drawing
|
||||
path — and its ``kind`` is deliberately neither ``disk`` nor ``memory``: a readout
|
||||
drawn from a recomputation and one drawn from a measurement must be tellable apart by
|
||||
the surface that shows them, which is the whole reason both exist.
|
||||
|
||||
``valid`` rows are the chain's own verdict intersected with finiteness: a ``NaN`` is a
|
||||
frame the chain had no number for, and a gap in the trace is the honest picture of it.
|
||||
"""
|
||||
|
||||
kind = "analysis"
|
||||
|
||||
def __init__(self, reader, *, unit: str = "raw") -> None:
|
||||
self.reader = reader
|
||||
self.unit = "N" if str(unit).upper().startswith("N") else "raw"
|
||||
self._table = None
|
||||
self._loaded = False
|
||||
|
||||
# ------------------------------------------------------------------- loading
|
||||
def table(self):
|
||||
"""The stored trace, read once from the result."""
|
||||
if not self._loaded:
|
||||
self._loaded = True
|
||||
try:
|
||||
self._table = self.reader.forces()
|
||||
except Exception: # noqa: BLE001 - an unreadable trace is an empty one, said so
|
||||
self._table = None
|
||||
if self._table is not None and not bool(self._table.calibrated):
|
||||
# No curve was applied anywhere in this result, so there is nothing to
|
||||
# offer in newtons: keep the raw counts rather than trading them for NaN.
|
||||
self.unit = "raw"
|
||||
return self._table
|
||||
|
||||
def unit_available(self, unit: str) -> bool:
|
||||
"""Whether this trace can be drawn in ``unit`` at all."""
|
||||
table = self.table()
|
||||
if table is None:
|
||||
return False
|
||||
if not str(unit).upper().startswith("N"):
|
||||
return bool(np.isfinite(table.raw).any())
|
||||
return bool(table.calibrated and np.isfinite(table.newtons).any())
|
||||
|
||||
# ------------------------------------------------------------------- protocol
|
||||
def extent(self) -> tuple[float, float]:
|
||||
table = self.table()
|
||||
if table is None or len(table) == 0:
|
||||
return (0.0, 0.0)
|
||||
return (float(table.times[0]), float(table.times[-1]))
|
||||
|
||||
def duration_s(self) -> float:
|
||||
first, last = self.extent()
|
||||
return max(0.0, last - first)
|
||||
|
||||
def window(
|
||||
self,
|
||||
t0: float,
|
||||
t1: float,
|
||||
*,
|
||||
max_points: int | None = None,
|
||||
unit: str | None = None,
|
||||
) -> SourceWindow:
|
||||
table = self.table()
|
||||
wanted = self._normalise(unit)
|
||||
if table is None or len(table) == 0:
|
||||
return self._empty(wanted)
|
||||
keep = (table.times >= float(t0)) & (table.times <= float(t1))
|
||||
values = table.value_for_unit(wanted)[keep]
|
||||
times = table.times[keep]
|
||||
valid = table.valid[keep]
|
||||
frame_ids = table.frame_ids[keep]
|
||||
decimated = False
|
||||
if max_points and times.size > int(max_points):
|
||||
rows = _extreme_rows(values, int(max_points))
|
||||
times, values, valid = times[rows], values[rows], valid[rows]
|
||||
frame_ids = frame_ids[rows]
|
||||
decimated = True
|
||||
return SourceWindow(
|
||||
times=times,
|
||||
values=values,
|
||||
valid=valid,
|
||||
frame_ids=frame_ids,
|
||||
unit=wanted,
|
||||
decimated=decimated,
|
||||
)
|
||||
|
||||
def envelope(self, *, buckets: int = 1200, unit: str | None = None) -> SourceWindow:
|
||||
"""The whole trace as one row per bucket, the way a recording's is drawn.
|
||||
|
||||
Each bucket contributes its own extreme row per plotted axis, so a shear spike
|
||||
survives the decimation instead of being averaged into the middle of the band.
|
||||
"""
|
||||
table = self.table()
|
||||
wanted = self._normalise(unit)
|
||||
if table is None or len(table) == 0:
|
||||
return self._empty(wanted)
|
||||
values = table.value_for_unit(wanted)
|
||||
total = len(table)
|
||||
count = max(1, min(int(buckets), total))
|
||||
edges = np.linspace(0, total, num=count + 1).astype(np.int64)
|
||||
rows: list[int] = []
|
||||
for index in range(count):
|
||||
start = int(edges[index])
|
||||
stop = max(int(edges[index + 1]), start + 1)
|
||||
chunk = values[start:stop]
|
||||
if not chunk.size:
|
||||
continue
|
||||
finite = np.isfinite(chunk)
|
||||
if not finite.any():
|
||||
rows.append(start)
|
||||
continue
|
||||
ordered = np.where(finite, chunk, -np.inf)
|
||||
extremes = {int(np.argmax(ordered[:, axis])) for axis in range(chunk.shape[1])}
|
||||
extremes |= {
|
||||
int(np.argmin(ordered[:, axis]))
|
||||
for axis in range(chunk.shape[1])
|
||||
if finite[:, axis].any()
|
||||
}
|
||||
rows.extend(start + offset for offset in sorted(extremes))
|
||||
picked = np.asarray(sorted(set(rows)), dtype=np.int64)
|
||||
if picked.size == 0:
|
||||
return self._empty(wanted)
|
||||
return SourceWindow(
|
||||
times=table.times[picked],
|
||||
values=values[picked],
|
||||
valid=table.valid[picked],
|
||||
frame_ids=table.frame_ids[picked],
|
||||
unit=wanted,
|
||||
decimated=True,
|
||||
)
|
||||
|
||||
def statistics(self, t0: float, t1: float, *, unit: str | None = None) -> SourceStats:
|
||||
return self.window(t0, t1, unit=self._normalise(unit)).statistics()
|
||||
|
||||
# ------------------------------------------------------------------- helpers
|
||||
def _normalise(self, unit: str | None) -> str:
|
||||
wanted = self.unit if unit is None else unit
|
||||
wanted = "N" if str(wanted).upper().startswith("N") else "raw"
|
||||
return wanted
|
||||
|
||||
def _empty(self, unit: str) -> SourceWindow:
|
||||
empty = np.empty(0, dtype=np.float64)
|
||||
return SourceWindow(
|
||||
times=empty,
|
||||
values=np.empty((0, 3), dtype=np.float64),
|
||||
valid=np.empty(0, dtype=bool),
|
||||
frame_ids=np.empty(0, dtype=np.int64),
|
||||
unit=unit,
|
||||
)
|
||||
|
||||
|
||||
def _extreme_rows(values: np.ndarray, max_points: int) -> np.ndarray:
|
||||
"""Row indices keeping every axis' extreme per bucket, so a spike survives a window."""
|
||||
total = int(values.shape[0])
|
||||
budget = max(4, int(max_points))
|
||||
bucket = max(1, total // max(1, budget // 6))
|
||||
picked: set[int] = set()
|
||||
for start in range(0, total, bucket):
|
||||
stop = min(total, start + bucket)
|
||||
for axis in range(values.shape[1]):
|
||||
column = values[start:stop, axis]
|
||||
finite = np.isfinite(column)
|
||||
if not finite.any():
|
||||
continue
|
||||
ordered = np.where(finite, column, -np.inf)
|
||||
picked.add(start + int(np.argmin(ordered)))
|
||||
picked.add(start + int(np.argmax(ordered)))
|
||||
if not picked:
|
||||
return np.arange(total)
|
||||
return np.asarray(sorted(picked), dtype=np.int64)
|
||||
|
||||
|
||||
class RecordingCurveSource:
|
||||
"""A recording on disk: interval reads plus a cached envelope."""
|
||||
|
||||
|
||||
@@ -153,6 +153,8 @@ def recordings_dir() -> Path:
|
||||
DERIVED_DIR_NAME = "OrisysStudio"
|
||||
#: Subdirectory holding the offline analysis results.
|
||||
ANALYSIS_CACHE_DIRNAME = "analysis-cache"
|
||||
#: Subdirectory holding the workspaces generated for videos that are not recordings.
|
||||
VIDEO_WORKSPACE_DIRNAME = "video-workspaces"
|
||||
|
||||
|
||||
def derived_data_dir() -> Path:
|
||||
@@ -186,6 +188,18 @@ def analysis_cache_dir() -> Path:
|
||||
return derived_data_dir() / ANALYSIS_CACHE_DIRNAME
|
||||
|
||||
|
||||
def video_workspace_dir() -> Path:
|
||||
"""Where a bare video's generated workspace lives: the app's own root.
|
||||
|
||||
A video with no recording beside it has no ``frames.csv`` and no ``session.json``, and
|
||||
the analysis pass reads nothing else. Rather than teach the pass a second input shape,
|
||||
the two files it already expects are *manufactured* for the video — and they are the
|
||||
app's own derived data, so they belong here and never in the folder the operator keeps
|
||||
the footage in. See :mod:`orisys_app_shared.analysis.bare_video`.
|
||||
"""
|
||||
return derived_data_dir() / VIDEO_WORKSPACE_DIRNAME
|
||||
|
||||
|
||||
def preference_path() -> Path:
|
||||
return app_root() / "preference.json"
|
||||
|
||||
|
||||
@@ -25,12 +25,12 @@ hiding it is how the same file ends up offered as a sensor config.
|
||||
Two bundles that declare one ``profile_id`` are both listed and each names the other. Nothing
|
||||
is shadowed, and the ambiguity is available to a caller that must refuse to start.
|
||||
|
||||
What the picker *says* about an entry is deliberately not what it is called internally. The
|
||||
label names the product, whether the bundle shipped with the app or came from the operator, and
|
||||
what the calibration selection gives — a named file, or raw force. The ``profile_id``, the
|
||||
bundle's shape and the SDK's own ``ok``/``degraded``/``failed`` verdict live in
|
||||
:meth:`CatalogEntry.tooltip`: they inform the operator who asked, instead of heading the row
|
||||
with a word that only says "something optional is missing".
|
||||
What the picker *says* about an entry is deliberately not what it is called internally. A row is
|
||||
the bundle's name — the descriptor's ``display_name``, else the folder name — and nothing else.
|
||||
The origin word, the calibration phrasing and the SDK's own ``ok``/``degraded``/``failed``
|
||||
verdict belong to the details surface under the selector, which is where the operator reads them
|
||||
(:meth:`CatalogEntry.tooltip` is the same facts on hover). A row is qualified only where two
|
||||
entries offered together would otherwise read alike; see :func:`row_labels`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -39,6 +39,7 @@ import contextlib
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from collections.abc import Iterable, Sequence
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
@@ -219,8 +220,15 @@ class CatalogEntry:
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
"""What the operator calls this configuration: its name, family, or folder."""
|
||||
return self.display_name or self.product_family or self.folder
|
||||
"""The bundle's name: the descriptor's ``display_name``, else its folder name.
|
||||
|
||||
The descriptor's name is the identity an operator can act on — it is what the bundle
|
||||
calls itself, and the SDK documents it as the picker's text. Without one, the folder
|
||||
name is the name: it is what the operator called the bundle when they put it there.
|
||||
Where two visible entries still read alike, :meth:`SensorCatalog.row_label` adds the
|
||||
suffix that tells them apart.
|
||||
"""
|
||||
return self.display_name or self.folder
|
||||
|
||||
@property
|
||||
def origin_label(self) -> str:
|
||||
@@ -259,17 +267,14 @@ class CatalogEntry:
|
||||
return path.name if path is not None else ""
|
||||
|
||||
@property
|
||||
def calibration_note(self) -> str:
|
||||
"""What the calibration *selection* gives: a named file, or raw force.
|
||||
def calibration_selection(self) -> str:
|
||||
"""The calibration selection as what it is: a named file, or raw force.
|
||||
|
||||
A calibration is a separate optional artifact chosen for a session, never a property of
|
||||
the bundle, so an absent one is stated as what is not selected — not as a bundle that is
|
||||
uncalibrated, and never as a defect.
|
||||
"""
|
||||
selected = self.selected_calibration
|
||||
if selected:
|
||||
return f"Calibration: {selected}"
|
||||
return "No calibration selected — raw force"
|
||||
return self.selected_calibration or "No calibration selected — raw force"
|
||||
|
||||
@property
|
||||
def duplicate(self) -> bool:
|
||||
@@ -292,30 +297,72 @@ class CatalogEntry:
|
||||
)
|
||||
return None
|
||||
|
||||
def label(self) -> str:
|
||||
"""One line for a picker item: what it is, where it came from, what it will give.
|
||||
def error_sentences(self) -> tuple[str, ...]:
|
||||
"""Every error as a readable sentence, minus the ones the reason already quotes.
|
||||
|
||||
Deliberately absent: the ``profile_id`` (identity no operator can act on), the SDK's
|
||||
``ok``/``degraded``/``failed`` verdict (a severity with no information in it) and the
|
||||
word ``legacy`` (which means "read through the compatibility adapter", not "old").
|
||||
What is left is the choice itself: the product, its origin, and the force a session
|
||||
started from it will produce.
|
||||
The reason is built out of the errors, so repeating them beside it would bury the one
|
||||
line that explains the entry. What is left is still said, never dropped.
|
||||
"""
|
||||
name = self.name
|
||||
if not self.valid:
|
||||
return f"{name} · Cannot start: {self.start_blocked_reason}"
|
||||
origin = self.origin_label
|
||||
if self.duplicates:
|
||||
return f"{name} · {origin} · Also provided by {', '.join(self.duplicates)}"
|
||||
return f"{name} · {origin} · {self.calibration_note}"
|
||||
return tuple(
|
||||
_sentence(issue) for issue in self.errors if str(issue) not in self.reason
|
||||
)
|
||||
|
||||
def warning_sentences(self) -> tuple[str, ...]:
|
||||
"""Every warning as a readable sentence rather than as an issue code."""
|
||||
return tuple(_sentence(issue) for issue in self.warnings)
|
||||
|
||||
def capability_line(self) -> str:
|
||||
"""What this profile can do, in one line — flow / force / axes / depth / 3D.
|
||||
|
||||
The line the setup prompt offers next to the bundle's name, so choosing one is a
|
||||
decision about *processing* rather than about a folder. It states only what is
|
||||
available: a capability that is not there is not a fact about the processing, and
|
||||
whose *reason* it is belongs in the tooltip beside it (``tooltip`` already carries
|
||||
every finding as a sentence). ``deformation`` is named as what the profile says it
|
||||
measures — optical flow in pixels, relative displacement or relative depth — since
|
||||
"depth" is one of three possible meanings and a bare "depth: yes" would be wrong
|
||||
for the other two.
|
||||
"""
|
||||
profile = self.profile
|
||||
if profile is None:
|
||||
return ""
|
||||
capabilities = profile.capabilities
|
||||
parts: list[str] = []
|
||||
if capabilities.video_processing.available:
|
||||
parts.append("video")
|
||||
if capabilities.optical_flow.available:
|
||||
parts.append("flow")
|
||||
if capabilities.raw_force.available:
|
||||
parts.append("raw force")
|
||||
axes = [
|
||||
name
|
||||
for name, availability in (
|
||||
("fn", capabilities.calibrated_force.fn),
|
||||
("fx", capabilities.calibrated_force.fx),
|
||||
("fy", capabilities.calibrated_force.fy),
|
||||
)
|
||||
if availability.available
|
||||
]
|
||||
if axes:
|
||||
parts.append(f"calibrated axes ({', '.join(axes)})")
|
||||
if capabilities.deformation.available:
|
||||
# The SDK's own detail for this capability is a sentence ("relative depth from
|
||||
# the deformation field"), and the capability's documented meanings are
|
||||
# optical flow in pixels, relative displacement and relative depth. "Depth" is
|
||||
# the one that is always true of a deformation field here, and the sentence it
|
||||
# came from stays in the tooltip rather than being truncated into the line.
|
||||
parts.append("depth")
|
||||
if capabilities.visualization_3d.available:
|
||||
parts.append("3D")
|
||||
return " · ".join(parts)
|
||||
|
||||
def tooltip(self) -> str:
|
||||
"""The facts behind the label, including why the entry is not startable.
|
||||
"""The facts behind the row, including why the entry is not startable.
|
||||
|
||||
The technical half the picker row has no room for: the identity the SDK assigned, how
|
||||
the bundle was read, its validation, and every finding as a sentence rather than as a
|
||||
code — ``calibration.absent`` says nothing an operator can use, "calibrated force is
|
||||
unavailable; raw force is available" says everything.
|
||||
The technical half the row does not carry: the identity the SDK assigned, how the bundle
|
||||
was read, its validation, and every finding as a sentence rather than as a code —
|
||||
``calibration.absent`` says nothing an operator can use, "calibrated force is unavailable;
|
||||
raw force is available" says everything.
|
||||
"""
|
||||
state_note = _STATE_NOTES.get(self.state, "verdict not recognised")
|
||||
lines = [
|
||||
@@ -325,19 +372,13 @@ class CatalogEntry:
|
||||
f"processing backend: {self.processing_backend or 'unknown'}",
|
||||
f"origin: {self.origin} ({self.bundle_path})",
|
||||
f"SDK validation: {self.state} ({state_note})",
|
||||
f"calibration: {self.selected_calibration or 'none selected — raw force'}",
|
||||
f"calibration selection: {self.calibration_selection}",
|
||||
]
|
||||
blocked = self.start_blocked_reason
|
||||
if blocked:
|
||||
lines.append(f"cannot start: {blocked}")
|
||||
# The reason already quotes the errors it was built from; repeating them here would
|
||||
# bury the one line that explains the entry.
|
||||
lines.extend(
|
||||
f"error: {_sentence(issue)}"
|
||||
for issue in self.errors
|
||||
if str(issue) not in self.reason
|
||||
)
|
||||
lines.extend(f"warning: {_sentence(issue)}" for issue in self.warnings)
|
||||
lines.extend(f"error: {text}" for text in self.error_sentences())
|
||||
lines.extend(f"warning: {text}" for text in self.warning_sentences())
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
@@ -427,6 +468,17 @@ class SensorCatalog:
|
||||
)
|
||||
return None if entry is None else entry.start_blocked_reason
|
||||
|
||||
def row_label(self, entry: CatalogEntry) -> str:
|
||||
"""The one line this entry gets in a picker row, told apart from its neighbours.
|
||||
|
||||
A row is the bundle's name and nothing else. The exception is a name two entries share
|
||||
while being offered side by side — the same folder under both roots, or one
|
||||
``display_name`` declared twice — and those carry the suffix :func:`row_labels` picks.
|
||||
Computed from the family group, which is what the picker shows at one time: two entries
|
||||
in groups that are never visible together keep the plain name.
|
||||
"""
|
||||
return row_labels(self.entries_for_family(entry.family)).get(entry.key, entry.name)
|
||||
|
||||
def summary_lines(self) -> list[str]:
|
||||
"""A short block for a log header or a diagnostics panel."""
|
||||
lines = [
|
||||
@@ -438,11 +490,63 @@ class SensorCatalog:
|
||||
for entry in self.entries:
|
||||
blocked = entry.start_blocked_reason
|
||||
lines.append(
|
||||
f" {entry.ref}: {entry.label()}" + (f" - {blocked}" if blocked else "")
|
||||
f" {entry.ref}: {self.row_label(entry)}"
|
||||
+ (f" - {blocked}" if blocked else "")
|
||||
)
|
||||
return lines
|
||||
|
||||
|
||||
def row_labels(entries: Iterable[CatalogEntry]) -> dict[str, str]:
|
||||
"""Row text per entry key for one visible group: the name, plus a suffix only on a collision.
|
||||
|
||||
Two entries may legitimately share a name — ``shipped/box`` beside ``sensors-user/box``, or
|
||||
two folders whose descriptors declare one ``display_name``. Neither is dropped or shadowed,
|
||||
so each has to be tellable apart in the list they share, and the suffix says which one the
|
||||
operator is looking at. The origin is the suffix the picker is meant to show, because that is
|
||||
what separates the bundle the app shipped from the operator's own copy of it; when one root
|
||||
supplies both sides of a collision, the origin cannot separate them and the folder — the
|
||||
identity that actually differs there — is used instead. Should even that read alike (a folder
|
||||
named like an origin), the full ``origin/folder`` ref is used, so no row is ever identical to
|
||||
another. Deterministic: the same tree always produces the same rows.
|
||||
"""
|
||||
labels: dict[str, str] = {}
|
||||
for name, group in _group_by_name(entries).items():
|
||||
if len(group) == 1:
|
||||
labels[group[0].key] = name
|
||||
continue
|
||||
labels.update(_collision_labels(name, group))
|
||||
return labels
|
||||
|
||||
|
||||
def _group_by_name(entries: Iterable[CatalogEntry]) -> dict[str, list[CatalogEntry]]:
|
||||
"""The entries by the name they would otherwise read as, in the order they were given."""
|
||||
groups: dict[str, list[CatalogEntry]] = {}
|
||||
for entry in entries:
|
||||
groups.setdefault(entry.name, []).append(entry)
|
||||
return groups
|
||||
|
||||
|
||||
def _collision_labels(
|
||||
name: str, group: Sequence[CatalogEntry]
|
||||
) -> dict[str, str]:
|
||||
"""One name shared by several visible entries: each row with the suffix that separates it."""
|
||||
by_origin: dict[str, int] = {}
|
||||
for entry in group:
|
||||
origin = entry.origin_label.lower()
|
||||
by_origin[origin] = by_origin.get(origin, 0) + 1
|
||||
labels: dict[str, str] = {}
|
||||
used: set[str] = set()
|
||||
for entry in group:
|
||||
origin = entry.origin_label.lower()
|
||||
preferred = origin if by_origin[origin] == 1 else entry.folder
|
||||
for suffix in (preferred, entry.ref):
|
||||
if suffix not in used:
|
||||
break
|
||||
used.add(suffix)
|
||||
labels[entry.key] = f"{name} ({suffix})"
|
||||
return labels
|
||||
|
||||
|
||||
def resolve_user_root() -> UserRootDecision:
|
||||
"""The writable user bundle root, preferring the portable sibling.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user