"""All active collection batches and database ingestion updates.""" from __future__ import annotations from collections import Counter from datetime import date, datetime, timedelta import html import math from typing import Callable, Iterable from zoneinfo import ZoneInfo import pandas as pd import plotly.graph_objects as go import streamlit as st from batch_updates import BatchUpdateCatalog, BatchUpdateRecord from city_labels import city_display_name from dashboard_charts import DATA_BLUE, style_figure _BEIJING = ZoneInfo('Asia/Shanghai') def ingestion_totals(catalog: BatchUpdateCatalog, today: date) -> tuple[int, int, int]: """Today and inclusive last 7/30 Beijing days, excluding future entries.""" counts = Counter() for record in catalog.records: for item in record.ingestion_days: counts[item.day] += item.videos return tuple(sum(count for day, count in counts.items() if today - timedelta(days=window-1) <= day <= today) for window in (1, 7, 30)) def ingestion_timeline(catalog: BatchUpdateCatalog, today: date, window: int | None = 30) -> list[tuple[date, int]]: """Fill Beijing calendar days through today; future rows never add activity.""" counts = Counter() for record in catalog.records: for item in record.ingestion_days: if item.day <= today: counts[item.day] += item.videos if window is None: if not counts: return [] start = min(counts) else: start = today - timedelta(days=window-1) return [(start + timedelta(days=offset), counts[start + timedelta(days=offset)]) for offset in range((today-start).days+1)] def _hour_days(catalog: BatchUpdateCatalog, today: date) -> dict[date, tuple[float, int, int]] | None: if not catalog.includes_durations: return None buckets = {} for record in catalog.records: for item in record.ingestion_days: if item.day <= today: bucket = buckets.setdefault(item.day, [[], 0, 0]) bucket[0].append(item.known_seconds) bucket[1] += item.known_videos bucket[2] += item.unknown_videos return {day: (math.fsum(values[0]), values[1], values[2]) for day, values in buckets.items()} def ingestion_hours(catalog: BatchUpdateCatalog, today: date) -> tuple[float | None, ...]: """Known active-video hours received in Beijing day windows, never frame estimates.""" days = _hour_days(catalog, today) if days is None: return None, None, None totals = [] for window in (1, 7, 30): selected = [values for day, values in days.items() if today - timedelta(days=window-1) <= day <= today] known, unknown = sum(row[1] for row in selected), sum(row[2] for row in selected) totals.append(math.fsum(row[0] for row in selected) / 3600 if known or not unknown else None) return tuple(totals) def ingestion_hour_timeline(catalog: BatchUpdateCatalog, today: date, window: int | None = 30) -> list[tuple[date, float | None]] | None: """No arrivals is measured zero; all-unknown arrivals leave an explicit gap.""" days = _hour_days(catalog, today) if days is None: return None if window is None and not days: return [] start = min(days) if window is None else today - timedelta(days=window-1) rows = [] for offset in range((today-start).days+1): day = start + timedelta(days=offset) seconds, known, unknown = days.get(day, (0, 0, 0)) rows.append((day, seconds / 3600 if known or not unknown else None)) return rows def batch_table(records: Iterable[BatchUpdateRecord], translate: Callable) -> pd.DataFrame: """Export only curated batch fields, with public timestamps in Beijing time.""" def stamp(value): return value.astimezone(_BEIJING).strftime('%Y-%m-%d %H:%M:%S') if value else translate('未知') records = sorted(records, key=lambda record: (record.last_ingested_at is None, -record.last_ingested_at.timestamp() if record.last_ingested_at else 0, record.code)) columns = tuple(map(translate, ('批次编号','城市','已知时长 (h)','首次入库','最近入库', '源数据更新','标注完成','可用标签'))) return pd.DataFrame([ (record.code, city_display_name(record.city), record.duration_hours, stamp(record.first_ingested_at), stamp(record.last_ingested_at), stamp(record.source_updated_at), stamp(record.annotation_completed_at), translate('有' if record.annotations_available else '暂无')) for record in records ], columns=columns) def render_batch_updates(load_updates: Callable, translate: Callable) -> None: def tr(zh, en): translated = translate(zh) return en if st.session_state.get('lang') == 'en' and translated == zh else translated names = {'批次编号':'Batch code','城市':'City','已知时长 (h)':'Known duration (h)', '首次入库':'First ingested','最近入库':'Last ingested','源数据更新':'Source updated', '标注完成':'Annotation completed','可用标签':'Usable labels','有':'Available', '暂无':'Unavailable','未知':'Unknown'} def table_translate(value): return tr(value, names.get(value, value)) st.markdown("
', unsafe_allow_html=True) st.caption(tr( '这里展示全部活跃采集批次,独立于城市、采集日期与 ODD 筛选。入库时间是数据库接收视频的时间,' '不等于相机采集时间;新增量按当前活跃视频的首次入库日计算,不是包含删除记录的历史审计。' '时间统一为北京时间;标注完成取实际处理结束时间,源数据更新取视频修改时间。', 'All active collection batches are shown independently of city, collection-date and ODD filters. ' 'Ingestion means database receipt, not camera collection. Additions use the creation dates of currently ' 'active videos; this is not a deletion audit. Times use Beijing time. Annotation completion uses the ' 'actual processing finish time; source updates use video modification time.')) catalog, status = load_updates() if status != 'ok' or catalog is None: st.warning(tr('数据更新暂不可用,请稍后刷新。', 'Dataset updates are unavailable. Please refresh later.')) return today = datetime.now(_BEIJING).date() today_hours, seven_hours, month_hours = ingestion_hours(catalog, today) with st.container(key='batch_updates_kpis'): for column, label, english, value in zip(st.columns(4), ('活跃采集批次','今日新增 (h)','近 7 日新增 (h)','近 30 日新增 (h)'), ('Active batches','Added today (h)','Added in 7 days (h)','Added in 30 days (h)'), (len(catalog.records), today_hours, seven_hours, month_hours)): column.metric(tr(label, english), (f'{value:,}' if label == '活跃采集批次' else f'{value:,.3f}' if value is not None else '—')) if not catalog.includes_durations: st.info(tr('批次及每日入库时长尚未发布;暂不显示时长趋势,不以视频数量换算。', 'Batch and daily ingestion durations have not been published. The duration trend is unavailable.')) elif any(record.unknown_videos for record in catalog.records): st.caption(tr('含未知时长数据,仅合计实际已知时长;全未知显示空值,测得零时长保留为 0 h。', 'Some durations are unknown. Only measured duration is summed; all-unknown values are blank, measured zero remains 0 h.')) if any(sum(day.videos for day in record.ingestion_days) < record.videos for record in catalog.records): st.caption(tr('存在入库日期缺失的数据;每日趋势和近期新增指标仅统计有入库日期的已知时长。', 'Some ingestion dates are missing. Daily and recent additions include only measured duration with an ingestion date.')) if catalog.unlinked_videos: hours = catalog.unlinked_duration_hours value = f'{hours:,.3f} h' if hours is not None else tr('时长未知或尚未发布', 'duration unknown or unpublished') st.info(tr(f'另有未关联采集批次的数据(已知部分:{value}),未计入批次新增量与下表。', f'Unlinked data (known portion: {value}) is excluded from batch additions and the table.')) if not catalog.records: st.info(tr('暂无活跃采集批次。', 'No active collection batches are available.')) return ranges = (tr('近 7 日', 'Last 7 days'), tr('近 30 日', 'Last 30 days'), tr('全部', 'All time')) selected_range = st.selectbox(tr('入库时间范围', 'Ingestion time range'), ranges, index=1, key='batch_updates_range') window = {ranges[0]:7, ranges[1]:30, ranges[2]:None}[selected_range] timeline = ingestion_hour_timeline(catalog, today, window) if timeline: figure = go.Figure(go.Bar(x=[day.isoformat() for day, _ in timeline], y=[count for _, count in timeline], marker_color=DATA_BLUE, hovertemplate='%{x}: %{y:.3f} h