from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QWidget, QMessageBox, QTableWidget, QTableWidgetItem, QGroupBox, QSpinBox, QDoubleSpinBox, QComboBox, QLineEdit, QTextEdit, QTabWidget, QFormLayout, QCheckBox, QScrollArea, QFileDialog) from PyQt5.QtCore import Qt from PyQt5.QtGui import QColor, QFont import numpy as np import csv class ExperimentDesignWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Планирование эксперимента - Цифровой помощник биохимика") self.setGeometry(200, 100, 1200, 800) self.setStyleSheet(""" QMainWindow { background-color: #f5f5f5; } QGroupBox { font-weight: bold; border: 2px solid #ccc; border-radius: 5px; margin-top: 10px; padding-top: 10px; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 5px 0 5px; color: #1565C0; } QPushButton { background-color: #4CAF50; color: white; border: none; padding: 8px; border-radius: 4px; font-weight: bold; } QPushButton:hover { background-color: #45a049; } QTableWidget { gridline-color: #ddd; } QHeaderView::section { background-color: #1565C0; color: white; padding: 8px; border: none; font-weight: bold; } """) self._init_ui() def _init_ui(self): central_widget = QWidget() self.setCentralWidget(central_widget) layout = QVBoxLayout(central_widget) # Заголовок title_label = QLabel("📈 Планирование полнофакторного эксперимента (DoE)") title_font = QFont() title_font.setPointSize(18) title_font.setBold(True) title_label.setFont(title_font) title_label.setAlignment(Qt.AlignCenter) title_label.setStyleSheet("color: #2E7D32;") layout.addWidget(title_label) # Вкладки tabs = QTabWidget() # Вкладка 1: Параметры эксперимента params_tab = QWidget() params_layout = QVBoxLayout(params_tab) # Группа факторов factors_group = QGroupBox("Факторы эксперимента (независимые переменные)") factors_layout = QVBoxLayout() info_label = QLabel("Определите факторы, которые влияют на ваш эксперимент:") info_label.setStyleSheet("color: #555; font-weight: normal;") factors_layout.addWidget(info_label) self.factors_table = QTableWidget() # Новый порядок колонок: Фактор, Нулевой уровень, Шаг, Верхний (+1), Нижний (-1), Единица измерения self.factors_table.setColumnCount(6) self.factors_table.setHorizontalHeaderLabels(["Фактор", "Нулевой уровень (0)", "Шаг", "Верхний уровень (+1)", "Нижний уровень (-1)", "Единица измерения"]) self.factors_table.setRowCount(2) # Пример данных sample_factors = [ ["Температура", "31", "6", "37", "25", "°C"], ["pH", "7.0", "0.5", "7.5", "6.5", ""], ] for i, factor in enumerate(sample_factors): for j, value in enumerate(factor): item = QTableWidgetItem(value) if j in [3, 4]: # Верхний и нижний уровень - только для чтения item.setFlags(item.flags() & ~Qt.ItemIsEditable) item.setBackground(QColor(240, 240, 240)) self.factors_table.setItem(i, j, item) # Настройка ширины колонок self.factors_table.setColumnWidth(0, 150) self.factors_table.setColumnWidth(1, 120) self.factors_table.setColumnWidth(2, 80) self.factors_table.setColumnWidth(3, 120) self.factors_table.setColumnWidth(4, 120) self.factors_table.setColumnWidth(5, 100) # Подключаем сигналы изменения ячеек self.factors_table.itemChanged.connect(self.on_factor_changed) factors_layout.addWidget(self.factors_table) # Кнопки для управления факторами factor_buttons = QHBoxLayout() add_factor_btn = QPushButton("+ Добавить фактор") add_factor_btn.clicked.connect(self.add_factor_row) remove_factor_btn = QPushButton("- Удалить последний") remove_factor_btn.clicked.connect(self.remove_factor_row) factor_buttons.addWidget(add_factor_btn) factor_buttons.addWidget(remove_factor_btn) factor_buttons.addStretch() factors_layout.addLayout(factor_buttons) factors_group.setLayout(factors_layout) params_layout.addWidget(factors_group) # Группа настроек эксперимента settings_group = QGroupBox("Настройки эксперимента") settings_layout = QHBoxLayout() center_layout = QHBoxLayout() center_layout.addWidget(QLabel("Количество центральных точек:")) self.center_points_spin = QSpinBox() self.center_points_spin.setRange(0, 10) self.center_points_spin.setValue(3) self.center_points_spin.setToolTip("Повторные опыты в нулевой точке для оценки дисперсии") center_layout.addWidget(self.center_points_spin) settings_layout.addLayout(center_layout) self.randomize_check = QCheckBox("Рэндомизировать порядок опытов") self.randomize_check.setChecked(True) settings_layout.addWidget(self.randomize_check) settings_layout.addStretch() settings_group.setLayout(settings_layout) params_layout.addWidget(settings_group) # Группа откликов responses_group = QGroupBox("Отклики (зависимые переменные)") responses_layout = QVBoxLayout() self.responses_table = QTableWidget() self.responses_table.setColumnCount(2) self.responses_table.setHorizontalHeaderLabels(["Отклик", "Единица измерения"]) self.responses_table.setRowCount(2) sample_responses = [ ["Оптическая плотность (OD600)", ""], ["Концентрация целевого продукта", "мг/мл"] ] for i, response in enumerate(sample_responses): for j, value in enumerate(response): self.responses_table.setItem(i, j, QTableWidgetItem(value)) responses_layout.addWidget(self.responses_table) response_buttons = QHBoxLayout() add_response_btn = QPushButton("+ Добавить отклик") add_response_btn.clicked.connect(self.add_response_row) remove_response_btn = QPushButton("- Удалить последний") remove_response_btn.clicked.connect(self.remove_response_row) response_buttons.addWidget(add_response_btn) response_buttons.addWidget(remove_response_btn) response_buttons.addStretch() responses_layout.addLayout(response_buttons) responses_group.setLayout(responses_layout) params_layout.addWidget(responses_group) tabs.addTab(params_tab, "📝 Параметры эксперимента") # Вкладка 2: Матрица планирования plan_tab = QWidget() plan_layout = QVBoxLayout(plan_tab) plan_info = QLabel("Полнофакторный план эксперимента с центральными точками") plan_info.setAlignment(Qt.AlignCenter) plan_info.setStyleSheet("font-weight: bold; font-size: 14px; padding: 10px;") plan_layout.addWidget(plan_info) scroll = QScrollArea() scroll.setWidgetResizable(True) matrix_widget = QWidget() matrix_layout = QVBoxLayout(matrix_widget) self.design_matrix = QTableWidget() matrix_layout.addWidget(self.design_matrix) scroll.setWidget(matrix_widget) plan_layout.addWidget(scroll) buttons_layout = QHBoxLayout() generate_btn = QPushButton("🔄 Сгенерировать план эксперимента") generate_btn.clicked.connect(self.generate_design_matrix) buttons_layout.addWidget(generate_btn) export_btn = QPushButton("📊 Экспорт в CSV") export_btn.clicked.connect(self.export_to_csv) buttons_layout.addWidget(export_btn) buttons_layout.addStretch() plan_layout.addLayout(buttons_layout) self.plan_info_label = QLabel("") self.plan_info_label.setStyleSheet("color: #666; font-size: 12px; padding: 5px;") plan_layout.addWidget(self.plan_info_label) tabs.addTab(plan_tab, "📊 Матрица планирования") # Вкладка 3: Анализ результатов analysis_tab = QWidget() analysis_layout = QVBoxLayout(analysis_tab) analysis_info = QLabel("Введите результаты экспериментов для анализа") analysis_info.setAlignment(Qt.AlignCenter) analysis_info.setStyleSheet("font-weight: bold; font-size: 14px; padding: 10px;") analysis_layout.addWidget(analysis_info) self.results_table = QTableWidget() analysis_layout.addWidget(self.results_table) analyze_btn = QPushButton("📈 Провести регрессионный анализ") analyze_btn.clicked.connect(self.perform_analysis) analysis_layout.addWidget(analyze_btn) self.analysis_output = QTextEdit() self.analysis_output.setReadOnly(True) self.analysis_output.setMaximumHeight(200) analysis_layout.addWidget(self.analysis_output) tabs.addTab(analysis_tab, "📐 Анализ результатов") layout.addWidget(tabs) btn_layout = QHBoxLayout() save_btn = QPushButton("💿 Сохранить проект") save_btn.clicked.connect(self.show_placeholder_message) btn_layout.addWidget(save_btn) load_btn = QPushButton("📂 Загрузить проект") load_btn.clicked.connect(self.show_placeholder_message) btn_layout.addWidget(load_btn) btn_layout.addStretch() close_btn = QPushButton("❌ Закрыть") close_btn.clicked.connect(self.close) btn_layout.addWidget(close_btn) layout.addLayout(btn_layout) def on_factor_changed(self, item): """При изменении нулевого уровня или шага пересчитываем верхний и нижний уровни""" row = item.row() col = item.column() # Если изменили нулевой уровень (колонка 1) или шаг (колонка 2) if col in [1, 2]: try: center = float(self.factors_table.item(row, 1).text()) if self.factors_table.item(row, 1) else 0 step = float(self.factors_table.item(row, 2).text()) if self.factors_table.item(row, 2) else 0 # Пересчитываем верхний и нижний уровни high = center + step low = center - step # Обновляем ячейки (временно отключаем сигнал) self.factors_table.blockSignals(True) high_item = self.factors_table.item(row, 3) if high_item: high_item.setText(f"{high:.3f}".rstrip('0').rstrip('.')) low_item = self.factors_table.item(row, 4) if low_item: low_item.setText(f"{low:.3f}".rstrip('0').rstrip('.')) self.factors_table.blockSignals(False) except ValueError: pass def add_factor_row(self): """Добавляет строку для нового фактора""" row = self.factors_table.rowCount() self.factors_table.insertRow(row) self.factors_table.setItem(row, 0, QTableWidgetItem(f"Фактор_{row+1}")) self.factors_table.setItem(row, 1, QTableWidgetItem("0")) self.factors_table.setItem(row, 2, QTableWidgetItem("1")) # Верхний и нижний уровни - только для чтения high_item = QTableWidgetItem("1") high_item.setFlags(high_item.flags() & ~Qt.ItemIsEditable) high_item.setBackground(QColor(240, 240, 240)) self.factors_table.setItem(row, 3, high_item) low_item = QTableWidgetItem("-1") low_item.setFlags(low_item.flags() & ~Qt.ItemIsEditable) low_item.setBackground(QColor(240, 240, 240)) self.factors_table.setItem(row, 4, low_item) self.factors_table.setItem(row, 5, QTableWidgetItem("")) def remove_factor_row(self): """Удаляет последнюю строку факторов""" if self.factors_table.rowCount() > 1: self.factors_table.removeRow(self.factors_table.rowCount() - 1) def add_response_row(self): """Добавляет строку для нового отклика""" row = self.responses_table.rowCount() self.responses_table.insertRow(row) self.responses_table.setItem(row, 0, QTableWidgetItem(f"Отклик_{row+1}")) self.responses_table.setItem(row, 1, QTableWidgetItem("")) def remove_response_row(self): """Удаляет последнюю строку откликов""" if self.responses_table.rowCount() > 1: self.responses_table.removeRow(self.responses_table.rowCount() - 1) def get_factors_data(self): """Получает данные о факторах""" factors = [] for row in range(self.factors_table.rowCount()): try: factor = { 'name': self.factors_table.item(row, 0).text() if self.factors_table.item(row, 0) else "", 'center': float(self.factors_table.item(row, 1).text()) if self.factors_table.item(row, 1) else 0, 'step': float(self.factors_table.item(row, 2).text()) if self.factors_table.item(row, 2) else 0, 'high': float(self.factors_table.item(row, 3).text()) if self.factors_table.item(row, 3) else 0, 'low': float(self.factors_table.item(row, 4).text()) if self.factors_table.item(row, 4) else 0, 'unit': self.factors_table.item(row, 5).text() if self.factors_table.item(row, 5) else "" } factors.append(factor) except (ValueError, AttributeError): continue return factors def calculate_factorial_design(self, factors): """Генерирует полнофакторный план 2^k с центральными точками""" k = len(factors) if k == 0: return [] # Генерируем 2^k комбинаций n_factorial = 2 ** k design = [] for i in range(n_factorial): experiment = {} for j in range(k): # Кодированный уровень (-1 или +1) coded_level = -1 if (i >> (k - 1 - j)) & 1 == 0 else 1 # Переводим в натуральные значения if coded_level == -1: natural_value = factors[j]['low'] else: natural_value = factors[j]['high'] experiment[f"Фактор_{j+1}"] = { 'coded': coded_level, 'natural': natural_value, 'name': factors[j]['name'], 'unit': factors[j]['unit'] } design.append(experiment) # Добавляем центральные точки n_center = self.center_points_spin.value() for i in range(n_center): center_experiment = {} for j in range(k): center_experiment[f"Фактор_{j+1}"] = { 'coded': 0, 'natural': factors[j]['center'], 'name': factors[j]['name'], 'unit': factors[j]['unit'] } center_experiment['is_center'] = True center_experiment['center_num'] = i + 1 design.append(center_experiment) # Рэндомизация порядка опытов if self.randomize_check.isChecked(): import random random.shuffle(design) return design def generate_design_matrix(self): """Генерирует и отображает матрицу планирования""" factors = self.get_factors_data() if len(factors) == 0: QMessageBox.warning(self, "Предупреждение", "Добавьте хотя бы один фактор!") return # Генерируем план design = self.calculate_factorial_design(factors) # Количество опытов n_experiments = len(design) n_factors = len(factors) # Настройка таблицы self.design_matrix.setRowCount(n_experiments) self.design_matrix.setColumnCount(n_factors + 2) # Заголовки headers = ["№ опыта"] + [f["name"] for f in factors] + ["Тип точки"] self.design_matrix.setHorizontalHeaderLabels(headers) # Заполняем матрицу for exp_idx, experiment in enumerate(design): # Номер опыта self.design_matrix.setItem(exp_idx, 0, QTableWidgetItem(str(exp_idx + 1))) # Значения факторов for factor_idx in range(n_factors): factor_key = f"Фактор_{factor_idx + 1}" value = experiment[factor_key]['natural'] unit = factors[factor_idx]['unit'] # Форматируем значение if isinstance(value, float): if value == int(value): display_value = str(int(value)) else: display_value = f"{value:.3f}".rstrip('0').rstrip('.') else: display_value = str(value) if unit: display_value += f" {unit}" item = QTableWidgetItem(display_value) # Подсветка центральных точек if experiment.get('is_center', False): item.setBackground(QColor(255, 255, 200)) self.design_matrix.setItem(exp_idx, factor_idx + 1, item) # Тип точки if experiment.get('is_center', False): type_item = QTableWidgetItem(f"Центральная #{experiment['center_num']}") type_item.setBackground(QColor(255, 255, 200)) else: # Показываем комбинацию уровней levels = [] for factor_idx in range(n_factors): factor_key = f"Фактор_{factor_idx + 1}" coded = experiment[factor_key]['coded'] levels.append("+" if coded == 1 else "-") type_item = QTableWidgetItem(f"Факторная ({''.join(levels)})") self.design_matrix.setItem(exp_idx, n_factors + 1, type_item) # Настройка ширины колонок self.design_matrix.resizeColumnsToContents() # Обновляем информацию n_factorial = 2 ** n_factors n_center = self.center_points_spin.value() self.plan_info_label.setText( f"📊 План эксперимента: {n_factorial} факторных точек + {n_center} центральных точек = {n_experiments} опытов" ) # Настраиваем таблицу для ввода результатов self.setup_results_table(n_experiments) QMessageBox.information(self, "Успех", f"Сгенерирован план для {n_factors} факторов\n" f"Факторных точек: {n_factorial}\n" f"Центральных точек: {n_center}\n" f"Всего опытов: {n_experiments}\n\n" f"Центральные точки позволяют оценить дисперсию воспроизводимости") def setup_results_table(self, n_experiments): """Настраивает таблицу для ввода результатов""" n_responses = self.responses_table.rowCount() self.results_table.setRowCount(n_experiments) self.results_table.setColumnCount(n_responses + 1) # Заголовки headers = ["№ опыта"] + [self.responses_table.item(i, 0).text() if self.responses_table.item(i, 0) else f"Отклик_{i+1}" for i in range(n_responses)] self.results_table.setHorizontalHeaderLabels(headers) # Заполняем номера опытов for i in range(n_experiments): self.results_table.setItem(i, 0, QTableWidgetItem(str(i + 1))) # Настройка ширины колонок self.results_table.setColumnWidth(0, 80) for i in range(n_responses): self.results_table.setColumnWidth(i + 1, 150) def export_to_csv(self): """Экспортирует матрицу планирования в CSV""" if self.design_matrix.rowCount() == 0: QMessageBox.warning(self, "Предупреждение", "Сначала сгенерируйте план эксперимента!") return filename, _ = QFileDialog.getSaveFileName( self, "Сохранить план эксперимента", "", "CSV Files (*.csv);;All Files (*)" ) if 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) # Заголовки headers = [] for j in range(self.design_matrix.columnCount()): header_item = self.design_matrix.horizontalHeaderItem(j) headers.append(header_item.text() if header_item else f"Колонка_{j+1}") writer.writerow(headers) # Данные for i in range(self.design_matrix.rowCount()): row = [] for j in range(self.design_matrix.columnCount()): item = self.design_matrix.item(i, j) row.append(item.text() if item else "") writer.writerow(row) QMessageBox.information(self, "Успех", f"План эксперимента сохранен в {filename}") except Exception as e: QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить файл: {str(e)}") def perform_analysis(self): """Проводит регрессионный анализ""" n_responses = self.responses_table.rowCount() if n_responses == 0: QMessageBox.warning(self, "Предупреждение", "Добавьте хотя бы один отклик!") return # Собираем результаты results = [] for i in range(self.results_table.rowCount()): row_results = [] for j in range(1, self.results_table.columnCount()): item = self.results_table.item(i, j) if item and item.text(): try: row_results.append(float(item.text())) except ValueError: row_results.append(None) else: row_results.append(None) results.append(row_results) # Проверяем, что все результаты введены missing = False for i, row in enumerate(results): for j, val in enumerate(row): if val is None: missing = True self.analysis_output.setText(f"❌ Ошибка: Не введены результаты для опыта {i+1}, отклик {j+1}") return # Анализ self.analysis_output.clear() self.analysis_output.append("=" * 60) self.analysis_output.append("РЕЗУЛЬТАТЫ РЕГРЕССИОННОГО АНАЛИЗА") self.analysis_output.append("=" * 60) factors = self.get_factors_data() design = self.calculate_factorial_design(factors) for resp_idx in range(n_responses): resp_name = self.responses_table.item(resp_idx, 0).text() self.analysis_output.append(f"\n📊 Отклик: {resp_name}") self.analysis_output.append("-" * 40) # Собираем значения отклика y_values = [results[i][resp_idx] for i in range(len(results))] # Среднее значение mean_y = np.mean(y_values) self.analysis_output.append(f"Среднее значение: {mean_y:.4f}") # Дисперсия variance = np.var(y_values, ddof=1) if len(y_values) > 1 else 0 self.analysis_output.append(f"Общая дисперсия: {variance:.4f}") # Стандартное отклонение std_dev = np.std(y_values, ddof=1) if len(y_values) > 1 else 0 self.analysis_output.append(f"Стандартное отклонение: {std_dev:.4f}") # Коэффициент вариации if mean_y != 0: cv = (std_dev / mean_y) * 100 self.analysis_output.append(f"Коэффициент вариации: {cv:.2f}%") # Разделяем факторные и центральные точки factorial_y = [] center_y = [] for i, exp in enumerate(design): if exp.get('is_center', False): center_y.append(y_values[i]) else: factorial_y.append(y_values[i]) if len(center_y) > 1: center_variance = np.var(center_y, ddof=1) self.analysis_output.append(f"\nЦентральные точки (n={len(center_y)}):") self.analysis_output.append(f" Среднее: {np.mean(center_y):.4f}") self.analysis_output.append(f" Дисперсия воспроизводимости: {center_variance:.4f}") self.analysis_output.append(f" Стандартное отклонение: {np.std(center_y, ddof=1):.4f}") # Критерий Фишера для проверки адекватности if len(factorial_y) > 0 and center_variance > 0: factorial_variance = np.var(factorial_y, ddof=1) if len(factorial_y) > 1 else 0 if factorial_variance > 0: fisher = max(factorial_variance, center_variance) / min(factorial_variance, center_variance) self.analysis_output.append(f"\nКритерий Фишера (F-отношение): {fisher:.4f}") if fisher < 4.0: self.analysis_output.append(" ✅ Модель адекватна экспериментальным данным") else: self.analysis_output.append(" ⚠️ Модель может быть неадекватна, требуется проверка") self.analysis_output.append("\n" + "=" * 60) self.analysis_output.append("✅ Анализ завершен") def show_placeholder_message(self): """Показывает сообщение о том, что функция в разработке""" QMessageBox.information( self, "В разработке", "🧪 Функция в стадии разработки!\n\nБлижайшие обновления:\n" "✅ Экспорт в Excel\n" "✅ Построение поверхностей отклика\n" "✅ Графики главных эффектов\n" "✅ Полный регрессионный анализ" )