Heavy computation is completed.

This commit is contained in:
Artyom Titov 2026-07-30 15:22:12 +03:00
commit eeb9c31455
30 changed files with 193638 additions and 105489 deletions

View file

@ -1,16 +1,31 @@
#!/usr/bin/env python3
"""Заполнение Excel-шаблона результатами из Mathematica Notebook (.nb).
"""Заполнение одного листа Excel результатами из Mathematica Notebook (.nb).
Пример:
python fill_table_from_nb.py \
--template table_template.xlsx \
--notebooks h_174-66_Pu240_2-3_osc_Qw1.nb \
Все найденные диапазоны размещаются НА ОДНОМ ЛИСТЕ друг под другом:
2-3
4-5
6-7
...
Внутри каждого диапазона собираются шесть notebook-файлов:
osc_Qw1, pr_Qw1, gid_Qw1,
osc_Qw2, pr_Qw2, gid_Qw2.
Можно передать отдельные файлы, маски или корневые каталоги. Каталоги
просматриваются рекурсивно. Файлы вроде "фитирование.nb" и ".nb.old"
автоматически пропускаются.
Пример для всего дерева каталогов:
python convert_precision.py \
--template table_template \
--notebooks . \
--output table_filled.xlsx
Можно передать сразу несколько notebook-файлов. Имя каждого файла должно содержать:
_2-3_ или _4-5_ блок строк в таблице;
_osc_, _pr_ или _gid_ группу столбцов;
_Qw1 или _Qw2 левую или правую половину таблицы.
Пример с маской:
python convert_precision.py \
--template table_template \
--notebooks "**/*_Qw1.nb" "**/*_Qw2.nb" \
--output table_filled.xlsx
"""
from __future__ import annotations
@ -20,16 +35,23 @@ import glob
import math
import re
import sys
from copy import copy
from decimal import Decimal, InvalidOperation, ROUND_HALF_UP
from dataclasses import dataclass
from pathlib import Path
from typing import Iterable
# Границы блоков в конкретном шаблоне.
PAIR_START_ROW = {
"2-3": 5,
"4-5": 19,
}
SCRIPT_VERSION = "2026.07.29.5-precision"
# В шаблоне один блок занимает 14 строк:
# 1-3 — заголовки, 4 — отступ, 5-12 — значения, 13-14 — отступ.
TEMPLATE_BLOCK_TOP_ROW = 1
BLOCK_HEIGHT = 14
DATA_START_OFFSET = 4
DATA_ROW_COUNT = 8
TEMPLATE_MAX_COLUMN = 22 # A:V
# Смещение группы относительно первого столбца данных своей половины.
MODEL_COLUMN_OFFSET = {
@ -38,6 +60,8 @@ MODEL_COLUMN_OFFSET = {
"gid": 6, # Jгид/J
}
MODEL_SORT_ORDER = {"osc": 0, "pr": 1, "gid": 2}
# Порядок столбцов внутри каждой группы шаблона.
METHOD_COLUMN_OFFSET = {
"heavy": 0,
@ -67,6 +91,8 @@ GROUP_RE = re.compile(r"Cell\[CellGroupData\[\{(.*?)\},\s*Open\s*\]\]", re.S)
INPUT_LABEL_RE = re.compile(r'CellLabel->\s*"In\[(\d+)\]:="')
ASSIGNMENT_RE = re.compile(r'RowBox\[\{"([A-Za-z][A-Za-z0-9]*)",\s*"="')
SIMPLE_OUTPUT_RE = re.compile(r'Cell\[BoxData\["([^"]+)"\],\s*"Output"', re.S)
PAIR_RE = re.compile(r"(?:^|_)(\d+)-(\d+)(?=_|$)")
QW_RE = re.compile(r"qw[_-]?([12])(?:[_-]|$)", re.I)
@dataclass(frozen=True)
@ -81,6 +107,8 @@ class NbRecord:
@dataclass(frozen=True)
class Target:
pair: str
pair_left: int
pair_right: int
model: str
qw_side: int
@ -185,13 +213,40 @@ def first_value_for_assignment(
return None
def extract_methods(records: list[NbRecord]) -> dict[str, MethodValues]:
def extract_methods(
records: list[NbRecord],
*,
source_path: Path | None = None,
source_label: str | None = None,
) -> dict[str, MethodValues]:
"""Выделяет heavy/new/light без привязки к номерам In[]/Out[]."""
q_indexes = [i for i, record in enumerate(records) if is_q_result(record)]
if len(q_indexes) < 3:
found_inputs = ", ".join(
f"In[{records[index].input_number}]" for index in q_indexes
) or "нет"
location_lines: list[str] = []
if source_path is not None:
try:
shown_path = source_path.resolve()
except OSError:
shown_path = source_path
location_lines.append(f"Файл: {shown_path}")
if source_label:
location_lines.append(f"Позиция: {source_label}")
location = "\n".join(location_lines)
if location:
location += "\n"
raise ValueError(
"Найдено меньше трёх выражений q/Sqrt[S1*S2]. "
"Проверьте, что notebook полностью вычислен и сохранён с Output-ячейками."
location
+ "Найдено выражений q/Sqrt[S1*S2]: "
+ f"{len(q_indexes)} (ожидалось не меньше 3).\n"
+ f"Соответствующие Input-ячейки: {found_inputs}.\n"
+ "Проверьте, что notebook полностью вычислен и сохранён "
+ "с Output-ячейками."
)
extracted: dict[str, MethodValues] = {}
@ -216,6 +271,7 @@ def extract_methods(records: list[NbRecord]) -> dict[str, MethodValues]:
values.Qb = first_value_for_assignment(section, qb_name)
values.hb = first_value_for_assignment(section, "hb")
values.Q = records[q_index].value
if values.Q is None:
q_value = first_value_for_assignment(section, "q")
s1_value = first_value_for_assignment(section, "S1")
@ -304,13 +360,23 @@ def detect_target(nb_path: Path) -> Target:
name = nb_path.stem
lower = name.lower()
pair_candidates = re.findall(r"(?:^|_)(\d+-\d+)(?=_|$)", name)
pair = next((item for item in pair_candidates if item in PAIR_START_ROW), None)
if pair is None:
# В имени могут быть две пары чисел: например 174-66 (состав ядра)
# и 2-3 (нужный диапазон). Берём пару соседних чисел.
pair_candidates = []
for match in PAIR_RE.finditer(name):
left = int(match.group(1))
right = int(match.group(2))
if right == left + 1:
pair_candidates.append((left, right))
if not pair_candidates:
raise ValueError(
f"В имени {nb_path.name} не найден поддерживаемый блок 2-3 или 4-5."
f"В имени {nb_path.name} не найден диапазон соседних чисел вида 2-3."
)
pair_left, pair_right = pair_candidates[-1]
pair = f"{pair_left}-{pair_right}"
model: str | None = None
for candidate in MODEL_COLUMN_OFFSET:
if re.search(rf"(?:^|[_-]){candidate}(?:[_-]|$)", lower):
@ -321,11 +387,63 @@ def detect_target(nb_path: Path) -> Target:
f"В имени {nb_path.name} не найден тип расчёта osc, pr или gid."
)
qw_match = re.search(r"qw[_-]?([12])(?:[_-]|$)", lower)
qw_match = QW_RE.search(lower)
if qw_match is None:
raise ValueError(f"В имени {nb_path.name} не найдено Qw1 или Qw2.")
return Target(pair=pair, model=model, qw_side=int(qw_match.group(1)))
return Target(
pair=pair,
pair_left=pair_left,
pair_right=pair_right,
model=model,
qw_side=int(qw_match.group(1)),
)
def looks_like_data_notebook(path: Path) -> bool:
if path.suffix.lower() != ".nb":
return False
try:
detect_target(path)
except ValueError:
return False
return True
def pair_sort_key(pair: str) -> tuple[int, int]:
left, right = pair.split("-", maxsplit=1)
return int(left), int(right)
def data_start_row(block_index: int) -> int:
return TEMPLATE_BLOCK_TOP_ROW + block_index * BLOCK_HEIGHT + DATA_START_OFFSET
def round_decimal_places(value: float, places: int = 3) -> float:
"""Округляет число до заданного количества знаков методом ROUND_HALF_UP."""
if not math.isfinite(value):
return value
quantizer = Decimal(1).scaleb(-places)
try:
rounded = Decimal(str(value)).quantize(quantizer, rounding=ROUND_HALF_UP)
except (InvalidOperation, ValueError):
return value
return float(rounded)
def round_significant(value: float, digits: int = 3) -> float:
"""Округляет число до заданного количества значащих цифр."""
if value == 0 or not math.isfinite(value):
return value
decimal_value = Decimal(str(value))
quantizer = Decimal(1).scaleb(decimal_value.adjusted() - digits + 1)
try:
rounded = decimal_value.quantize(quantizer, rounding=ROUND_HALF_UP)
except (InvalidOperation, ValueError):
return value
return float(rounded)
def format_metadata(pair: str, metadata: dict[str, float | None]) -> str:
@ -334,38 +452,147 @@ def format_metadata(pair: str, metadata: dict[str, float | None]) -> str:
return ""
if float(value).is_integer():
return str(int(value))
return f"{value:.3g}"
return f"{round_decimal_places(value):.3f}".replace(".", ",")
def compact_decimal(value: float | None) -> str:
def three_decimals(value: float | None) -> str:
if value is None:
return ""
text = f"{value:.2f}"
if text.startswith("0."):
text = text[1:]
elif text.startswith("-0."):
text = "-" + text[2:]
return text
# b1 и b2 находятся внутри текстовой подписи, поэтому запятую
# задаём вручную, а завершающие нули сохраняем.
return f"{round_decimal_places(value):.3f}".replace(".", ",")
return (
f"{pair}\n"
f"Z1={integer_or_blank(metadata['Z1'])}\n"
f"Z2={integer_or_blank(metadata['Z2'])}\n"
f"d={integer_or_blank(metadata['d'])}\n"
f"b1={compact_decimal(metadata['b1'])}\n"
f"b2={compact_decimal(metadata['b2'])}"
f"b1={three_decimals(metadata['b1'])}\n"
f"b2={three_decimals(metadata['b2'])}"
)
def copy_template_block(worksheet, destination_top_row: int) -> None:
"""Копирует первый 14-строчный блок шаблона в новую позицию."""
from openpyxl.cell.cell import MergedCell
source_top = TEMPLATE_BLOCK_TOP_ROW
source_bottom = source_top + BLOCK_HEIGHT - 1
destination_bottom = destination_top_row + BLOCK_HEIGHT - 1
row_shift = destination_top_row - source_top
source_merges = [
(
merged.min_row,
merged.min_col,
merged.max_row,
merged.max_col,
)
for merged in worksheet.merged_cells.ranges
if merged.min_row >= source_top and merged.max_row <= source_bottom
]
# Удаляем старые объединения во всём целевом блоке, если они там были.
for merged in list(worksheet.merged_cells.ranges):
if not (
merged.max_row < destination_top_row
or merged.min_row > destination_bottom
):
worksheet.unmerge_cells(str(merged))
for row_offset in range(BLOCK_HEIGHT):
source_row = source_top + row_offset
destination_row = destination_top_row + row_offset
source_dimension = worksheet.row_dimensions[source_row]
destination_dimension = worksheet.row_dimensions[destination_row]
destination_dimension.height = source_dimension.height
destination_dimension.hidden = source_dimension.hidden
destination_dimension.outlineLevel = source_dimension.outlineLevel
for column in range(1, TEMPLATE_MAX_COLUMN + 1):
source_cell = worksheet.cell(row=source_row, column=column)
destination_cell = worksheet.cell(row=destination_row, column=column)
if isinstance(source_cell, MergedCell):
destination_cell.value = None
else:
destination_cell.value = source_cell.value
destination_cell.comment = copy(source_cell.comment)
if source_cell.hyperlink is not None:
destination_cell._hyperlink = copy(source_cell.hyperlink)
if getattr(source_cell, "has_style", False):
destination_cell._style = copy(source_cell._style)
destination_cell.number_format = source_cell.number_format
destination_cell.protection = copy(source_cell.protection)
destination_cell.alignment = copy(source_cell.alignment)
for min_row, min_col, max_row, max_col in source_merges:
worksheet.merge_cells(
start_row=min_row + row_shift,
start_column=min_col,
end_row=max_row + row_shift,
end_column=max_col,
)
def prepare_sheet_blocks(worksheet, pairs: list[str]) -> dict[str, int]:
"""Создаёт на одном листе одинаковые блоки для всех диапазонов."""
from openpyxl.cell.cell import MergedCell
if not pairs:
raise ValueError("Нет диапазонов для создания таблицы.")
# Первый блок уже является образцом. Все остальные копируются из него.
for block_index in range(1, len(pairs)):
destination_top = TEMPLATE_BLOCK_TOP_ROW + block_index * BLOCK_HEIGHT
copy_template_block(worksheet, destination_top)
# Удаляем лишние старые блоки шаблона, если диапазон только один.
required_last_row = TEMPLATE_BLOCK_TOP_ROW + len(pairs) * BLOCK_HEIGHT - 1
if worksheet.max_row > required_last_row:
for merged in list(worksheet.merged_cells.ranges):
if merged.min_row > required_last_row:
worksheet.unmerge_cells(str(merged))
worksheet.delete_rows(
required_last_row + 1,
worksheet.max_row - required_last_row,
)
empty_metadata = {"Z1": None, "Z2": None, "d": None, "b1": None, "b2": None}
starts: dict[str, int] = {}
for block_index, pair in enumerate(pairs):
start_row = data_start_row(block_index)
starts[pair] = start_row
# Очищаем старые числовые значения, сохраняя оформление и подписи строк.
for row in range(start_row, start_row + DATA_ROW_COUNT):
for column in list(range(3, 12)) + list(range(14, 23)):
cell = worksheet.cell(row=row, column=column)
if not isinstance(cell, MergedCell):
cell.value = None
# Подпись диапазона видна даже при отсутствии одного из файлов Qw.
worksheet.cell(row=start_row, column=1).value = format_metadata(
pair, empty_metadata
)
worksheet.cell(row=start_row, column=13).value = format_metadata(
pair, empty_metadata
)
return starts
def write_notebook_to_sheet(
worksheet,
start_row: int,
target: Target,
methods: dict[str, MethodValues],
metadata: dict[str, float | None],
written_cells: dict[str, str],
source_name: str,
) -> list[str]:
start_row = PAIR_START_ROW[target.pair]
# Qw1: подпись в A, данные с C. Qw2: подпись в M, данные с N.
if target.qw_side == 1:
metadata_column = 1
@ -394,74 +621,227 @@ def write_notebook_to_sheet(
coordinate = worksheet.cell(row=row, column=column).coordinate
previous_source = written_cells.get(coordinate)
if previous_source is not None and previous_source != source_name:
print(
f"Предупреждение: {coordinate} уже заполнялась из "
f"{previous_source}; новое значение берётся из {source_name}.",
file=sys.stderr,
raise ValueError(
f"Ячейка {coordinate} уже заполнялась из {previous_source}; "
f"повторная запись из {source_name}."
)
cell = worksheet.cell(row=row, column=column)
cell.value = value
cell.number_format = "0.000"
if value_name == "Q":
# Q округляется до трёх значащих цифр, а не до трёх
# фиксированных знаков после запятой. Формат без экспоненты:
# 0,0688; 0,0000787; 0,112 (разделитель зависит от Excel).
written_value = round_significant(value, digits=3)
cell.number_format = "0.###############"
else:
# Все остальные параметры сохраняются и показываются
# с точностью до трёх знаков после запятой.
written_value = round_decimal_places(value, places=3)
cell.number_format = "0.000"
cell.value = written_value
written_cells[coordinate] = source_name
report.append(f"{coordinate}={value:.12g}")
report.append(f"{coordinate}={written_value:.15g}")
return report
def expand_notebook_arguments(patterns: list[str]) -> list[Path]:
paths: list[Path] = []
"""Разворачивает файлы, glob-маски и каталоги; каталоги ищутся рекурсивно."""
candidates: list[Path] = []
def add_candidate(path: Path) -> None:
if path.is_dir():
candidates.extend(path.rglob("*.nb"))
elif path.is_file():
candidates.append(path)
for pattern in patterns:
matches = [Path(p) for p in glob.glob(pattern)]
if matches:
paths.extend(matches)
if glob.has_magic(pattern):
matches = [Path(item) for item in glob.glob(pattern, recursive=True)]
if not matches:
print(f"Предупреждение: маска ничего не нашла: {pattern}", file=sys.stderr)
for match in matches:
add_candidate(match)
else:
path = Path(pattern)
if path.exists():
paths.append(path)
else:
raise FileNotFoundError(f"Не найден notebook: {pattern}")
if not path.exists():
raise FileNotFoundError(f"Не найден путь: {pattern}")
add_candidate(path)
unique_paths: list[Path] = []
seen: set[Path] = set()
for path in paths:
skipped: list[Path] = []
for path in candidates:
resolved = path.resolve()
if resolved not in seen:
seen.add(resolved)
if resolved in seen:
continue
seen.add(resolved)
if looks_like_data_notebook(path):
unique_paths.append(path)
else:
skipped.append(path)
for path in sorted(skipped):
print(f"Пропущен служебный notebook: {path}", file=sys.stderr)
if not unique_paths:
raise FileNotFoundError(
"Не найдено ни одного файла вида *_osc_Qw1.nb, *_pr_Qw2.nb и т. п."
)
return unique_paths
def report_missing_files(parsed_items) -> None:
expected = {
(model, qw_side)
for model in MODEL_COLUMN_OFFSET
for qw_side in (1, 2)
}
by_pair: dict[str, set[tuple[str, int]]] = {}
for _, target, _, _ in parsed_items:
by_pair.setdefault(target.pair, set()).add((target.model, target.qw_side))
for pair in sorted(by_pair, key=pair_sort_key):
missing = expected - by_pair[pair]
if missing:
missing_text = ", ".join(
f"{model}_Qw{qw_side}"
for model, qw_side in sorted(
missing, key=lambda item: (item[1], MODEL_SORT_ORDER[item[0]])
)
)
print(
f"Предупреждение: для диапазона {pair} отсутствуют: {missing_text}",
file=sys.stderr,
)
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--version",
action="version",
version=f"%(prog)s {SCRIPT_VERSION}",
)
parser.add_argument("--template", required=True, type=Path, help="Excel-шаблон")
parser.add_argument(
"--notebooks",
required=True,
nargs="+",
help="Один или несколько .nb файлов; разрешены маски *.nb",
help=(
"Файлы, glob-маски или каталоги. Каталоги обходятся рекурсивно; "
"например: --notebooks ."
),
)
parser.add_argument("--output", required=True, type=Path, help="Итоговый .xlsx")
parser.add_argument("--sheet", default="Лист1", help="Имя листа шаблона")
parser.add_argument("--sheet", default="Лист1", help="Имя общего листа шаблона")
parser.add_argument(
"--dry-run",
action="store_true",
help="Только разобрать notebook и показать найденные значения",
help="Только разобрать notebook и показать найденные значения/позиции",
)
args = parser.parse_args()
print(
f"[{Path(__file__).name} {SCRIPT_VERSION}] "
"расширенная диагностика и новое округление включены",
file=sys.stderr,
)
notebook_paths = expand_notebook_arguments(args.notebooks)
parsed_items = []
seen_targets: dict[tuple[str, str, int], Path] = {}
notebook_errors: list[str] = []
for nb_path in notebook_paths:
records = parse_records(nb_path)
target = detect_target(nb_path)
methods = extract_methods(records)
metadata = extract_metadata(records)
target_key = (target.pair, target.model, target.qw_side)
if target_key in seen_targets:
raise ValueError(
f"Два файла претендуют на {target.pair}/{target.model}/Qw{target.qw_side}: "
f"{seen_targets[target_key]} и {nb_path}"
)
seen_targets[target_key] = nb_path
try:
records = parse_records(nb_path)
# Контекст файла добавляется централизованно в обработчике ниже,
# чтобы сообщение не дублировало строки «Файл» и «Позиция».
methods = extract_methods(records)
metadata = extract_metadata(records)
except (OSError, ValueError) as exc:
# Контекст добавляется здесь БЕЗУСЛОВНО, поэтому имя проблемного
# файла выводится для любой ошибки разбора.
try:
shown_path = nb_path.resolve()
except OSError:
shown_path = nb_path
notebook_errors.append(
"\n".join(
(
"=== ПРОБЛЕМНЫЙ NOTEBOOK ===",
f"Файл: {shown_path}",
(
f"Позиция: диапазон {target.pair}, "
f"{target.model}, Qw{target.qw_side}"
),
f"Причина: {exc}",
)
)
)
continue
parsed_items.append((nb_path, target, methods, metadata))
if notebook_errors:
print(
f"{nb_path.name}: pair={target.pair}, model={target.model}, "
f"Ошибка разбора notebook-файлов ({len(notebook_errors)}):",
file=sys.stderr,
)
for index, message in enumerate(notebook_errors, start=1):
indented = message.replace("\n", "\n ")
print(f" {index}. {indented}", file=sys.stderr)
print(
"Исправьте или пересчитайте перечисленные notebook-файлы "
"и запустите команду повторно.",
file=sys.stderr,
)
return 1
pairs = sorted({item[1].pair for item in parsed_items}, key=pair_sort_key)
pair_to_index = {pair: index for index, pair in enumerate(pairs)}
pair_to_start_row = {
pair: data_start_row(index) for pair, index in pair_to_index.items()
}
parsed_items.sort(
key=lambda item: (
item[1].pair_left,
item[1].pair_right,
item[1].qw_side,
MODEL_SORT_ORDER[item[1].model],
)
)
print("Диапазоны на одном листе:")
for pair in pairs:
block_index = pair_to_index[pair]
block_top = TEMPLATE_BLOCK_TOP_ROW + block_index * BLOCK_HEIGHT
print(
f" {pair}: блок строк {block_top}-{block_top + BLOCK_HEIGHT - 1}, "
f"значения с строки {pair_to_start_row[pair]}"
)
report_missing_files(parsed_items)
for nb_path, target, methods, _ in parsed_items:
print(
f"{nb_path}: pair={target.pair}, model={target.model}, "
f"Qw{target.qw_side}"
)
for method_name in ("heavy", "new", "light"):
@ -486,7 +866,7 @@ def main() -> int:
if not args.template.exists():
raise FileNotFoundError(f"Не найден шаблон: {args.template}")
# Открытие через бинарный поток позволяет использовать шаблон без расширения .xlsx.
# Открытие через бинарный поток позволяет использовать шаблон без .xlsx.
with args.template.open("rb") as template_file:
workbook = load_workbook(template_file)
@ -497,15 +877,18 @@ def main() -> int:
)
worksheet = workbook[args.sheet]
actual_starts = prepare_sheet_blocks(worksheet, pairs)
written_cells: dict[str, str] = {}
for nb_path, target, methods, metadata in parsed_items:
report = write_notebook_to_sheet(
worksheet=worksheet,
start_row=actual_starts[target.pair],
target=target,
methods=methods,
metadata=metadata,
written_cells=written_cells,
source_name=nb_path.name,
source_name=str(nb_path),
)
print(f" Записано {len(report)} ячеек: " + ", ".join(report))