commit 025421193d44a25f8c2736d3a0f4bbbd8925fd26 Author: Artemiy Date: Thu Jul 23 15:12:32 2026 +0500 Initial commit: планирование эксперимента и оптимизация смесей diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..cfdc847 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..e568021 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# Планирование эксперимента и оптимизация смесей + +Программа для автоматизированного планирования эксперимента, построения регрессионных моделей, проверки значимости факторов и крутого восхождения (градиентного метода) для поиска оптимума в задачах со смесями. + +## Основные возможности + +- **Редактор состава**: создание и изменение ингредиентов смеси (проценты, разбавление, плотность). +- **Планирование эксперимента**: генерация различных планов: + - Полный факторный план `2^k` + - Центральный композиционный план (ЦКП) с возможностью дробного ядра + - Дробный факторный план `2^(k-p)` + - План Плакетта-Бермана + - План Бокса-Бенкена +- **Ввод откликов**: добавление, редактирование, переименование и удаление откликов. +- **Анализ значений**: + - Построение линейной регрессии (МНК) + - Проверка значимости коэффициентов по t-критерию с заданным уровнем α + - R², скорректированный R², F-критерий + - Дисперсия в центре и доверительный интервал + - Исключение незначимых факторов и пересчёт модели +- **Крутое восхождение**: + - Расчёт коэффициентов модели для выбранного отклика + - Генерация точек вдоль градиента (максимизация или минимизация) + - Возможность установить выбранную точку как новый состав +- **Экспорт данных**: все таблицы можно сохранить в CSV или скопировать в буфер обмена (TSV). + +## Установка и запуск + +1. Клонируйте репозиторий: + ```bash + git clone + cd experiment diff --git a/analysis.py b/analysis.py new file mode 100644 index 0000000..92733c1 --- /dev/null +++ b/analysis.py @@ -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 в планировании эксперимента.") diff --git a/ascent.py b/ascent.py new file mode 100644 index 0000000..5aae2de --- /dev/null +++ b/ascent.py @@ -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 для продолжения...") diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/core/classes.py b/core/classes.py new file mode 100644 index 0000000..4f341b7 --- /dev/null +++ b/core/classes.py @@ -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})" diff --git a/core/doe.py b/core/doe.py new file mode 100644 index 0000000..a7a4784 --- /dev/null +++ b/core/doe.py @@ -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= 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 diff --git a/core/experiment.py b/core/experiment.py new file mode 100644 index 0000000..570a77b --- /dev/null +++ b/core/experiment.py @@ -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", [])) diff --git a/core/fs.py b/core/fs.py new file mode 100644 index 0000000..64f45ba --- /dev/null +++ b/core/fs.py @@ -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) diff --git a/core/statistics.py b/core/statistics.py new file mode 100644 index 0000000..d6d8663 --- /dev/null +++ b/core/statistics.py @@ -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 diff --git a/core/table_io.py b/core/table_io.py new file mode 100644 index 0000000..6940c0c --- /dev/null +++ b/core/table_io.py @@ -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}") diff --git a/editor.py b/editor.py new file mode 100644 index 0000000..61ef2e1 --- /dev/null +++ b/editor.py @@ -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("Неверный пункт.") diff --git a/main.py b/main.py new file mode 100644 index 0000000..6e97ab5 --- /dev/null +++ b/main.py @@ -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() diff --git a/plan.py b/plan.py new file mode 100644 index 0000000..312770e --- /dev/null +++ b/plan.py @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..42f42c3 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +numpy +scipy +pyperclip diff --git a/response.py b/response.py new file mode 100644 index 0000000..eb357f5 --- /dev/null +++ b/response.py @@ -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 diff --git a/table.py b/table.py new file mode 100644 index 0000000..d3df741 --- /dev/null +++ b/table.py @@ -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)