diff --git a/controller.py b/controller.py index 08e4412..c2d85f7 100644 --- a/controller.py +++ b/controller.py @@ -1,7 +1,8 @@ -from PyQt5.QtWidgets import QMessageBox, QFileDialog, QTableWidgetItem, QComboBox, QLineEdit +from PyQt5.QtWidgets import QMessageBox, QFileDialog, QTableWidgetItem, QComboBox, QLineEdit, QDoubleSpinBox from PyQt5.QtCore import Qt +from PyQt5.QtGui import QColor from model import Model -from view import MainWindow as MediumCalculatorView +from view import MediumCalculatorWindow import json from reagent import Reagent @@ -9,10 +10,9 @@ from reagent import Reagent class Controller: def __init__(self): self.model = Model() - self.view = MediumCalculatorView() + self.view = MediumCalculatorWindow() self._connect_signals() - # Убираем автоматический показ окна - теперь он вызывается из главного меню - # self.view.show() + def _connect_signals(self): """Подключает обработчики событий интерфейса""" @@ -118,26 +118,22 @@ class Controller: self.view.table.setItem(row, 0, QTableWidgetItem(reagent.name)) self.view.table.setItem(row, 1, QTableWidgetItem(f"{reagent.percentage:.2f}")) - + + # Единица - QComboBox unit_combo = QComboBox() unit_combo.addItems(["нг", "мкг", "мг", "г", "кг", "нл", "мкл", "мл", "л"]) unit_combo.setCurrentText(reagent.unit) self.view.table.setCellWidget(row, 2, unit_combo) - + self.view.table.setItem(row, 3, QTableWidgetItem(f"{reagent.conversion_factor:.2f}")) - - # Создаём поле для разбавления (QLineEdit для ручного ввода) - dilution_edit = QLineEdit() - dilution_edit.setText(f"{getattr(reagent, 'dilution_factor', 1.0):.3f}") - dilution_edit.setAlignment(Qt.AlignRight) - dilution_edit.setToolTip("Во сколько раз разбавить (1 = без разбавления)") - self.view.table.setCellWidget(row, 4, dilution_edit) - + + # Разбавление - обычная ячейка + self.view.table.setItem(row, 4, QTableWidgetItem(f"{getattr(reagent, 'dilution_factor', 1.0):.3f}")) + self.view.table.setItem(row, 5, QTableWidgetItem("")) # Очищаем результаты self.view.clear_results() - def save_composition(self): """Сохраняет состав среды в JSON-файл""" filename, _ = QFileDialog.getSaveFileName( diff --git a/experiment_design.py b/experiment_design.py index 5bdd856..a6b4889 100644 --- a/experiment_design.py +++ b/experiment_design.py @@ -2,16 +2,19 @@ from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QWidget, QMessageBox, QTableWidget, QTableWidgetItem, QGroupBox, QSpinBox, QDoubleSpinBox, QComboBox, QLineEdit, - QTextEdit, QTabWidget, QFormLayout) + QTextEdit, QTabWidget, QFormLayout, QCheckBox, + QScrollArea, QFileDialog) from PyQt5.QtCore import Qt -from PyQt5.QtGui import QFont +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, 1000, 750) + self.setGeometry(200, 100, 1200, 800) self.setStyleSheet(""" QMainWindow { background-color: #f5f5f5; @@ -27,6 +30,7 @@ class ExperimentDesignWindow(QMainWindow): subcontrol-origin: margin; left: 10px; padding: 0 5px 0 5px; + color: #1565C0; } QPushButton { background-color: #4CAF50; @@ -42,6 +46,13 @@ class ExperimentDesignWindow(QMainWindow): QTableWidget { gridline-color: #ddd; } + QHeaderView::section { + background-color: #1565C0; + color: white; + padding: 8px; + border: none; + font-weight: bold; + } """) self._init_ui() @@ -71,26 +82,41 @@ class ExperimentDesignWindow(QMainWindow): 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() - self.factors_table.setColumnCount(4) - self.factors_table.setHorizontalHeaderLabels(["Фактор", "Нижний уровень (-1)", "Верхний уровень (+1)", "Единица измерения"]) - self.factors_table.setRowCount(3) + # Новый порядок колонок: Фактор, Нулевой уровень, Шаг, Верхний (+1), Нижний (-1), Единица измерения + self.factors_table.setColumnCount(6) + self.factors_table.setHorizontalHeaderLabels(["Фактор", "Нулевой уровень (0)", "Шаг", + "Верхний уровень (+1)", "Нижний уровень (-1)", "Единица измерения"]) + self.factors_table.setRowCount(2) # Пример данных sample_factors = [ - ["Температура", "25", "37", "°C"], - ["pH", "6.5", "7.5", ""], - ["Концентрация глюкозы", "5", "20", "г/л"] + ["Температура", "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): - self.factors_table.setItem(i, j, QTableWidgetItem(value)) + 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) @@ -108,6 +134,27 @@ class ExperimentDesignWindow(QMainWindow): 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() @@ -128,7 +175,6 @@ class ExperimentDesignWindow(QMainWindow): responses_layout.addWidget(self.responses_table) - # Кнопки для управления откликами response_buttons = QHBoxLayout() add_response_btn = QPushButton("+ Добавить отклик") add_response_btn.clicked.connect(self.add_response_row) @@ -148,17 +194,38 @@ class ExperimentDesignWindow(QMainWindow): plan_tab = QWidget() plan_layout = QVBoxLayout(plan_tab) - plan_info = QLabel("Полнофакторный план эксперимента (Full Factorial Design)") + 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() - plan_layout.addWidget(self.design_matrix) + 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) - plan_layout.addWidget(generate_btn) + 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, "📊 Матрица планирования") @@ -166,58 +233,29 @@ class ExperimentDesignWindow(QMainWindow): analysis_tab = QWidget() analysis_layout = QVBoxLayout(analysis_tab) - analysis_info = QLabel("Регрессионный анализ и визуализация результатов") + analysis_info = QLabel("Введите результаты экспериментов для анализа") analysis_info.setAlignment(Qt.AlignCenter) analysis_info.setStyleSheet("font-weight: bold; font-size: 14px; padding: 10px;") analysis_layout.addWidget(analysis_info) - # Заглушка для анализа - placeholder = QLabel("Здесь будет:\n\n" - "• Множественная линейная регрессия\n" - "• Анализ взаимодействий факторов\n" - "• ANOVA (дисперсионный анализ)\n" - "• Построение поверхностей отклика\n" - "• Графики главных эффектов\n" - "• Оптимизация параметров") - placeholder.setAlignment(Qt.AlignCenter) - placeholder.setStyleSheet("color: #666; font-size: 12px; border: 1px dashed #ccc; padding: 20px;") - analysis_layout.addWidget(placeholder) + self.results_table = QTableWidget() + analysis_layout.addWidget(self.results_table) - analyze_btn = QPushButton("📈 Провести анализ (в разработке)") - analyze_btn.clicked.connect(self.show_placeholder_message) + 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, "📐 Анализ результатов") - # Вкладка 4: Визуализация - viz_tab = QWidget() - viz_layout = QVBoxLayout(viz_tab) - - viz_info = QLabel("Интерактивная визуализация данных") - viz_info.setAlignment(Qt.AlignCenter) - viz_info.setStyleSheet("font-weight: bold; font-size: 14px; padding: 10px;") - viz_layout.addWidget(viz_info) - - viz_placeholder = QLabel("Здесь будут:\n\n" - "• 2D и 3D графики поверхностей отклика\n" - "• Контурные графики\n" - "• Диаграммы Парето\n" - "• Графики нормальной вероятности") - viz_placeholder.setAlignment(Qt.AlignCenter) - viz_placeholder.setStyleSheet("color: #666; font-size: 12px; border: 1px dashed #ccc; padding: 20px;") - viz_layout.addWidget(viz_placeholder) - - tabs.addTab(viz_tab, "📈 Визуализация") - layout.addWidget(tabs) - # Кнопки управления btn_layout = QHBoxLayout() - export_btn = QPushButton("💾 Экспорт отчёта (PDF/Excel)") - export_btn.clicked.connect(self.show_placeholder_message) - btn_layout.addWidget(export_btn) - save_btn = QPushButton("💿 Сохранить проект") save_btn.clicked.connect(self.show_placeholder_message) btn_layout.addWidget(save_btn) @@ -234,6 +272,36 @@ class ExperimentDesignWindow(QMainWindow): 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() @@ -241,7 +309,19 @@ class ExperimentDesignWindow(QMainWindow): 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")) - self.factors_table.setItem(row, 3, QTableWidgetItem("")) + + # Верхний и нижний уровни - только для чтения + 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): """Удаляет последнюю строку факторов""" @@ -260,53 +340,330 @@ class ExperimentDesignWindow(QMainWindow): if self.responses_table.rowCount() > 1: self.responses_table.removeRow(self.responses_table.rowCount() - 1) - def generate_design_matrix(self): - """Генерирует матрицу планирования (заглушка)""" - n_factors = self.factors_table.rowCount() + 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 [] - if n_factors == 0: + # Генерируем 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 - # Полнофакторный план: 2^k опытов - n_experiments = 2 ** n_factors + # Генерируем план + 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 + 1) + self.design_matrix.setColumnCount(n_factors + 2) # Заголовки - headers = ["Опыт №"] + [self.factors_table.item(i, 0).text() if self.factors_table.item(i, 0) else f"Фактор_{i+1}" - for i in range(n_factors)] + headers = ["№ опыта"] + [f["name"] for f in factors] + ["Тип точки"] self.design_matrix.setHorizontalHeaderLabels(headers) - # Заполняем матрицу (простой 2^k план) - for exp in range(n_experiments): + # Заполняем матрицу + for exp_idx, experiment in enumerate(design): # Номер опыта - self.design_matrix.setItem(exp, 0, QTableWidgetItem(str(exp + 1))) + self.design_matrix.setItem(exp_idx, 0, QTableWidgetItem(str(exp_idx + 1))) - # Кодированные уровни факторов (-1 или +1) - for factor in range(n_factors): - level = -1 if (exp // (2 ** factor)) % 2 == 0 else 1 - self.design_matrix.setItem(exp, factor + 1, QTableWidgetItem(str(level))) + # Значения факторов + 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_experiments}") + 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\n" - "✅ Полнофакторный план (2^k факторный дизайн)\n" - "✅ Регрессионный анализ и ANOVA\n" + "🧪 Функция в стадии разработки!\n\nБлижайшие обновления:\n" + "✅ Экспорт в Excel\n" "✅ Построение поверхностей отклика\n" - "✅ Оптимизация параметров\n" - "✅ Экспорт в Excel и PDF\n" - "✅ Визуализация 2D/3D графиков\n\n" - "Следите за обновлениями!" + "✅ Графики главных эффектов\n" + "✅ Полный регрессионный анализ" ) diff --git a/main.py b/main.py index f2dee33..d1f2b8a 100755 --- a/main.py +++ b/main.py @@ -6,9 +6,12 @@ from main_window import MainWindow def main(): app = QApplication(sys.argv) - # Создаём главное окно с выбором режима - main_window = MainWindow() - main_window.show() + # Устанавливаем стиль приложения + app.setStyle('Fusion') + + # Создаём главное окно цифрового помощника биохимика + assistant = MainWindow() + assistant.show() # Запускаем цикл обработки событий sys.exit(app.exec_()) diff --git a/main_window.py b/main_window.py index ee729a3..aaa542c 100644 --- a/main_window.py +++ b/main_window.py @@ -1,12 +1,12 @@ from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QWidget, QFrame) from PyQt5.QtCore import Qt -from PyQt5.QtGui import QFont, QIcon +from PyQt5.QtGui import QFont from controller import Controller from experiment_design import ExperimentDesignWindow -class DigitalBiochemistAssistant(QMainWindow): +class MainWindow(QMainWindow): def __init__(self): super().__init__() self.setWindowTitle("Цифровой помощник биохимика - Главное меню") @@ -48,7 +48,7 @@ class DigitalBiochemistAssistant(QMainWindow): layout.setContentsMargins(50, 50, 50, 50) # Заголовок - title_label = QLabel("🧬 Цифровой помощник биохимика 🧪") + title_label = QLabel("Цифровой помощник биохимика") title_font = QFont() title_font.setPointSize(20) title_font.setBold(True) @@ -69,7 +69,7 @@ class DigitalBiochemistAssistant(QMainWindow): layout.addSpacing(20) # Кнопка 1: Калькулятор питательных сред - btn_medium = QPushButton("🥼 Калькулятор питательных сред") + btn_medium = QPushButton("Калькулятор питательных сред") btn_medium.setMinimumHeight(80) btn_medium.clicked.connect(self.open_medium_calculator) layout.addWidget(btn_medium) @@ -85,7 +85,7 @@ class DigitalBiochemistAssistant(QMainWindow): layout.addSpacing(15) # Кнопка 2: Планирование эксперимента - btn_experiment = QPushButton("📊 Планирование эксперимента (DoE)") + btn_experiment = QPushButton("Планирование эксперимента (DoE)") btn_experiment.setMinimumHeight(80) btn_experiment.clicked.connect(self.open_experiment_designer) layout.addWidget(btn_experiment) @@ -110,7 +110,7 @@ class DigitalBiochemistAssistant(QMainWindow): bottom_layout = QHBoxLayout() # Информация о версии - version_label = QLabel("Версия 2.0 | © 2024 Цифровой помощник биохимика") + version_label = QLabel("Версия alpha 0.1.2 | © 2026 Цифровой помощник биохимика") version_label.setStyleSheet("color: #999; font-size: 10px;") bottom_layout.addWidget(version_label) diff --git a/view.py b/view.py index 757a206..f08d0f2 100644 --- a/view.py +++ b/view.py @@ -1,239 +1,422 @@ from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem, QPushButton, QLabel, QDoubleSpinBox, QComboBox, QLineEdit, - QWidget, QMessageBox) + QWidget, QMessageBox, QGroupBox, QFrame, QHeaderView) from PyQt5.QtCore import Qt +from PyQt5.QtGui import QFont, QColor -class MainWindow(QMainWindow): + +class MediumCalculatorWindow(QMainWindow): def __init__(self): super().__init__() - self.setWindowTitle("Калькулятор питательных сред") - self.setGeometry(100, 100, 1000, 600) + self.setWindowTitle("Калькулятор питательных сред - Цифровой помощник биохимика") + self.setGeometry(200, 100, 1200, 700) + self.setStyleSheet(""" + QMainWindow { + background-color: #f5f5f5; + } + QGroupBox { + font-weight: bold; + border: 2px solid #ccc; + border-radius: 5px; + margin-top: 10px; + padding-top: 10px; + background-color: white; + } + QGroupBox::title { + subcontrol-origin: margin; + left: 10px; + padding: 0 5px 0 5px; + color: #1565C0; + } + QTableWidget { + gridline-color: #ddd; + background-color: white; + alternate-background-color: #f9f9f9; + } + QHeaderView::section { + background-color: #1565C0; + color: white; + padding: 8px; + border: none; + font-weight: bold; + } + QPushButton { + background-color: #2196F3; + color: white; + border: none; + padding: 8px 15px; + border-radius: 4px; + font-weight: bold; + } + QPushButton:hover { + background-color: #1976D2; + } + QPushButton:pressed { + background-color: #0D47A1; + } + QPushButton#danger { + background-color: #f44336; + } + QPushButton#danger:hover { + background-color: #da190b; + } + QPushButton#success { + background-color: #4CAF50; + } + QPushButton#success:hover { + background-color: #45a049; + } + QDoubleSpinBox, QLineEdit { + padding: 4px; + border: 1px solid #ccc; + border-radius: 3px; + background-color: white; + color: black; + font-size: 12px; + min-height: 20px; + } + QComboBox { + border: 1px solid #ccc; + border-radius: 3px; + background-color: white; + color: black; + font-size: 12px; + min-height: 20px; + padding: 2px; + } + QComboBox::drop-down { + border: none; + width: 20px; + } + QComboBox QAbstractItemView { + background-color: white; + color: black; + selection-background-color: #2196F3; + selection-color: white; + } + QLabel { + color: black; + font-size: 12px; + } + QLabel#title { + font-size: 18px; + font-weight: bold; + color: #0D47A1; + } + QLabel#info { + color: #1565C0; + font-size: 11px; + } + """) self._init_ui() def _init_ui(self): central_widget = QWidget() self.setCentralWidget(central_widget) - layout = QVBoxLayout() + layout = QVBoxLayout(central_widget) + layout.setSpacing(15) + layout.setContentsMargins(20, 20, 20, 20) - # Верхняя панель: параметры среды - top_layout = QHBoxLayout() - top_layout.addWidget(QLabel("Общее количество:")) + title_label = QLabel("Калькулятор питательных сред") + title_label.setObjectName("title") + title_label.setAlignment(Qt.AlignCenter) + title_font = QFont() + title_font.setPointSize(18) + title_font.setBold(True) + title_label.setFont(title_font) + layout.addWidget(title_label) + + params_group = QGroupBox("Параметры среды") + params_layout = QHBoxLayout() + params_layout.setSpacing(20) + + amount_layout = QHBoxLayout() + amount_layout.addWidget(QLabel("Общее количество:")) self.amount_input = QDoubleSpinBox() self.amount_input.setRange(0.001, 1000000.0) self.amount_input.setValue(1000.0) - top_layout.addWidget(self.amount_input) + self.amount_input.setMinimumWidth(150) + amount_layout.addWidget(self.amount_input) self.amount_unit_combo = QComboBox() self.amount_unit_combo.addItems(["нл", "мкл", "мл", "л"]) self.amount_unit_combo.setCurrentText("мл") - top_layout.addWidget(self.amount_unit_combo) + self.amount_unit_combo.setMinimumWidth(80) + amount_layout.addWidget(self.amount_unit_combo) + params_layout.addLayout(amount_layout) - top_layout.addWidget(QLabel("Растворитель:")) + solvent_layout = QHBoxLayout() + solvent_layout.addWidget(QLabel("Растворитель:")) self.solvent_input = QLineEdit("Вода") - top_layout.addWidget(self.solvent_input) - layout.addLayout(top_layout) + self.solvent_input.setMinimumWidth(150) + solvent_layout.addWidget(self.solvent_input) + params_layout.addLayout(solvent_layout) - # Таблица реагентов (с растворителем в первой строке) - layout.addWidget(QLabel("Состав среды:")) + params_layout.addStretch() + params_group.setLayout(params_layout) + layout.addWidget(params_group) + + table_group = QGroupBox("Состав среды") + table_layout = QVBoxLayout() + self.table = QTableWidget() - self.table.setColumnCount(6) # Увеличиваем до 6 колонок - self.table.setHorizontalHeaderLabels(["Название", "%", "Единица", "Коэфф.", "Разбавление (x)", "Результат"]) - layout.addWidget(self.table) + self.table.setColumnCount(6) + self.table.setHorizontalHeaderLabels(["Название", "%", "Единица", "Коэфф.", "Разбавление (x)", "Количество"]) + self.table.setAlternatingRowColors(True) + self.table.setSelectionBehavior(QTableWidget.SelectRows) + self.table.setEditTriggers(QTableWidget.DoubleClicked | QTableWidget.EditKeyPressed) + + self.table.verticalHeader().setVisible(False) + + self.table.setColumnWidth(0, 180) + self.table.setColumnWidth(1, 70) + self.table.setColumnWidth(2, 90) + self.table.setColumnWidth(3, 70) + self.table.setColumnWidth(4, 100) + self.table.setColumnWidth(5, 120) + + table_layout.addWidget(self.table) + table_group.setLayout(table_layout) + layout.addWidget(table_group) - # Кнопки управления + btn_group = QGroupBox("Управление") btn_layout = QHBoxLayout() + btn_layout.setSpacing(10) + self.add_row_btn = QPushButton("Добавить реагент") - self.remove_row_btn = QPushButton("Удалить реагент") - self.calculate_btn = QPushButton("Рассчитать") - self.save_btn = QPushButton("Сохранить") - self.load_btn = QPushButton("Загрузить") - + self.add_row_btn.setMinimumWidth(150) btn_layout.addWidget(self.add_row_btn) - btn_layout.addWidget(self.remove_row_btn) - btn_layout.addWidget(self.calculate_btn) - btn_layout.addWidget(self.save_btn) - btn_layout.addWidget(self.load_btn) - layout.addLayout(btn_layout) - central_widget.setLayout(layout) + self.remove_row_btn = QPushButton("Удалить реагент") + self.remove_row_btn.setObjectName("danger") + self.remove_row_btn.setMinimumWidth(150) + btn_layout.addWidget(self.remove_row_btn) + + btn_layout.addStretch() + + self.calculate_btn = QPushButton("Рассчитать") + self.calculate_btn.setObjectName("success") + self.calculate_btn.setMinimumWidth(150) + btn_layout.addWidget(self.calculate_btn) + + self.save_btn = QPushButton("Сохранить") + self.save_btn.setMinimumWidth(150) + btn_layout.addWidget(self.save_btn) + + self.load_btn = QPushButton("Загрузить") + self.load_btn.setMinimumWidth(150) + btn_layout.addWidget(self.load_btn) + + btn_group.setLayout(btn_layout) + layout.addWidget(btn_group) + + info_frame = QFrame() + info_frame.setFrameShape(QFrame.StyledPanel) + info_frame.setStyleSheet("background-color: #e3f2fd; border-radius: 5px;") + info_layout = QHBoxLayout(info_frame) + + info_label = QLabel("Подсказка: Реагенты в массовых единицах (г, мг и т.д.) не учитываются при расчёте объёма растворителя") + info_label.setObjectName("info") + info_layout.addWidget(info_label) + info_layout.addStretch() + + layout.addWidget(info_frame) + self.add_initial_rows() def add_initial_rows(self): - """Добавляет начальные строки: растворитель и первый реагент""" - # Добавляем строку растворителя (первая, нередактируемая) self.add_solvent_row() - # Добавляем строку для первого реагента self.add_new_row() def add_solvent_row(self): - """Добавляет строку растворителя (нередактируемая)""" row_count = self.table.rowCount() self.table.insertRow(row_count) - - # Название растворителя (берём из поля ввода) + self.table.setRowHeight(row_count, 30) + solvent_name = self.solvent_input.text() solvent_item = QTableWidgetItem(solvent_name) solvent_item.setFlags(solvent_item.flags() & ~Qt.ItemIsEditable) - solvent_item.setBackground(Qt.lightGray) + solvent_item.setBackground(QColor(230, 230, 230)) + solvent_item.setForeground(QColor(0, 0, 0)) + font = QFont() + font.setBold(True) + solvent_item.setFont(font) self.table.setItem(row_count, 0, solvent_item) - - # Процент (будет рассчитан автоматически) + percent_item = QTableWidgetItem("") percent_item.setFlags(percent_item.flags() & ~Qt.ItemIsEditable) - percent_item.setBackground(Qt.lightGray) + percent_item.setBackground(QColor(230, 230, 230)) + percent_item.setForeground(QColor(0, 0, 0)) self.table.setItem(row_count, 1, percent_item) - - # Единица измерения (не используется для растворителя) - unit_item = QTableWidgetItem("-") + + unit_item = QTableWidgetItem(self.amount_unit_combo.currentText()) unit_item.setFlags(unit_item.flags() & ~Qt.ItemIsEditable) - unit_item.setBackground(Qt.lightGray) + unit_item.setBackground(QColor(230, 230, 230)) + unit_item.setForeground(QColor(0, 0, 0)) self.table.setItem(row_count, 2, unit_item) - - # Коэффициент (не используется для растворителя) + coeff_item = QTableWidgetItem("-") coeff_item.setFlags(coeff_item.flags() & ~Qt.ItemIsEditable) - coeff_item.setBackground(Qt.lightGray) + coeff_item.setBackground(QColor(230, 230, 230)) + coeff_item.setForeground(QColor(0, 0, 0)) self.table.setItem(row_count, 3, coeff_item) - - # Разбавление (не используется для растворителя) + dilution_item = QTableWidgetItem("-") dilution_item.setFlags(dilution_item.flags() & ~Qt.ItemIsEditable) - dilution_item.setBackground(Qt.lightGray) + dilution_item.setBackground(QColor(230, 230, 230)) + dilution_item.setForeground(QColor(0, 0, 0)) self.table.setItem(row_count, 4, dilution_item) - - # Результат (будет заполнен при расчёте) + result_item = QTableWidgetItem("") result_item.setFlags(result_item.flags() & ~Qt.ItemIsEditable) - result_item.setBackground(Qt.lightGray) + result_item.setBackground(QColor(240, 240, 240)) + result_item.setForeground(QColor(0, 0, 0)) self.table.setItem(row_count, 5, result_item) def update_solvent_name(self): - """Обновляет название растворителя в первой строке таблицы""" solvent_name = self.solvent_input.text() name_item = self.table.item(0, 0) if name_item: name_item.setText(solvent_name) + def format_number(self, value): + if value == int(value): + return str(int(value)) + else: + formatted = f"{value:.6f}".rstrip('0').rstrip('.') + if '.' in formatted and len(formatted.split('.')[1]) > 4: + formatted = f"{value:.4f}".rstrip('0').rstrip('.') + return formatted + def add_new_row(self): - """Добавляет новую строку для реагента""" row_count = self.table.rowCount() self.table.insertRow(row_count) + self.table.setRowHeight(row_count, 30) - self.table.setItem(row_count, 0, QTableWidgetItem(f"Реагент_{row_count}")) - self.table.setItem(row_count, 1, QTableWidgetItem("0.0")) + name_item = QTableWidgetItem(f"Реагент_{row_count}") + name_item.setForeground(QColor(0, 0, 0)) + self.table.setItem(row_count, 0, name_item) + + percent_item = QTableWidgetItem("0") + percent_item.setForeground(QColor(0, 0, 0)) + self.table.setItem(row_count, 1, percent_item) unit_combo = QComboBox() unit_combo.addItems(["нг", "мкг", "мг", "г", "кг", "нл", "мкл", "мл", "л"]) - unit_combo.setCurrentText("г") + unit_combo.setCurrentText("мг") self.table.setCellWidget(row_count, 2, unit_combo) - self.table.setItem(row_count, 3, QTableWidgetItem("1.0")) - - # Разбавление - обычное текстовое поле - dilution_edit = QLineEdit("1.0") - dilution_edit.setAlignment(Qt.AlignRight) - dilution_edit.setToolTip("Во сколько раз разбавить (1 = без разбавления)") - self.table.setCellWidget(row_count, 4, dilution_edit) - - self.table.setItem(row_count, 5, QTableWidgetItem("")) + coeff_item = QTableWidgetItem("1") + coeff_item.setForeground(QColor(0, 0, 0)) + self.table.setItem(row_count, 3, coeff_item) + + dilution_item = QTableWidgetItem("1") + dilution_item.setForeground(QColor(0, 0, 0)) + dilution_item.setFlags(dilution_item.flags() | Qt.ItemIsEditable) + self.table.setItem(row_count, 4, dilution_item) + + result_item = QTableWidgetItem("") + result_item.setFlags(result_item.flags() & ~Qt.ItemIsEditable) + result_item.setBackground(QColor(250, 250, 250)) + result_item.setForeground(QColor(0, 0, 0)) + self.table.setItem(row_count, 5, result_item) def remove_selected_row(self): - """Удаляет выделенную строку из таблицы (кроме строки растворителя)""" selected_rows = set() for item in self.table.selectedItems(): selected_rows.add(item.row()) - - # Удаляем строки в обратном порядке, пропуская строку растворителя (индекс 0) + for row in sorted(selected_rows, reverse=True): - if row > 0: # Не удаляем строку растворителя + if row > 0: self.table.removeRow(row) def get_table_data(self) -> list: - """Возвращает данные таблицы в виде списка списков (только реагенты, без растворителя)""" data = [] - # Начинаем с 1 строки (пропускаем растворитель) for row in range(1, self.table.rowCount()): row_data = [] - - # Название (колонка 0) + name_item = self.table.item(row, 0) row_data.append(name_item.text() if name_item else "") - - # Процент (колонка 1) + percent_item = self.table.item(row, 1) row_data.append(percent_item.text() if percent_item else "0") - - # Единица измерения (колонка 2 - комбобокс) + unit_widget = self.table.cellWidget(row, 2) if unit_widget and isinstance(unit_widget, QComboBox): row_data.append(unit_widget.currentText()) else: row_data.append("мг") - - # Коэффициент (колонка 3) + coeff_item = self.table.item(row, 3) - row_data.append(coeff_item.text() if coeff_item else "1.0") - - # Разбавление (колонка 4 - spinbox) - dilution_widget = self.table.cellWidget(row, 4) - if dilution_widget and isinstance(dilution_widget, QDoubleSpinBox): - dilution_factor = dilution_widget.value() + row_data.append(coeff_item.text() if coeff_item else "1") + + dilution_item = self.table.item(row, 4) + if dilution_item: + try: + dilution_factor = float(dilution_item.text()) + except ValueError: + dilution_factor = 1.0 row_data.append(dilution_factor) else: row_data.append(1.0) - + data.append(row_data) return data def update_solvent_percent(self, solvent_percent: float): - """Обновляет процент растворителя в первой строке""" percent_item = self.table.item(0, 1) if percent_item: - percent_item.setText(f"{solvent_percent:.2f}") + percent_item.setText(self.format_number(solvent_percent)) def show_error(self, message: str): - """Показывает сообщение об ошибке""" QMessageBox.critical(self, "Ошибка", message) def update_results(self, results: list): - """Обновляет столбец результатов (индекс 5) в таблице""" - # Начинаем с 1 строки (реагенты), 0 строка - растворитель for row, amount in enumerate(results, start=1): if row < self.table.rowCount(): - formatted_amount = f"{amount:.4f}" - self.table.setItem(row, 5, QTableWidgetItem(formatted_amount)) + formatted_amount = self.format_number(amount) + result_item = QTableWidgetItem(formatted_amount) + result_item.setFlags(result_item.flags() & ~Qt.ItemIsEditable) + result_item.setBackground(QColor(220, 255, 220)) + result_item.setForeground(QColor(0, 0, 0)) + font = QFont() + font.setBold(True) + result_item.setFont(font) + self.table.setItem(row, 5, result_item) def update_solvent_result(self, solvent_amount: float, unit: str): - """Обновляет результат для растворителя в первой строке""" - formatted_amount = f"{solvent_amount:.4f}" + formatted_amount = self.format_number(solvent_amount) result_item = self.table.item(0, 5) if result_item: result_item.setText(formatted_amount) - - # Также обновляем единицу измерения в колонке 2 для информации + result_item.setBackground(QColor(220, 255, 220)) + result_item.setForeground(QColor(0, 0, 0)) + unit_item = self.table.item(0, 2) if unit_item: unit_item.setText(unit) def update_display(self, solvent: str, total_amount: float, amount_unit: str): - """Обновляет отображение растворителя и общего количества среды""" self.solvent_input.setText(solvent) self.update_solvent_name() self.amount_input.setValue(total_amount) self.amount_unit_combo.setCurrentText(amount_unit) def clear_results(self): - """Очищает столбец результатов для всех строк""" for row in range(self.table.rowCount()): - self.table.setItem(row, 5, QTableWidgetItem("")) - - # Очищаем процент растворителя + result_item = self.table.item(row, 5) + if result_item: + result_item.setText("") + if row == 0: + result_item.setBackground(QColor(230, 230, 230)) + else: + result_item.setBackground(QColor(250, 250, 250)) + percent_item = self.table.item(0, 1) if percent_item: percent_item.setText("") - - # Очищаем единицу измерения растворителя + unit_item = self.table.item(0, 2) if unit_item: - unit_item.setText("-") + unit_item.setText(self.amount_unit_combo.currentText())