Initial commit: планирование эксперимента и оптимизация смесей

This commit is contained in:
2026-07-23 15:12:32 +05:00
commit 025421193d
17 changed files with 1634 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Python
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.so
*.egg
*.egg-info/
dist/
build/
*.whl
# Виртуальное окружение
env/
venv/
.venv/
ENV/
env.bak/
venv.bak/
# Файлы проекта
project.json
*.csv
*.log
# Редакторы
.vscode/
.idea/
*.swp
*.swo
*~
.DS_Store
+32
View File
@@ -0,0 +1,32 @@
# Планирование эксперимента и оптимизация смесей
Программа для автоматизированного планирования эксперимента, построения регрессионных моделей, проверки значимости факторов и крутого восхождения (градиентного метода) для поиска оптимума в задачах со смесями.
## Основные возможности
- **Редактор состава**: создание и изменение ингредиентов смеси (проценты, разбавление, плотность).
- **Планирование эксперимента**: генерация различных планов:
- Полный факторный план `2^k`
- Центральный композиционный план (ЦКП) с возможностью дробного ядра
- Дробный факторный план `2^(k-p)`
- План Плакетта-Бермана
- План Бокса-Бенкена
- **Ввод откликов**: добавление, редактирование, переименование и удаление откликов.
- **Анализ значений**:
- Построение линейной регрессии (МНК)
- Проверка значимости коэффициентов по t-критерию с заданным уровнем α
- R², скорректированный R², F-критерий
- Дисперсия в центре и доверительный интервал
- Исключение незначимых факторов и пересчёт модели
- **Крутое восхождение**:
- Расчёт коэффициентов модели для выбранного отклика
- Генерация точек вдоль градиента (максимизация или минимизация)
- Возможность установить выбранную точку как новый состав
- **Экспорт данных**: все таблицы можно сохранить в CSV или скопировать в буфер обмена (TSV).
## Установка и запуск
1. Клонируйте репозиторий:
```bash
git clone <url>
cd experiment
+182
View File
@@ -0,0 +1,182 @@
# analysis.py
"""
Анализ экспериментальных данных: регрессия, проверка значимости, дисперсия в центре.
"""
import numpy as np
from core.table_io import handle_table_output
from core.statistics import (
compute_regression,
get_center_indices,
compute_center_stats,
chi2_confidence_interval,
get_t_critical,
regression_summary
)
def analysis_menu(mixture, design, responses):
if not design.plan or not responses:
print("Нет данных для анализа: план или отклики пусты.")
input("Нажмите Enter для продолжения...")
return
active_indices = design._active_indices()
k = len(active_indices)
if k < 2:
print("Для анализа необходимо минимум 2 активных фактора.")
input("Нажмите Enter для продолжения...")
return
factor_names = []
for idx in active_indices:
ing_index = idx + 1
if ing_index < len(mixture.ings):
factor_names.append(mixture.ings[ing_index].name)
else:
factor_names.append(f"x{idx+1}")
print("\nВведите уровень значимости alpha (например, 0.05, 0.01, 0.001):")
while True:
try:
alpha_input = input("alpha (по умолчанию 0.05): ").strip()
if alpha_input == "":
alpha = 0.05
else:
alpha = float(alpha_input)
if alpha <= 0 or alpha >= 1:
print("alpha должна быть в интервале (0, 1).")
continue
break
except ValueError:
print("Введите число.")
plan_matrix = np.array([[row[j] for j in active_indices] for row in design.plan])
N = plan_matrix.shape[0]
X = np.hstack([np.ones((N, 1)), plan_matrix])
df_res = N - (k + 1)
while True:
print("\n" + "=" * 50)
print("АНАЛИЗ ЗНАЧЕНИЙ")
print(f"Уровень значимости α = {alpha}")
print("Доступные отклики:")
for idx, resp in enumerate(responses, start=1):
print(f"{idx}. {resp['name']}")
print(f"{len(responses)+1}. Анализировать все отклики")
print("(Enter - выход в главное меню)")
print("=" * 50)
choice = input("Выберите отклик для анализа: ").strip()
if choice == "":
break
try:
idx = int(choice) - 1
if idx < 0:
print("Неверный номер.")
continue
if idx < len(responses):
analyze_single_response(responses[idx], X, k, df_res, design, alpha, factor_names)
input("Нажмите Enter для продолжения...")
elif idx == len(responses):
for resp in responses:
print(f"\n--- Анализ отклика '{resp['name']}' ---")
analyze_single_response(resp, X, k, df_res, design, alpha, factor_names)
input("Нажмите Enter для продолжения...")
else:
print("Неверный номер.")
except ValueError:
print("Введите число.")
def analyze_single_response(response, X, k, df_res, design, alpha, factor_names):
y = np.array(response["values"])
N = len(y)
if N != X.shape[0]:
print(f"Ошибка: число значений отклика ({N}) не соответствует числу опытов ({X.shape[0]})")
return
reg = compute_regression(X, y)
beta = reg["beta"]
se = reg["se"]
t_stats = reg["t_stats"]
s2_res = reg["s2_res"]
df_res_actual = reg["df_res"]
y_pred = reg["y_pred"]
if np.isnan(beta).any():
print("Ошибка при расчёте регрессии: матрица вырождена.")
return
def print_and_get_tcrit(beta, se, t_stats, df_res, y_pred, y, k, factor_names, alpha):
summary = regression_summary(y, y_pred, k, df_res)
r2 = summary["r2"]
r2_adj = summary["r2_adj"]
f_stat = summary["f_stat"]
f_pvalue = summary["f_pvalue"]
print("\n=== Регрессионный анализ ===")
print(f"Число опытов: {len(y)}")
print(f"Число факторов: {k}")
print(f"Степени свободы остатков: {df_res}")
print(f"Стандартная ошибка регрессии: {np.sqrt(s2_res) if not np.isnan(s2_res) else np.nan:.6f}")
print(f"Множественный R: {np.sqrt(r2) if not np.isnan(r2) else np.nan:.6f}")
print(f"R-квадрат: {r2:.6f}")
print(f"Скорректированный R-квадрат: {r2_adj:.6f}")
print(f"F-статистика: {f_stat:.4f}, p-value: {f_pvalue:.6f}")
t_crit = get_t_critical(df_res, alpha=alpha)
print("\nКоэффициенты регрессии:")
headers = ["Коэффициент", "Значение", "Станд. ошибка", "t-статистика", f"|t| > t_крит({alpha})?"]
rows = []
coef_names = ["Свободный член"] + factor_names
for i, name in enumerate(coef_names):
sig = "Да" if (not np.isnan(t_stats[i]) and abs(t_stats[i]) > t_crit and not np.isnan(t_crit)) else "Нет"
rows.append([
name,
f"{beta[i]:.6f}",
f"{se[i]:.6f}",
f"{t_stats[i]:.4f}",
sig
])
handle_table_output(headers, rows, title="", ask_each_time=True)
if not np.isnan(t_crit):
print(f"Критическое значение t (alpha={alpha}, df={df_res}): {t_crit:.4f}")
return t_crit
t_crit = print_and_get_tcrit(beta, se, t_stats, df_res_actual, y_pred, y, k, factor_names, alpha)
center_indices = get_center_indices(design.plan, design._active_indices())
if len(center_indices) >= 2:
y_center = y[center_indices]
center_mean, center_var = compute_center_stats(y_center)
print("\n=== Анализ центральных точек ===")
print(f"Количество центральных точек: {len(center_indices)}")
print(f"Среднее значение отклика в центре: {center_mean:.6f}")
print(f"Дисперсия в центре (несмещённая): {center_var:.6f}")
print(f"Стандартное отклонение: {np.sqrt(center_var):.6f}")
lower, upper = chi2_confidence_interval(center_var, len(center_indices), alpha=alpha)
if not np.isnan(lower):
print(f"Доверительный интервал для дисперсии (уровень доверия {1-alpha}): [{lower:.6f}, {upper:.6f}]")
else:
print("\nНедостаточно центральных точек (нужно минимум 2).")
if k > 0 and not np.isnan(t_crit):
print("\nХотите исключить незначимые факторы и пересчитать модель?")
choice = input("(y/n, по умолчанию n): ").strip().lower()
if choice == 'y':
significant = [True] + [abs(t_stats[i]) > t_crit for i in range(1, k+1)]
if all(significant):
print("Все факторы значимы. Упрощённая модель не отличается от полной.")
return
X_new = X[:, significant]
reg_new = compute_regression(X_new, y)
beta_new = reg_new["beta"]
se_new = reg_new["se"]
t_stats_new = reg_new["t_stats"]
df_res_new = reg_new["df_res"]
y_pred_new = reg_new["y_pred"]
if np.isnan(beta_new).any():
print("Ошибка при пересчёте упрощённой модели.")
return
factor_names_new = ["Свободный член"] + [factor_names[i-1] for i in range(1, k+1) if significant[i]]
k_new = len(factor_names_new) - 1
print("\n=== Упрощённая модель (исключены незначимые факторы) ===")
print_and_get_tcrit(beta_new, se_new, t_stats_new, df_res_new, y_pred_new, y, k_new, factor_names_new[1:], alpha)
print("\nСовет: для использования упрощённой модели в крутом восхождении установите шаги для исключённых факторов равными 0 в планировании эксперимента.")
+135
View File
@@ -0,0 +1,135 @@
# ascent.py
"""
Крутое восхождение (градиентный метод) для поиска оптимума.
"""
import numpy as np
from core.table_io import handle_table_output
from core.statistics import compute_regression, compute_gradient_points
def ascent_menu(mixture, design, responses):
if not design.plan or not responses:
print("Нет данных для крутого восхождения: план или отклики пусты.")
input("Нажмите Enter для продолжения...")
return
active_indices = design._active_indices()
k = len(active_indices)
if k < 2:
print("Для крутого восхождения необходимо минимум 2 активных фактора.")
input("Нажмите Enter для продолжения...")
return
if not mixture.ings or len(mixture.ings) < 2:
print("В смеси нет изменяемых ингредиентов.")
return
base_levels = []
factor_names = []
for idx in active_indices:
ing_index = idx + 1
if ing_index >= len(mixture.ings):
print(f"Ошибка: индекс ингредиента {ing_index} выходит за пределы списка.")
return
factor_names.append(mixture.ings[ing_index].name)
base_levels.append(mixture.ings[ing_index].value)
print(f"\nБазовые уровни факторов: {dict(zip(factor_names, base_levels))}")
print("\nДоступные отклики:")
for i, resp in enumerate(responses, start=1):
print(f"{i}. {resp['name']}")
try:
resp_idx = int(input("Выберите номер отклика для оптимизации: ")) - 1
if resp_idx < 0 or resp_idx >= len(responses):
print("Неверный номер.")
return
response = responses[resp_idx]
except ValueError:
print("Введите число.")
return
plan_matrix = np.array([[row[j] for j in active_indices] for row in design.plan])
N = plan_matrix.shape[0]
X = np.hstack([np.ones((N, 1)), plan_matrix])
y = np.array(response["values"])
if len(y) != N:
print("Ошибка: число значений отклика не совпадает с числом опытов.")
return
reg = compute_regression(X, y)
beta = reg["beta"]
if np.isnan(beta).any():
print("Ошибка расчёта коэффициентов регрессии.")
return
print("\n=== Коэффициенты модели (МНК) ===")
headers = ["Фактор", "Коэффициент"]
rows = [["Свободный член", f"{beta[0]:.6f}"]]
for i, name in enumerate(factor_names):
rows.append([name, f"{beta[i+1]:.6f}"])
handle_table_output(headers, rows, title="", ask_each_time=True)
print("\nВведите шаг движения (в % для первого фактора).")
print("Шаг для остальных факторов будет пропорционален их коэффициентам.")
try:
step = float(input("Шаг (положительное число): "))
if step <= 0:
print("Шаг должен быть положительным.")
return
except ValueError:
print("Введите число.")
return
try:
num_steps = int(input("Количество шагов (например, 5): "))
if num_steps <= 0:
print("Количество шагов должно быть положительным.")
return
except ValueError:
print("Введите целое число.")
return
direction = input("Максимизировать отклик? (y/n, по умолчанию y): ").strip().lower()
maximize = direction != 'n'
try:
points = compute_gradient_points(
beta, base_levels, step, num_steps,
factor_names=factor_names,
maximize=maximize
)
except Exception as e:
print(f"Ошибка: {e}")
return
headers = ["Шаг"] + factor_names + ["Предсказанный отклик"]
rows = []
for pt in points:
row = [str(pt["Шаг"])] + [f"{pt[name]:.4f}" for name in factor_names] + [f"{pt['Предсказанный отклик']:.4f}"]
rows.append(row)
handle_table_output(
headers, rows,
title=f"Точки крутого восхождения (направление: {'максимизация' if maximize else 'минимизация'})"
)
print("\nВы можете выбрать одну из точек и установить её как новый состав.")
choice = input("Установить состав из точки? (введите номер шага или Enter для пропуска): ").strip()
if choice:
try:
step_num = int(choice) - 1
if 0 <= step_num < len(points):
pt = points[step_num]
for name in factor_names:
for idx, info in enumerate(mixture.ings):
if info.name == name:
mixture.ings[idx].value = pt[name]
break
mixture.comp_solvent_percent()
print("Состав обновлён. Текущие проценты:")
for info in mixture.ings:
print(f" {info.name}: {info.value:.2f}%")
else:
print("Неверный номер.")
except ValueError:
print("Ошибка ввода.")
input("Нажмите Enter для продолжения...")
View File
+183
View File
@@ -0,0 +1,183 @@
# core/classes.py
"""
Модуль содержит классы для представления ингредиентов и смеси.
"""
from dataclasses import dataclass
class Ingredient:
"""Представляет ингредиент с именем, единицей измерения и значением."""
def __init__(self, name: str = "Water", unit: str = "ml", value: float = 1.0):
self.name = name
self.unit = unit
self.value = value
def to_dict(self):
return {"name": self.name, "unit": self.unit, "value": self.value}
@staticmethod
def from_dict(data):
return Ingredient(data["name"], data["unit"], data["value"])
def __repr__(self):
return f"{self.name}: {self.value} {self.unit}"
@dataclass
class IngredientInfo:
"""
Обёртка для ингредиента в составе смеси, добавляет разбавление и плотность.
"""
ingredient: Ingredient
dilution: float = 1.0
density: float = 1.0
def to_dict(self):
return {
"ingredient": self.ingredient.to_dict(),
"dilution": self.dilution,
"density": self.density
}
@staticmethod
def from_dict(data):
ing = Ingredient.from_dict(data["ingredient"])
return IngredientInfo(
ingredient=ing,
dilution=data.get("dilution", 1.0),
density=data.get("density", 1.0)
)
@property
def name(self):
return self.ingredient.name
@property
def unit(self):
return self.ingredient.unit
@property
def value(self):
return self.ingredient.value
@value.setter
def value(self, new_value):
self.ingredient.value = new_value
class Mixture:
"""
Основной класс для работы со смесью.
Первый ингредиент считается растворителем (его процент пересчитывается автоматически).
"""
def __init__(self, solvent=None, ings=None, total_amount=1000.0, total_unit="ml"):
all_ings = []
if solvent is None:
water = Ingredient(name="Water", unit=total_unit, value=100.0)
solvent_info = IngredientInfo(ingredient=water)
elif isinstance(solvent, Ingredient):
solvent_info = IngredientInfo(ingredient=solvent)
elif isinstance(solvent, IngredientInfo):
solvent_info = solvent
else:
raise TypeError("solvent must be Ingredient or IngredientInfo")
all_ings.append(solvent_info)
if ings is not None:
if not isinstance(ings, list):
raise TypeError("ings must be a list")
for item in ings:
if isinstance(item, Ingredient):
all_ings.append(IngredientInfo(ingredient=item))
elif isinstance(item, IngredientInfo):
all_ings.append(item)
else:
raise TypeError("Each item in ings must be Ingredient or IngredientInfo")
self.ings = all_ings
self.total_amount = total_amount
self.total_unit = total_unit
self.comp_solvent_percent()
def comp_solvent_percent(self) -> float:
"""Пересчёт процента растворителя = 100% - сумма остальных."""
if not self.ings:
return 0.0
total_others = sum(info.value for info in self.ings[1:])
if total_others > 100.0:
raise ValueError("Sum of all ingredient percentages exceeds 100%")
self.ings[0].value = 100.0 - total_others
return self.ings[0].value
def add_ingredient(self, ingredient: Ingredient, dilution: float = 1.0, density: float = 1.0):
info = IngredientInfo(ingredient, dilution, density)
self.ings.append(info)
self.comp_solvent_percent()
def remove_ingredient(self, index: int):
if index == 0:
raise ValueError("Нельзя удалить растворитель (первый ингредиент).")
removed = self.ings.pop(index)
self.comp_solvent_percent()
return removed
def get_absolute_ingredients(self):
"""Возвращает абсолютные значения ингредиентов (с учётом плотности и разбавления)."""
abs_list = []
sum_rest = 0.0
for info in self.ings[1:]:
abs_value = (info.value / 100.0) * self.total_amount * info.dilution * info.density
abs_list.append(Ingredient(name=info.name, unit=info.unit, value=abs_value))
sum_rest += abs_value
if sum_rest > self.total_amount:
raise ValueError("Сумма ингредиентов (кроме растворителя) превышает общее количество")
solvent_abs = self.total_amount - sum_rest
solvent = Ingredient(name=self.ings[0].name, unit=self.ings[0].unit, value=solvent_abs)
abs_list.insert(0, solvent)
return abs_list
def get_ingredient_info(self, index: int):
return self.ings[index]
def set_total_amount(self, new_amount: float, new_unit: str):
if new_amount <= 0:
raise ValueError("Общее количество должно быть положительным.")
if not new_unit.strip():
raise ValueError("Единица измерения не может быть пустой.")
self.total_amount = new_amount
self.total_unit = new_unit.strip()
def set_composition(self, *percentages):
expected = len(self.ings) - 1
if len(percentages) != expected:
raise ValueError(f"Expected {expected} percentages, got {len(percentages)}")
total = sum(percentages)
if total > 100.0:
raise ValueError(f"Sum of percentages ({total:.2f}) exceeds 100")
for i, pct in enumerate(percentages, start=1):
if pct < 0:
raise ValueError(f"Percentage for {self.ings[i].name} cannot be negative")
self.ings[i].value = pct
self.comp_solvent_percent()
def to_dict(self):
return {
"ings": [info.to_dict() for info in self.ings],
"total_amount": self.total_amount,
"total_unit": self.total_unit
}
@staticmethod
def from_dict(data):
ings = [IngredientInfo.from_dict(item) for item in data["ings"]]
solvent = ings[0] if ings else None
rest = ings[1:] if len(ings) > 1 else None
return Mixture(
solvent=solvent,
ings=rest,
total_amount=data["total_amount"],
total_unit=data["total_unit"]
)
def __repr__(self):
parts = []
for info in self.ings:
parts.append(f"{info.name} {info.value:.1f}% (разб.{info.dilution:.2f}, плотн.{info.density:.2f})")
return f"Mixture({', '.join(parts)}, total={self.total_amount} {self.total_unit})"
+137
View File
@@ -0,0 +1,137 @@
# core/doe.py
"""
Модуль с функциями для генерации планов эксперимента.
"""
import random
import numpy as np
def ispoz(N):
"""Проверяет, является ли N положительным целым числом (для использования в ze)."""
return isinstance(N, (int, float)) and not isinstance(N, bool) and N > 0
def ze(k, n):
"""Генерирует матрицу размера n x k, заполненную нулями (центральные точки)."""
if n is None or k is None:
return []
elif ispoz(k) and ispoz(n):
return [[0] * k for _ in range(n)]
return []
def ffe(k):
"""Полный факторный план 2^k. Возвращает список списков размером 2^k x k со значениями ±1."""
if k < 2:
raise ValueError("Factor must be at least 2")
N = 2 ** k
out = []
for i in range(N):
row = []
for j in range(k):
bit = ((i >> j) & 1) * 2 - 1
row.append(bit)
out.append(row)
return out
def ccp(k, alpha=1.0, center_points=1, fractional=None):
"""
Центральный композиционный план (ЦКП) для k факторов.
alpha – звёздное плечо.
center_points – количество центральных точек.
fractional – степень дробления ядра (если None, используется полный факторный план).
"""
if k < 2:
raise ValueError("Для ЦКП необходимо минимум 2 фактора.")
if fractional is not None:
if fractional <= 0 or fractional >= k:
raise ValueError("Степень дробления p должна быть от 1 до k-1")
base_k = k - fractional
if base_k < 2:
raise ValueError(f"Ядро дробного факторного плана должно иметь минимум 2 фактора, а получается {base_k}.")
if fractional is not None:
from core.doe import fractional_factorial
core_plan = fractional_factorial(k, fractional)
else:
core_plan = ffe(k)
star_points = []
for i in range(k):
row = [0.0] * k
row[i] = alpha
star_points.append(row)
row_neg = [0.0] * k
row_neg[i] = -alpha
star_points.append(row_neg)
center_rows = [[0.0] * k for _ in range(center_points)]
plan = core_plan + star_points + center_rows
return plan
def fractional_factorial(k, p=1):
"""Дробный факторный план 2^(k-p). p – степень дробления."""
if k < 2 or p <= 0 or p >= k:
raise ValueError("Некорректные параметры: k>=2, 0<p<k")
base_k = k - p
if base_k < 2:
raise ValueError(f"Ядро должно содержать минимум 2 фактора, а получается {base_k}.")
N = 2 ** base_k
base_plan = ffe(base_k)
plan = []
for row in base_plan:
new_row = row.copy()
for i in range(p):
prod = 1
for j in range(base_k):
prod *= row[j]
new_row.append(prod)
plan.append(new_row)
return plan
def plackett_burman(k):
"""
План Плакетта-Бермана для k факторов.
Число опытов N – ближайшее кратное 4, >= k+1.
Для N = 8, 16, 32 используется рекурсивное построение матрицы Адамара.
"""
if k < 2:
raise ValueError("Требуется минимум 2 фактора.")
N = 4 * ((k + 3) // 4)
def hadamard_rec(n):
if n == 1:
return np.array([[1]])
H = hadamard_rec(n//2)
top = np.hstack([H, H])
bottom = np.hstack([H, -H])
return np.vstack([top, bottom])
if (N & (N - 1)) == 0: # N — степень двойки
H = hadamard_rec(N)
else:
try:
from scipy.linalg import hadamard
H = hadamard(N)
except ImportError:
print("Предупреждение: scipy не установлена. Генерируем случайный план.")
plan = []
for _ in range(N):
row = [random.choice([-1, 1]) for _ in range(k)]
plan.append(row)
return plan
plan = H[:, 1:k+1].tolist()
return plan
def box_behnken(k):
"""
План Бокса-Бенкена для k факторов (k >= 3).
Содержит комбинации на серединах рёбер и центральные точки.
"""
if k < 3:
raise ValueError("Требуется минимум 3 фактора.")
plan = []
for i in range(k):
for j in range(i+1, k):
for a, b in [(1,1), (1,-1), (-1,1), (-1,-1)]:
row = [0.0] * k
row[i] = a
row[j] = b
plan.append(row)
for _ in range(3):
plan.append([0.0] * k)
return plan
+99
View File
@@ -0,0 +1,99 @@
# core/experiment.py
"""
Класс ExperimentDesign для хранения шагов и матрицы плана,
а также генерации экспериментальных смесей.
"""
from core.classes import Mixture, Ingredient, IngredientInfo
class ExperimentDesign:
def __init__(self, steps=None, plan=None):
self.steps = steps if steps is not None else []
self.plan = plan if plan is not None else []
def set_steps(self, steps):
"""
Устанавливает шаги для всех факторов (ингредиентов кроме растворителя).
Каждый шаг должен быть >= 0. Нулевые шаги означают, что данный ингредиент не является фактором.
"""
for s in steps:
if s < 0:
raise ValueError("Step cannot be negative")
self.steps = list(steps)
def _active_indices(self):
"""Возвращает список индексов (в self.steps) с шагом > 0."""
return [i for i, s in enumerate(self.steps) if s > 0]
def _active_steps(self):
"""Возвращает список шагов > 0."""
return [s for s in self.steps if s > 0]
def generate_plan(self, k=None):
"""
Генерирует полный факторный план 2^k, где k - число активных факторов (шагов > 0).
Если k не указано, вычисляется автоматически.
Если активных факторов < 2, выбрасывает ValueError.
"""
active = self._active_steps()
if k is None:
k = len(active)
if k < 2:
raise ValueError(f"Need at least 2 active factors (steps > 0), but got {k}")
from core.doe import ffe
self.plan = ffe(k)
def get_experiment_mixture(self, mixture, exp_index):
"""
Возвращает Mixture для опыта с индексом exp_index (0-based).
Изменяются только те ингредиенты, у которых шаг > 0.
"""
if not self.plan or not self.steps:
raise ValueError("Plan or steps not set")
if exp_index >= len(self.plan):
raise IndexError("Experiment index out of range")
active_indices = self._active_indices()
active_steps = self._active_steps()
if len(active_indices) != len(self.plan[0]):
raise ValueError("Number of active steps does not match plan columns")
base_percents = [info.value for info in mixture.ings]
new_percents = base_percents.copy()
for col, idx_in_steps in enumerate(active_indices):
factor = self.plan[exp_index][col]
ingredient_idx = idx_in_steps + 1
delta = active_steps[col] * factor
new_percents[ingredient_idx] = base_percents[ingredient_idx] + delta
new_ings = []
for i, info in enumerate(mixture.ings):
new_ings.append(
IngredientInfo(
ingredient=Ingredient(name=info.name, unit=info.unit, value=new_percents[i]),
dilution=info.dilution,
density=info.density
)
)
temp_mixture = Mixture(
solvent=new_ings[0],
ings=new_ings[1:],
total_amount=mixture.total_amount,
total_unit=mixture.total_unit
)
return temp_mixture
def get_all_mixtures(self, mixture):
"""Возвращает список Mixture для всех опытов."""
return [self.get_experiment_mixture(mixture, i) for i in range(len(self.plan))]
def to_dict(self):
return {
"steps": self.steps,
"plan": self.plan
}
@classmethod
def from_dict(cls, data):
return cls(steps=data.get("steps", []), plan=data.get("plan", []))
+47
View File
@@ -0,0 +1,47 @@
# core/fs.py
"""
Модуль для работы с файлами проекта (сохранение/загрузка в JSON).
"""
import json
from core.classes import Mixture
from core.experiment import ExperimentDesign
PROJECT_FILENAME = "project.json"
def load_project(filename: str = PROJECT_FILENAME):
"""
Загружает проект из JSON-файла.
Возвращает кортеж (mixture, design, responses).
Если файл отсутствует или повреждён, возвращает новые объекты по умолчанию.
"""
try:
with open(filename, "r", encoding="utf-8") as f:
data = json.load(f)
mixture = Mixture.from_dict(data.get("mixture", {})) if "mixture" in data else Mixture()
design = ExperimentDesign.from_dict(data.get("design", {})) if "design" in data else ExperimentDesign()
responses = data.get("responses", [])
if isinstance(responses, list) and responses and isinstance(responses[0], (int, float)):
responses = [{"name": "Отклик 1", "values": responses}]
return mixture, design, responses
except (FileNotFoundError, json.JSONDecodeError, KeyError):
return Mixture(), ExperimentDesign(), []
def save_project_partial(filename: str = PROJECT_FILENAME, mixture=None, design=None, responses=None):
"""
Сохраняет только переданные части проекта, остальные загружаются из существующего файла.
Используется для точечного сохранения (например, только смеси).
"""
cur_mixture, cur_design, cur_responses = load_project(filename)
if mixture is not None:
cur_mixture = mixture
if design is not None:
cur_design = design
if responses is not None:
cur_responses = responses
data = {
"mixture": cur_mixture.to_dict(),
"design": cur_design.to_dict(),
"responses": cur_responses
}
with open(filename, "w", encoding="utf-8") as f:
json.dump(data, f, ensure_ascii=False, indent=4)
+130
View File
@@ -0,0 +1,130 @@
# core/statistics.py
"""
Статистические функции для регрессии, проверки значимости, дисперсии и градиента.
"""
import numpy as np
try:
from scipy import stats
SCIPY_AVAILABLE = True
except ImportError:
SCIPY_AVAILABLE = False
def compute_regression(X, y):
"""МНК-регрессия, возвращает коэффициенты, ошибки, t-статистики."""
N, p = X.shape
try:
XTX = X.T @ X
XTX_inv = np.linalg.inv(XTX)
beta = XTX_inv @ (X.T @ y)
y_pred = X @ beta
residuals = y - y_pred
df_res = N - p
if df_res > 0:
s2_res = np.sum(residuals**2) / df_res
se = np.sqrt(np.diag(XTX_inv) * s2_res)
else:
s2_res = np.nan
se = [np.nan] * p
t_stats = beta / se if not np.isnan(se).any() else [np.nan] * p
except np.linalg.LinAlgError:
beta = [np.nan] * p
se = [np.nan] * p
t_stats = [np.nan] * p
df_res = 0
s2_res = np.nan
residuals = np.full(N, np.nan)
y_pred = np.full(N, np.nan)
XTX_inv = None
return {
"beta": beta,
"se": se,
"t_stats": t_stats,
"df_res": df_res,
"s2_res": s2_res,
"residuals": residuals,
"y_pred": y_pred,
"XTX_inv": XTX_inv
}
def regression_summary(y, y_pred, k, df_res):
"""Вычисляет R², скорректированный R², F-статистику и p-value."""
n = len(y)
ss_total = np.sum((y - np.mean(y))**2)
ss_res = np.sum((y - y_pred)**2)
r2 = 1 - ss_res / ss_total if ss_total > 0 else np.nan
if df_res > 0:
r2_adj = 1 - (1 - r2) * (n - 1) / df_res
else:
r2_adj = np.nan
if df_res > 0 and k > 0:
f_stat = (r2 / k) / ((1 - r2) / df_res) if r2 < 1 else np.inf
else:
f_stat = np.nan
if SCIPY_AVAILABLE and not np.isnan(f_stat) and f_stat != np.inf:
p_value = 1 - stats.f.cdf(f_stat, k, df_res)
else:
p_value = np.nan
return {"r2": r2, "r2_adj": r2_adj, "f_stat": f_stat, "f_pvalue": p_value}
def get_center_indices(plan, active_indices):
"""Возвращает индексы строк плана, где активные факторы равны 0."""
if not plan:
return []
plan_arr = np.array(plan)
if active_indices:
active_cols = plan_arr[:, active_indices]
center_mask = np.all(active_cols == 0, axis=1)
return np.where(center_mask)[0].tolist()
return []
def compute_center_stats(y_center):
"""Среднее и несмещённая дисперсия для центральных точек."""
if len(y_center) < 2:
return None, None
return np.mean(y_center), np.var(y_center, ddof=1)
def chi2_confidence_interval(var, n, alpha=0.05):
"""Доверительный интервал для дисперсии на основе χ²-распределения."""
if not SCIPY_AVAILABLE or n < 2:
return np.nan, np.nan
df = n - 1
chi2_lower = stats.chi2.ppf(alpha / 2, df)
chi2_upper = stats.chi2.ppf(1 - alpha / 2, df)
return df * var / chi2_upper, df * var / chi2_lower
def get_t_critical(df, alpha=0.05):
"""Критическое значение t (двустороннее)."""
if not SCIPY_AVAILABLE or df <= 0:
return np.nan
return stats.t.ppf(1 - alpha/2, df)
def compute_gradient_points(beta, base_levels, step_factor, num_steps, factor_names=None, maximize=True):
"""
Генерирует точки вдоль градиента (крутое восхождение или спуск).
beta – коэффициенты модели: [b0, b1, b2, ...]
base_levels – список базовых уровней для каждого фактора
step_factor – шаг по первому фактору
num_steps – количество шагов
factor_names – список названий факторов
maximize – True для максимизации, False для минимизации
"""
if len(beta) - 1 != len(base_levels):
raise ValueError("Число коэффициентов (без b0) должно совпадать с числом факторов")
direction = 1 if maximize else -1
coefs = np.array(beta[1:]) * direction
max_abs = np.max(np.abs(coefs))
if max_abs == 0:
raise ValueError("Все коэффициенты равны нулю, направление не определено.")
norm_coefs = coefs / max_abs
steps = step_factor * norm_coefs
points = []
for step_num in range(1, num_steps + 1):
point = {}
for i, name in enumerate(factor_names or [f"x{i+1}" for i in range(len(base_levels))]):
point[name] = base_levels[i] + steps[i] * step_num
predicted = beta[0] + np.sum(beta[1:] * np.array([point[name] for name in factor_names or range(len(base_levels))]))
point["Шаг"] = step_num
point["Предсказанный отклик"] = predicted
points.append(point)
return points
+87
View File
@@ -0,0 +1,87 @@
# core/table_io.py
"""
Модуль для работы с таблицами: вывод, сохранение в CSV, копирование в буфер обмена.
"""
import os
import csv
from table import print_table
try:
import pyperclip
HAS_PYPERCLIP = True
except ImportError:
HAS_PYPERCLIP = False
def handle_table_output(headers, rows, title="Таблица", ask_each_time=True):
"""
Выводит таблицу, затем предлагает сохранить в CSV или скопировать в буфер обмена.
headers – список заголовков
rows – список строк
title – заголовок
ask_each_time если False, только выводит таблицу без дополнительных вопросов.
"""
if title:
print(f"\n=== {title} ===")
print_table(headers, rows)
if not ask_each_time:
return
while True:
print("\nДействия с таблицей:")
print(" 1. Сохранить в CSV")
if HAS_PYPERCLIP:
print(" 2. Скопировать в буфер обмена")
else:
print(" 2. Скопировать в буфер обмена (недоступно, установите pyperclip)")
print(" Enter - пропустить")
choice = input("Выберите действие: ").strip()
if choice == "":
break
if choice == "1":
save_to_csv(headers, rows)
break
elif choice == "2":
if HAS_PYPERCLIP:
copy_to_clipboard(headers, rows)
break
else:
print("pyperclip не установлен. Установите командой: pip install pyperclip")
continue
else:
print("Неверный выбор. Введите 1, 2 или Enter.")
def save_to_csv(headers, rows, default_filename="table.csv"):
"""Сохраняет таблицу в CSV-файл."""
filename = input(f"Имя файла (по умолчанию {default_filename}): ").strip()
if not filename:
filename = default_filename
if not filename.lower().endswith('.csv'):
filename += '.csv'
try:
with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
writer = csv.writer(f, delimiter=';')
writer.writerow(headers)
writer.writerows(rows)
print(f"Таблица сохранена в {os.path.abspath(filename)}")
except Exception as e:
print(f"Ошибка при сохранении: {e}")
def copy_to_clipboard(headers, rows):
"""Копирует таблицу в буфер обмена в формате TSV."""
if not HAS_PYPERCLIP:
print("pyperclip не установлен. Установите командой: pip install pyperclip")
return
lines = []
lines.append("\t".join(str(h) for h in headers))
for row in rows:
lines.append("\t".join(str(cell) for cell in row))
text = "\n".join(lines)
try:
pyperclip.copy(text)
print("Таблица скопирована в буфер обмена (TSV-формат).")
print("Вставьте её в Excel или другой редактор.")
except Exception as e:
print(f"Ошибка копирования в буфер обмена: {e}")
+121
View File
@@ -0,0 +1,121 @@
# editor.py
"""
Редактор состава смеси.
Позволяет добавлять, изменять ингредиенты и сохранять состав в проект.
"""
from core.classes import Ingredient
from core.fs import save_project_partial
from core.table_io import handle_table_output
def safe_float(prompt, default=None):
"""Безопасный ввод числа с возможностью значения по умолчанию."""
while True:
val = input(prompt)
if val.strip() == "" and default is not None:
return default
try:
return float(val)
except ValueError:
print("Ошибка! Введите число.")
def display_ingredients(mixture):
"""Выводит список ингредиентов в виде таблицы."""
if not mixture.ings:
print("Список ингредиентов пуст.")
return
headers = ["", "Название", "%", "Разбавление", "Плотность"]
rows = []
for i, info in enumerate(mixture.ings, start=1):
rows.append([
str(i),
info.name + (" (растворитель)" if i == 1 else ""),
f"{info.value:.1f}",
f"{info.dilution:.2f}",
f"{info.density:.2f}"
])
handle_table_output(headers, rows, title="Список ингредиентов")
def editor_menu(mixture):
"""
Подменю редактора состава.
Изменяет объект mixture напрямую.
"""
while True:
print("\n" + "=" * 50)
print("РЕДАКТОР СОСТАВА")
print("1. Добавить ингредиент")
print("2. Показать все ингредиенты")
print("3. Изменить количество ингредиента (%)")
print("4. Изменить разбавление и плотность")
print("5. Сохранить состав в проект")
print("(Enter - выход в главное меню)")
print("=" * 50)
choice = input("Выберите пункт: ").strip()
if choice == "":
break
if choice == "1":
name = input("Название: ")
unit = input("Единица: ")
value = safe_float("Количество (%): ")
if value < 0:
print("Процент не может быть отрицательным!")
continue
dilution = safe_float("Разбавление (по умолчанию 1): ", default=1.0)
density = safe_float("Плотность (по умолчанию 1): ", default=1.0)
ing = Ingredient(name, unit, value)
mixture.add_ingredient(ing, dilution, density)
print(f"Добавлено: {ing.name} = {ing.value:.1f}%")
elif choice == "2":
display_ingredients(mixture)
elif choice == "3":
if len(mixture.ings) <= 1:
print("Нельзя изменить единственный ингредиент (растворитель) напрямую.")
continue
display_ingredients(mixture)
try:
idx = int(input("Номер ингредиента: ")) - 1
if idx == 0:
print("Нельзя менять растворитель напрямую.")
continue
if idx < 0 or idx >= len(mixture.ings):
print("Неверный номер!")
continue
new_val = safe_float(f"Новое количество для {mixture.ings[idx].name} (%): ")
if new_val < 0:
print("Процент не может быть отрицательным!")
continue
mixture.ings[idx].value = new_val
mixture.comp_solvent_percent()
print(f"Обновлено! Растворитель теперь: {mixture.ings[0].value:.1f}%")
except ValueError:
print("Ошибка!")
elif choice == "4":
if not mixture.ings:
print("Список пуст.")
continue
display_ingredients(mixture)
try:
idx = int(input("Номер ингредиента: ")) - 1
if idx < 0 or idx >= len(mixture.ings):
print("Неверный номер!")
continue
info = mixture.ings[idx]
new_dil = safe_float(f"Новое разбавление для {info.name} (было {info.dilution:.2f}): ", default=info.dilution)
new_den = safe_float(f"Новая плотность для {info.name} (было {info.density:.2f}): ", default=info.density)
info.dilution = new_dil
info.density = new_den
print("Параметры обновлены!")
except ValueError:
print("Ошибка!")
elif choice == "5":
save_project_partial(mixture=mixture)
print("Состав сохранён в проект.")
else:
print("Неверный пункт.")
+47
View File
@@ -0,0 +1,47 @@
# main.py
"""
Главное меню программы.
Загружает проект при старте и предоставляет доступ ко всем подменю.
"""
from core.fs import load_project
from editor import editor_menu
from plan import plan_menu
from response import response_menu
from analysis import analysis_menu
from ascent import ascent_menu
PROJECT_FILE = "project.json"
def main():
mixture, design, responses = load_project(PROJECT_FILE)
while True:
print("\n" + "=" * 50)
print("ГЛАВНОЕ МЕНЮ")
print("1. Редактор состава")
print("2. Планирование эксперимента")
print("3. Ввод значений отклика")
print("4. Анализ значений")
print("5. Крутое восхождение")
print("6. Выход")
print("=" * 50)
choice = input("Выберите пункт: ").strip()
if choice == "6":
print("До свидания! (данные не сохранены автоматически)")
break
elif choice == "1":
editor_menu(mixture)
elif choice == "2":
design = plan_menu(mixture, design)
elif choice == "3":
responses = response_menu(design, responses)
elif choice == "4":
analysis_menu(mixture, design, responses)
elif choice == "5":
ascent_menu(mixture, design, responses)
else:
print("Неверный пункт.")
if __name__ == "__main__":
main()
+215
View File
@@ -0,0 +1,215 @@
# plan.py
"""
Планирование эксперимента.
Позволяет задать шаги для факторов, сгенерировать план различных типов,
показать матрицу плана, экспериментальные смеси и сохранить дизайн в проект.
"""
from core.experiment import ExperimentDesign
from core.fs import save_project_partial
from core.doe import ze, ccp, fractional_factorial, plackett_burman, box_behnken
from core.table_io import handle_table_output
def plan_menu(mixture, design):
if design is None:
design = ExperimentDesign()
while True:
print("\n" + "=" * 50)
print("ПЛАНИРОВАНИЕ ЭКСПЕРИМЕНТА")
print("1. Задать шаги для факторов (ингредиентов, кроме растворителя)")
print("2. Сгенерировать план (выбор типа)")
print("3. Показать матрицу плана")
print("4. Показать экспериментальные смеси (абсолютные значения)")
print("5. Сохранить дизайн в проект")
print("(Enter - выход в главное меню)")
print("=" * 50)
choice = input("Выберите пункт: ").strip()
if choice == "":
break
if choice == "1":
num_factors = len(mixture.ings) - 1
if num_factors <= 0:
print("В смеси нет изменяемых ингредиентов (только растворитель).")
continue
print(f"Введите шаги для {num_factors} факторов (в %). Введите 0, если фактор не используется:")
steps = []
for i in range(1, len(mixture.ings)):
name = mixture.ings[i].name
while True:
val = input(f"Шаг для {name} (текущий % = {mixture.ings[i].value:.1f}): ")
try:
s = float(val)
if s < 0:
print("Шаг не может быть отрицательным. Введите 0 или положительное число.")
continue
steps.append(s)
break
except ValueError:
print("Введите число.")
design.set_steps(steps)
active = design._active_steps()
print(f"Шаги установлены. Активных факторов (шаг > 0): {len(active)}")
if len(active) < 2:
print("Предупреждение: для генерации плана необходимо минимум 2 активных фактора.")
elif choice == "2":
active_steps = design._active_steps()
if len(active_steps) < 2:
print(f"Недостаточно активных факторов (шаг > 0): {len(active_steps)}. Требуется минимум 2.")
continue
k = len(active_steps)
print("\nВыберите тип плана:")
print(" 1. Полный факторный план 2^k")
print(" 2. Центральный композиционный план (ЦКП)")
print(" 3. Дробный факторный план 2^(k-p)")
print(" 4. План Плакетта-Бермана")
print(" 5. План Бокса-Бенкена")
plan_type = input("Ваш выбор (по умолчанию 1): ").strip()
if plan_type == "":
plan_type = "1"
if plan_type == "1":
print(f"Активных факторов: {k}. Будет сгенерирован план 2^{k} = {2**k} опытов.")
try:
design.generate_plan()
print(f"Основной план сгенерирован: {len(design.plan)} опытов.")
except ValueError as e:
print(f"Ошибка: {e}")
continue
try:
num_center = input("Введите количество дополнительных центральных точек (0, если не нужно): ").strip()
if num_center == "":
num_center = "0"
num_center = int(num_center)
if num_center < 0:
print("Количество не может быть отрицательным.")
elif num_center > 0:
center_rows = ze(k, num_center)
if center_rows:
design.plan.extend(center_rows)
print(f"Добавлено {num_center} центральных точек. Всего опытов: {len(design.plan)}")
else:
print("Ошибка генерации центральных точек.")
except ValueError:
print("Ошибка: введите целое число.")
elif plan_type == "2":
print("Центральный композиционный план (ЦКП)")
try:
alpha_input = input("Введите значение альфа (звёздное плечо, по умолчанию 1.0): ").strip()
alpha = float(alpha_input) if alpha_input else 1.0
except ValueError:
print("Ошибка ввода, используется alpha=1.0")
alpha = 1.0
try:
center_input = input("Введите количество центральных точек (по умолчанию 1): ").strip()
center_points = int(center_input) if center_input else 1
if center_points < 0:
print("Количество не может быть отрицательным, установлено 1.")
center_points = 1
except ValueError:
print("Ошибка ввода, используется 1 центральная точка.")
center_points = 1
use_fractional = input("Использовать дробное ядро? (y/n, по умолчанию n): ").strip().lower()
fractional = None
if use_fractional == 'y':
try:
p = int(input("Введите степень дробления p (1 или больше): ").strip())
if 0 < p < k:
if k - p < 2:
print(f"Ошибка: при k={k} и p={p} ядро будет содержать {k-p} факторов, что меньше 2. Используйте p <= {k-2}.")
continue
fractional = p
print(f"Будет использовано дробное ядро 2^{k-p}.")
else:
print("Некорректное p, используем полное ядро.")
except ValueError:
print("Ошибка ввода, используем полное ядро.")
try:
design.plan = ccp(k, alpha, center_points, fractional)
if fractional:
print(f"ЦКП с дробным ядром 2^{k-fractional} сгенерирован. Опытов: {len(design.plan)}")
else:
print(f"ЦКП с полным ядром сгенерирован. Опытов: {len(design.plan)}")
except Exception as e:
print(f"Ошибка генерации ЦКП: {e}")
elif plan_type == "3":
print("Дробный факторный план 2^(k-p)")
try:
p_input = input("Введите степень дробления p (по умолчанию 1): ").strip()
p = int(p_input) if p_input else 1
if p <= 0 or p >= k:
print("p должно быть от 1 до k-1. Используем p=1.")
p = 1
if k - p < 2:
print(f"Ошибка: при k={k} и p={p} ядро будет содержать {k-p} факторов, что меньше 2. Используйте p <= {k-2}.")
continue
design.plan = fractional_factorial(k, p)
print(f"Дробный факторный план 2^{k-p} сгенерирован. Опытов: {len(design.plan)}")
except Exception as e:
print(f"Ошибка: {e}")
elif plan_type == "4":
print("План Плакетта-Бермана")
try:
design.plan = plackett_burman(k)
print(f"План Плакетта-Бермана сгенерирован. Опытов: {len(design.plan)}")
except Exception as e:
print(f"Ошибка: {e}")
elif plan_type == "5":
print("План Бокса-Бенкена")
try:
if k < 3:
print("План Бокса-Бенкена требует минимум 3 фактора.")
continue
design.plan = box_behnken(k)
print(f"План Бокса-Бенкена сгенерирован. Опытов: {len(design.plan)}")
except Exception as e:
print(f"Ошибка: {e}")
else:
print("Неверный тип плана. План не сгенерирован.")
elif choice == "3":
if not design.plan:
print("План не сгенерирован.")
continue
active_indices = design._active_indices()
headers = ["Опыт"] + [mixture.ings[idx+1].name for idx in active_indices]
rows = []
for i, row in enumerate(design.plan, start=1):
rows.append([str(i)] + [str(v) for v in row])
handle_table_output(headers, rows, title="Матрица плана")
elif choice == "4":
if not design.plan or not design.steps:
print("План или шаги не заданы.")
continue
try:
exp_mixtures = design.get_all_mixtures(mixture)
headers = ["Опыт"] + [f"{info.name} ({info.unit})" for info in mixture.ings]
rows = []
for i, exp_mix in enumerate(exp_mixtures, start=1):
abs_ings = exp_mix.get_absolute_ingredients()
rows.append([str(i)] + [f"{ing.value:.2f}" for ing in abs_ings])
handle_table_output(headers, rows, title="Экспериментальные смеси (абсолютные значения)")
except Exception as e:
print(f"Ошибка: {e}")
elif choice == "5":
save_project_partial(design=design)
print("Дизайн сохранён в проект.")
else:
print("Неверный пункт.")
return design
+3
View File
@@ -0,0 +1,3 @@
numpy
scipy
pyperclip
+156
View File
@@ -0,0 +1,156 @@
# response.py
"""
Ввод и управление откликами эксперимента.
"""
from core.fs import save_project_partial
from core.table_io import handle_table_output
def response_menu(design, responses):
if not design.plan:
print("План эксперимента пуст. Сначала создайте или загрузите дизайн.")
input("Нажмите Enter для продолжения...")
return responses
num_experiments = len(design.plan)
while True:
print("\n" + "=" * 50)
print("ВВОД ЗНАЧЕНИЙ ОТКЛИКА")
print(f"Количество опытов: {num_experiments}")
print("1. Показать все отклики")
print("2. Добавить новый отклик")
print("3. Редактировать отклик")
print("4. Переименовать отклик")
print("5. Удалить отклик")
print("6. Сохранить отклики в проект")
print("(Enter - выход в главное меню)")
print("=" * 50)
choice = input("Выберите пункт: ").strip()
if choice == "":
break
if choice == "1":
if not responses:
print("Отклики не введены.")
continue
headers = ["Опыт"] + [resp["name"] for resp in responses]
rows = []
for i in range(num_experiments):
row = [str(i + 1)]
for resp in responses:
values = resp["values"]
if i < len(values):
row.append(f"{values[i]:.3f}")
else:
row.append("")
rows.append(row)
handle_table_output(headers, rows, title="Таблица откликов")
elif choice == "2":
name = input("Введите название нового отклика: ").strip()
if not name:
print("Название не может быть пустым.")
continue
if any(r["name"].lower() == name.lower() for r in responses):
print("Отклик с таким названием уже существует.")
continue
print(f"Введите {num_experiments} значений для отклика '{name}':")
values = []
for i in range(num_experiments):
while True:
val = input(f" Опыт {i+1}: ").strip()
try:
values.append(float(val))
break
except ValueError:
print("Введите число.")
responses.append({"name": name, "values": values})
print(f"Отклик '{name}' добавлен.")
elif choice == "3":
if not responses:
print("Нет откликов для редактирования.")
continue
print("Список откликов:")
for idx, resp in enumerate(responses, start=1):
print(f"{idx}. {resp['name']} (значений: {len(resp['values'])})")
try:
idx = int(input("Выберите номер отклика для редактирования: ")) - 1
if idx < 0 or idx >= len(responses):
print("Неверный номер.")
continue
resp = responses[idx]
print(f"Редактирование отклика '{resp['name']}'. Введите новые значения.")
new_values = []
for i in range(num_experiments):
while True:
val = input(f" Опыт {i+1} (было {resp['values'][i] if i < len(resp['values']) else ''}): ").strip()
if val == "":
if i < len(resp['values']):
new_values.append(resp['values'][i])
else:
print("Значение обязательно.")
continue
break
try:
new_values.append(float(val))
break
except ValueError:
print("Введите число.")
resp["values"] = new_values
print(f"Отклик '{resp['name']}' обновлён.")
except ValueError:
print("Ошибка ввода.")
elif choice == "4":
if not responses:
print("Нет откликов для переименования.")
continue
print("Список откликов:")
for idx, resp in enumerate(responses, start=1):
print(f"{idx}. {resp['name']}")
try:
idx = int(input("Выберите номер отклика для переименования: ")) - 1
if idx < 0 or idx >= len(responses):
print("Неверный номер.")
continue
new_name = input(f"Введите новое название для '{responses[idx]['name']}': ").strip()
if not new_name:
print("Название не может быть пустым.")
continue
if any(r["name"].lower() == new_name.lower() for r in responses if r != responses[idx]):
print("Отклик с таким названием уже существует.")
continue
old_name = responses[idx]["name"]
responses[idx]["name"] = new_name
print(f"Отклик '{old_name}' переименован в '{new_name}'.")
except ValueError:
print("Ошибка ввода.")
elif choice == "5":
if not responses:
print("Нет откликов для удаления.")
continue
print("Список откликов:")
for idx, resp in enumerate(responses, start=1):
print(f"{idx}. {resp['name']} (значений: {len(resp['values'])})")
try:
idx = int(input("Выберите номер отклика для удаления: ")) - 1
if idx < 0 or idx >= len(responses):
print("Неверный номер.")
continue
deleted = responses.pop(idx)
print(f"Отклик '{deleted['name']}' удалён.")
except ValueError:
print("Ошибка ввода.")
elif choice == "6":
save_project_partial(responses=responses)
print("Отклики сохранены в проект.")
else:
print("Неверный пункт.")
return responses
+27
View File
@@ -0,0 +1,27 @@
# table.py
"""
Модуль для вывода таблиц в консоль с выравниванием.
"""
def print_table(headers, rows):
"""
Печатает таблицу с выравниванием.
headers: список заголовков (строки)
rows: список строк, каждая строка — список значений (строки)
"""
widths = [len(str(h)) for h in headers]
for row in rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(str(cell)))
sep = "+" + "+".join("-" * (w + 2) for w in widths) + "+"
def print_row(cells):
print("| " + " | ".join(str(c).ljust(widths[i]) for i, c in enumerate(cells)) + " |")
print(sep)
print_row(headers)
print(sep)
for row in rows:
print_row(row)
print(sep)