Files
my-v0-server/app/datafiles/grid.py
T

307 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""按列存放的单元格,读取、拆表和类型推断共用
每列是若干段 Arrow 数组,每段一种类型:字符串(已去掉首尾空白,空串记为空值)、布尔、int64、float64、
date32、timestamp[us],或全是空值。读取时每 CHUNK_ROWS 行转一次,同一时间只有一个块是 Python 对象;
块里同一列的类型混杂时(表头和数值在同一列、数值列里夹着"待定"),这一段存成 Arrow 的 dense union:
每种类型一个子数组,原值和原类型都保留。Arrow 的 is_valid 等函数不认 union 的子数组里的空值,
所以按段处理的地方都经过 each,对子数组分别计算再按位置拼回
"""
from collections.abc import Iterable, Sequence
from dataclasses import dataclass
from datetime import date, datetime, time
from itertools import zip_longest
from typing import Any
import numpy as np
import pyarrow as pa
import pyarrow.compute as pc
from app.datafiles.cells import (
EXACT_FLOAT_INT,
INT64_RANGE,
NULL_TEXT,
WHITESPACE,
as_text,
mask,
union_layout,
)
CHUNK_ROWS = 65_536
NoneType = type(None)
class Column:
def __init__(self, pieces: Sequence[pa.Array]):
self.pieces = [piece for piece in pieces if len(piece)]
self.offsets = np.cumsum([0, *map(len, self.pieces)])
@classmethod
def from_values(cls, values: Sequence[Any]) -> "Column":
return cls([_convert(values)])
def __len__(self) -> int:
return int(self.offsets[-1])
def present(self) -> np.ndarray:
"""每个单元格是否有值"""
return _join(
[
each(piece, lambda part: mask(part.is_valid()), bool)
for piece in self.pieces
],
bool,
)
def matches(self, test) -> np.ndarray:
"""字符串单元格中 test(Arrow 字符串数组 → 布尔数组)为真的位置;其他类型的单元格都不算"""
def check(part: pa.Array) -> np.ndarray:
if pa.types.is_string(part.type):
return mask(test(part))
return np.zeros(len(part), bool)
return _join([each(piece, check, bool) for piece in self.pieces], bool)
def slice(self, start: int, stop: int) -> "Column":
pieces = []
for piece, offset in zip(self.pieces, self.offsets):
begin, end = max(start - offset, 0), min(stop - offset, len(piece))
if begin < end:
pieces.append(piece.slice(begin, end - begin))
return Column(pieces)
def take(self, rows: np.ndarray) -> "Column":
"""按行号(升序)取出若干行;行号连续时不复制数据"""
if len(rows) and rows[-1] - rows[0] + 1 == len(rows):
return self.slice(int(rows[0]), int(rows[-1]) + 1)
bounds = np.searchsorted(rows, self.offsets)
return Column(
[
piece.take(pa.array(rows[bounds[i] : bounds[i + 1]] - self.offsets[i]))
for i, piece in enumerate(self.pieces)
]
)
def to_pylist(self) -> list[Any]:
return [value for piece in self.pieces for value in piece.to_pylist()]
@dataclass
class Grid:
"""不带表头的原始网格;第 i 行是文件或 sheet 中的第 i + 1 行,各列一样长"""
sheet: str | None
columns: list[Column]
height: int
@classmethod
def from_rows(cls, sheet: str | None, rows: Iterable[Sequence[Any]]) -> "Grid":
builder = GridBuilder()
for row in rows:
builder.add(row)
return builder.finish(sheet)
def rows(
self, start: int, stop: int, columns: Sequence[int] | None = None
) -> list[list[Any]]:
"""[start, stop) 行的 Python 值,只取 columns 这些列"""
return self._rows(
[column.slice(start, stop) for column in self._pick(columns)], stop - start
)
def rows_at(
self, rows: np.ndarray, columns: Sequence[int] | None = None
) -> list[list[Any]]:
return self._rows(
[column.take(rows) for column in self._pick(columns)], len(rows)
)
def _pick(self, columns: Sequence[int] | None) -> list[Column]:
return self.columns if columns is None else [self.columns[c] for c in columns]
@staticmethod
def _rows(columns: list[Column], count: int) -> list[list[Any]]:
if not columns:
return [[] for _ in range(count)]
return [list(row) for row in zip(*(column.to_pylist() for column in columns))]
class GridBuilder:
"""逐行接收单元格,每 CHUNK_ROWS 行转成列;行长短不一时补空值
strings 为真表示单元格全是字符串(CSV),跳过类型检查;已经按列读好的字符串(Arrow 读 CSV)用 add_strings
"""
def __init__(self, strings: bool = False):
self._strings = strings
self._columns: list[list[pa.Array]] = []
self._pending: list[Sequence[Any]] = []
self._done = 0
@property
def height(self) -> int:
return self._done + len(self._pending)
def add(self, row: Sequence[Any]) -> None:
self._pending.append(row)
if len(self._pending) >= CHUNK_ROWS:
self._flush()
def add_rows(self, rows: Sequence[Sequence[Any]]) -> None:
self._pending.extend(rows)
if len(self._pending) >= CHUNK_ROWS:
self._flush()
def add_strings(self, columns: Sequence[pa.Array]) -> None:
"""一批按列存放的字符串单元格,各列等长"""
self._flush()
self._append([[_normalize(column)] for column in columns], len(columns[0]))
def finish(self, sheet: str | None) -> Grid:
self._flush()
return Grid(sheet, [Column(pieces) for pieces in self._columns], self._done)
def _flush(self) -> None:
rows, count = self._pending, len(self._pending)
if not count:
return
width = max(map(len, rows))
self._append(
[
[
_normalize(pa.array(values, pa.string()))
if self._strings
else _convert(values)
]
for values in (zip_longest(*rows) if width else ())
],
count,
)
self._pending = []
def _append(self, columns: list[list[pa.Array]], count: int) -> None:
for position, pieces in enumerate(columns):
if position == len(self._columns):
# 这一列在前面的块里没有出现过
self._columns.append([pa.nulls(self._done)])
self._columns[position].extend(pieces)
for pieces in self._columns[len(columns) :]:
pieces.append(pa.nulls(count))
self._done += count
def each(piece: pa.Array, compute, dtype) -> np.ndarray:
"""对一段计算 numpy 结果;union 段对每个子数组分别计算,再按位置拼回"""
if not pa.types.is_union(piece.type):
return compute(piece)
codes, offsets = union_layout(piece)
result = np.zeros(len(piece), dtype)
for code in range(piece.type.num_fields):
at = np.flatnonzero(codes == code)
if len(at):
result[at] = compute(piece.field(code))[offsets[at]]
return result
def _join(parts: list[np.ndarray], dtype) -> np.ndarray:
return np.concatenate(parts) if parts else np.zeros(0, dtype)
def _normalize(piece: pa.Array) -> pa.Array:
"""字符串去掉首尾空白(和 str.strip 相同),空串记为空值"""
if not pa.types.is_string(piece.type):
return piece
piece = pc.utf8_trim(piece, WHITESPACE)
return pc.if_else(pc.equal(piece, ""), NULL_TEXT, piece)
def _convert(values: Sequence[Any]) -> pa.Array:
"""一段单元格转成 Arrow:类型一致时转成一个数组,混杂时转成 dense union"""
types = set(map(type, values))
types.discard(NoneType)
if str in types and len(types) > 1:
# 空白单元格(Excel 读出来是 "")不算一种类型
values = [
None if type(v) is str and (not v or v.isspace()) else v for v in values
]
types = set(map(type, values))
types.discard(NoneType)
piece = _uniform(values, types)
if piece is not None:
return _normalize(piece)
families: dict[type, int] = {}
children: list[list[Any]] = []
codes = np.empty(len(values), np.int8)
offsets = np.empty(len(values), np.int32)
for index, value in enumerate(values):
family = _family(value)
code = families.get(family)
if code is None:
code = families[family] = len(children)
children.append([])
codes[index] = code
offsets[index] = len(children[code])
children[code].append(value)
return pa.UnionArray.from_dense(
pa.array(codes),
pa.array(offsets),
[_normalize(_child(child)) for child in children],
)
def _uniform(values: Sequence[Any], types: set[type]) -> pa.Array | None:
"""类型一致(或能无损地合成一种)时转成一个 Arrow 数组,否则返回空"""
if not types:
return pa.nulls(len(values))
if types == {str}:
return pa.array(values, pa.string())
if types == {bool}:
return pa.array(values, pa.bool_())
if types <= {int, float}:
if float not in types:
try:
return pa.array(values, pa.int64())
except (OverflowError, pa.ArrowException):
return None
# 整数和小数混在一起时存成 float64;整数超出 float 能精确表示的范围就不合并
if all(
-EXACT_FLOAT_INT < v < EXACT_FLOAT_INT for v in values if type(v) is int
):
return pa.array(values, pa.float64())
return None
if types <= {date, datetime}:
if any(type(v) is datetime and v.tzinfo is not None for v in values):
return None
if types == {date}:
return pa.array(values, pa.date32())
# 日期按零点存,类型推断时零点的时间仍算日期,和 classify 一致
return pa.array(
[datetime.combine(v, time()) if type(v) is date else v for v in values],
pa.timestamp("us"),
)
return None
def _family(value: Any) -> type:
"""union 里归为同一个子数组的类型:日期和不带时区的时间合在一起,超出 int64 的整数等存成文本,其余按 Python 类型分开"""
kind = type(value)
if kind is date or (kind is datetime and value.tzinfo is None):
return date
if kind is int and value not in INT64_RANGE:
return object
if kind in (NoneType, str, bool, int, float):
return kind
return object
def _child(values: list[Any]) -> pa.Array:
types = set(map(type, values))
types.discard(NoneType)
piece = _uniform(values, types)
if piece is not None:
return piece
# 超出 int64 的整数、带时区的时间、时长等:存成文本,classify 对它们的结论与文本相同
return pa.array([None if v is None else as_text(v) for v in values], pa.string())