1029 lines
46 KiB
Python
1029 lines
46 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Единый графический интерфейс для калькулятора сред и DoE
|
||
"""
|
||
from theme import Colors, Fonts, Spacing, ButtonStyles, get_full_stylesheet, apply_theme, TitleStyles
|
||
import sys
|
||
from typing import List, Dict, Optional
|
||
from PyQt5.QtWidgets import (
|
||
QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||
QTabWidget, QGroupBox, QLabel, QPushButton, QTableWidget,
|
||
QTableWidgetItem, QDoubleSpinBox, QComboBox, QLineEdit,
|
||
QMessageBox, QSpinBox, QCheckBox, QTextEdit, QFileDialog,
|
||
QScrollArea, QHeaderView, QToolBar
|
||
)
|
||
from PyQt5.QtCore import Qt
|
||
from PyQt5.QtGui import QFont, QColor
|
||
|
||
# Импорты расчётов
|
||
from calculations.medium import (
|
||
calculate_medium_composition,
|
||
convert_units,
|
||
VOLUME_UNITS,
|
||
MASS_UNITS
|
||
)
|
||
|
||
from calculations.doe import (
|
||
generate_factorial_design,
|
||
analyze_experiment,
|
||
calculate_factor_levels,
|
||
get_active_factors,
|
||
get_inactive_factors,
|
||
FACTOR_TYPES
|
||
)
|
||
# Импорт моделей для JSON
|
||
try:
|
||
from calculations.models.project_data import (
|
||
ProjectData, ReagentData, FactorData,
|
||
ResponseData, ExperimentResultsData, create_new_project
|
||
)
|
||
JSON_SUPPORT = True
|
||
except ImportError:
|
||
JSON_SUPPORT = False
|
||
print("Предупреждение: Модуль project_data не найден, JSON поддержка отключена")
|
||
|
||
|
||
class MainWindow(QMainWindow):
|
||
"""Главное окно с калькулятором сред и планировщиком эксперимента"""
|
||
|
||
def __init__(self):
|
||
super().__init__()
|
||
self.setWindowTitle("Биохимический помощник")
|
||
self.setStyleSheet(get_full_stylesheet())
|
||
self.setGeometry(100, 100, 1300, 800)
|
||
self.setStyleSheet(self._get_stylesheet())
|
||
|
||
self.generated_design = None
|
||
self.last_medium_result = None
|
||
self.tab_widget = None
|
||
|
||
self._setup_ui()
|
||
self._add_file_toolbar()
|
||
|
||
def _get_stylesheet(self):
|
||
return get_full_stylesheet()
|
||
|
||
def _add_file_toolbar(self):
|
||
"""Добавляет панель инструментов с кнопками сохранения/загрузки"""
|
||
toolbar = QToolBar("Файл")
|
||
toolbar.setMovable(False)
|
||
self.addToolBar(toolbar)
|
||
|
||
if JSON_SUPPORT:
|
||
save_action = toolbar.addAction("💾 Сохранить проект")
|
||
save_action.triggered.connect(self.save_project_to_json)
|
||
|
||
load_action = toolbar.addAction("📂 Загрузить проект")
|
||
load_action.triggered.connect(self.load_project_from_json)
|
||
|
||
toolbar.addSeparator()
|
||
|
||
def _setup_ui(self):
|
||
central = QWidget()
|
||
self.setCentralWidget(central)
|
||
layout = QVBoxLayout(central)
|
||
|
||
# Заголовок
|
||
title = QLabel("Цифровой помощник биохимика")
|
||
title.setObjectName("mainTitle") # Стиль подтянется из theme
|
||
title.setAlignment(Qt.AlignCenter)
|
||
layout.addWidget(title)
|
||
|
||
# Вкладки
|
||
self.tab_widget = QTabWidget()
|
||
self.tab_widget.addTab(self._create_medium_tab(), "🧪 Калькулятор сред")
|
||
self.tab_widget.addTab(self._create_factors_tab(), "📊 Факторы эксперимента")
|
||
self.tab_widget.addTab(self._create_design_tab(), "📋 Матрица планирования")
|
||
self.tab_widget.addTab(self._create_analysis_tab(), "📈 Анализ результатов")
|
||
|
||
layout.addWidget(self.tab_widget)
|
||
|
||
# Кнопка закрытия
|
||
close_btn = QPushButton("Закрыть")
|
||
close_btn.setObjectName("danger")
|
||
close_btn.clicked.connect(self.close)
|
||
close_btn.setMaximumWidth(150)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
btn_layout.addStretch()
|
||
btn_layout.addWidget(close_btn)
|
||
layout.addLayout(btn_layout)
|
||
|
||
# ========== ВКЛАДКА 1: КАЛЬКУЛЯТОР СРЕД ==========
|
||
|
||
def _create_medium_tab(self):
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
|
||
# Параметры
|
||
params_box = QGroupBox("Параметры среды")
|
||
params_layout = QHBoxLayout()
|
||
|
||
self.total_volume_spin = QDoubleSpinBox()
|
||
self.total_volume_spin.setRange(0.001, 1000000)
|
||
self.total_volume_spin.setValue(100)
|
||
self.total_volume_spin.setSuffix(" ")
|
||
|
||
self.volume_unit_combo = QComboBox()
|
||
self.volume_unit_combo.addItems(["мл", "л", "мкл", "нл"])
|
||
self.volume_unit_combo.setCurrentText("мл")
|
||
|
||
params_layout.addWidget(QLabel("Общий объём:"))
|
||
params_layout.addWidget(self.total_volume_spin)
|
||
params_layout.addWidget(self.volume_unit_combo)
|
||
params_layout.addSpacing(30)
|
||
|
||
params_layout.addWidget(QLabel("Растворитель:"))
|
||
self.solvent_input = QLineEdit("Вода")
|
||
params_layout.addWidget(self.solvent_input)
|
||
params_layout.addStretch()
|
||
|
||
params_box.setLayout(params_layout)
|
||
layout.addWidget(params_box)
|
||
|
||
# Таблица реагентов
|
||
reagents_box = QGroupBox("Состав среды")
|
||
reagents_layout = QVBoxLayout()
|
||
|
||
self.reagents_table = QTableWidget()
|
||
self.reagents_table.setColumnCount(5)
|
||
self.reagents_table.setHorizontalHeaderLabels(
|
||
["Реагент", "%", "Единица", "Разбавление", "Количество"]
|
||
)
|
||
self.reagents_table.setAlternatingRowColors(True)
|
||
self.reagents_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch)
|
||
|
||
reagents_layout.addWidget(self.reagents_table)
|
||
|
||
# Кнопки управления реагентами
|
||
btn_layout = QHBoxLayout()
|
||
add_btn = QPushButton("➕ Добавить реагент")
|
||
add_btn.clicked.connect(self._add_reagent_row)
|
||
remove_btn = QPushButton("➖ Удалить реагент")
|
||
remove_btn.setObjectName("danger")
|
||
remove_btn.clicked.connect(self._remove_reagent_row)
|
||
calculate_btn = QPushButton("🧮 Рассчитать")
|
||
calculate_btn.setObjectName("success")
|
||
calculate_btn.clicked.connect(self._calculate_medium)
|
||
|
||
btn_layout.addWidget(add_btn)
|
||
btn_layout.addWidget(remove_btn)
|
||
btn_layout.addStretch()
|
||
btn_layout.addWidget(calculate_btn)
|
||
reagents_layout.addLayout(btn_layout)
|
||
|
||
reagents_box.setLayout(reagents_layout)
|
||
layout.addWidget(reagents_box)
|
||
|
||
# Информационная панель
|
||
self.info_label = QLabel("ℹ️ Нажмите «Рассчитать» для получения количеств реагентов")
|
||
self.info_label.setStyleSheet("background-color: #ecf0f1; padding: 8px; border-radius: 5px;")
|
||
layout.addWidget(self.info_label)
|
||
|
||
# Добавляем начальные строки
|
||
self._add_reagent_row()
|
||
|
||
return tab
|
||
|
||
def _add_reagent_row(self, name = "", percentage = "0", unit = "мл", dilution = "1", amount = ""):
|
||
row = self.reagents_table.rowCount()
|
||
self.reagents_table.insertRow(row)
|
||
if name == "":
|
||
self.reagents_table.setItem(row, 0, QTableWidgetItem(f"Реагент_{row+1}"))
|
||
else:
|
||
self.reagents_table.setItem(row, 0, QTableWidgetItem(name))
|
||
self.reagents_table.setItem(row, 1, QTableWidgetItem(percentage))
|
||
|
||
unit_combo = QComboBox()
|
||
unit_combo.addItems(["мг", "г", "кг", "мкг", "нг", "мл", "мкл", "л"])
|
||
unit_combo.setCurrentText(unit)
|
||
self.reagents_table.setCellWidget(row, 2, unit_combo)
|
||
|
||
self.reagents_table.setItem(row, 3, QTableWidgetItem(dilution))
|
||
self.reagents_table.setItem(row, 4, QTableWidgetItem(amount))
|
||
|
||
def _remove_reagent_row(self):
|
||
for row in sorted(set(i.row() for i in self.reagents_table.selectedItems()), reverse=True):
|
||
if row >= 0:
|
||
self.reagents_table.removeRow(row)
|
||
|
||
def _get_reagents_from_table(self) -> List[Dict]:
|
||
"""Собирает данные реагентов из таблицы"""
|
||
reagents = []
|
||
for row in range(self.reagents_table.rowCount()):
|
||
name_item = self.reagents_table.item(row, 0)
|
||
percent_item = self.reagents_table.item(row, 1)
|
||
unit_widget = self.reagents_table.cellWidget(row, 2)
|
||
dilution_item = self.reagents_table.item(row, 3)
|
||
|
||
if not name_item or not percent_item:
|
||
continue
|
||
|
||
try:
|
||
percent_text = percent_item.text().strip()
|
||
if not percent_text:
|
||
continue
|
||
|
||
reagent = {
|
||
'name': name_item.text(),
|
||
'percentage': float(percent_text),
|
||
'unit': unit_widget.currentText() if unit_widget else "мг",
|
||
'dilution_factor': float(dilution_item.text()) if dilution_item and dilution_item.text() else 1.0
|
||
}
|
||
reagents.append(reagent)
|
||
except ValueError as e:
|
||
print(f"Ошибка в строке {row}: {e}")
|
||
continue
|
||
|
||
return reagents
|
||
|
||
def _calculate_medium(self):
|
||
"""Выполняет расчёт питательной среды"""
|
||
try:
|
||
reagents = self._get_reagents_from_table()
|
||
|
||
if not reagents:
|
||
QMessageBox.warning(self, "Предупреждение", "Добавьте хотя бы один реагент")
|
||
return
|
||
|
||
result = calculate_medium_composition(
|
||
total_volume=self.total_volume_spin.value(),
|
||
volume_unit=self.volume_unit_combo.currentText(),
|
||
reagents=reagents,
|
||
solvent_name=self.solvent_input.text()
|
||
)
|
||
|
||
# Отображаем результаты в таблице
|
||
for row, reagent in enumerate(result['reagents']):
|
||
if row < self.reagents_table.rowCount():
|
||
amount_text = self._format_number(reagent['calculated_amount'])
|
||
self.reagents_table.setItem(row, 4, QTableWidgetItem(amount_text))
|
||
# Подсветка
|
||
item = self.reagents_table.item(row, 4)
|
||
if item:
|
||
item.setBackground(QColor(220, 255, 220))
|
||
|
||
# Информация о растворителе
|
||
solvent_text = (
|
||
f"✅ Растворитель: {self._format_number(result['solvent_volume'])} {result['total_unit']} "
|
||
f"({result['solvent_percentage']:.1f}%)"
|
||
)
|
||
self.info_label.setText(solvent_text)
|
||
self.info_label.setStyleSheet("background-color: #d5f5e3; padding: 8px; border-radius: 5px;")
|
||
# Сохраняем результаты для передачи в DoE
|
||
self.last_medium_result = result
|
||
|
||
# QMessageBox.information(self, "Успех", "Расчёт выполнен успешно!")
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка", str(e))
|
||
|
||
# ========== ВКЛАДКА 2: ФАКТОРЫ ЭКСПЕРИМЕНТА ==========
|
||
def _create_factors_tab(self):
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
|
||
# Кнопка импорта из калькулятора
|
||
import_box = QGroupBox("Импорт данных")
|
||
import_layout = QHBoxLayout()
|
||
|
||
self.import_btn = QPushButton("📥 Импортировать реагенты из калькулятора")
|
||
self.import_btn.setObjectName("success")
|
||
self.import_btn.clicked.connect(self._import_reagents_to_factors)
|
||
import_layout.addWidget(self.import_btn)
|
||
import_layout.addStretch()
|
||
import_box.setLayout(import_layout)
|
||
layout.addWidget(import_box)
|
||
|
||
# Таблица факторов
|
||
factors_box = QGroupBox("Факторы эксперимента")
|
||
factors_layout = QVBoxLayout()
|
||
|
||
self.factors_table = QTableWidget()
|
||
self.factors_table.setColumnCount(9)
|
||
self.factors_table.setHorizontalHeaderLabels([
|
||
"Фактор", "%", "Разбавление", "Нулевой уровень", "Шаг",
|
||
"Тип шага", "Верхний (+1)", "Нижний (-1)", "Ед. изм."
|
||
])
|
||
#self.factors_table.setAlternatingRowColors(True)
|
||
|
||
factors_layout.addWidget(self.factors_table)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
add_factor_btn = QPushButton("➕ Добавить фактор")
|
||
add_factor_btn.clicked.connect(self._add_factor_row)
|
||
remove_factor_btn = QPushButton("➖ Удалить фактор")
|
||
remove_factor_btn.setObjectName("danger")
|
||
remove_factor_btn.clicked.connect(self._remove_factor_row)
|
||
|
||
btn_layout.addWidget(add_factor_btn)
|
||
btn_layout.addWidget(remove_factor_btn)
|
||
btn_layout.addStretch()
|
||
|
||
factors_layout.addLayout(btn_layout)
|
||
factors_box.setLayout(factors_layout)
|
||
layout.addWidget(factors_box)
|
||
|
||
# Настройки эксперимента
|
||
settings_box = QGroupBox("Настройки эксперимента")
|
||
settings_layout = QVBoxLayout()
|
||
|
||
# Параметры среды (из калькулятора)
|
||
env_layout = QHBoxLayout()
|
||
env_layout.addWidget(QLabel("Общий объём:"))
|
||
self.exp_total_volume = QDoubleSpinBox()
|
||
self.exp_total_volume.setRange(0.001, 1000000)
|
||
self.exp_total_volume.setValue(100)
|
||
self.exp_total_volume.setSuffix(" ")
|
||
env_layout.addWidget(self.exp_total_volume)
|
||
|
||
self.exp_volume_unit = QComboBox()
|
||
self.exp_volume_unit.addItems(["мл", "л", "мкл", "нл"])
|
||
self.exp_volume_unit.setCurrentText("мл")
|
||
env_layout.addWidget(self.exp_volume_unit)
|
||
|
||
env_layout.addSpacing(20)
|
||
env_layout.addWidget(QLabel("Растворитель:"))
|
||
self.exp_solvent = QLineEdit("Вода")
|
||
env_layout.addWidget(self.exp_solvent)
|
||
env_layout.addStretch()
|
||
settings_layout.addLayout(env_layout)
|
||
|
||
# Настройки эксперимента
|
||
exp_layout = QHBoxLayout()
|
||
exp_layout.addWidget(QLabel("Центральных точек:"))
|
||
self.center_points_spin = QSpinBox()
|
||
self.center_points_spin.setRange(0, 10)
|
||
self.center_points_spin.setValue(3)
|
||
exp_layout.addWidget(self.center_points_spin)
|
||
exp_layout.addSpacing(20)
|
||
|
||
self.randomize_check = QCheckBox("Рандомизировать порядок")
|
||
self.randomize_check.setChecked(False)
|
||
exp_layout.addWidget(self.randomize_check)
|
||
exp_layout.addStretch()
|
||
settings_layout.addLayout(exp_layout)
|
||
|
||
settings_box.setLayout(settings_layout)
|
||
layout.addWidget(settings_box)
|
||
|
||
# Кнопка генерации
|
||
generate_btn = QPushButton("🎲 Сгенерировать план эксперимента")
|
||
generate_btn.clicked.connect(self._generate_design)
|
||
layout.addWidget(generate_btn)
|
||
|
||
# Добавляем начальный фактор
|
||
self._add_factor_row()
|
||
# ПОДКЛЮЧАЕМ СИГНАЛ ИЗМЕНЕНИЯ ЯЧЕЕК
|
||
self.factors_table.cellChanged.connect(self._on_factor_changed)
|
||
return tab
|
||
|
||
def _add_factor_row(self, name = "", percentage = "0", dilution = "0", center = "0", step ="0", step_type = "%", high = "", low = "", unit= "г"):
|
||
row = self.factors_table.rowCount()
|
||
self.factors_table.insertRow(row)
|
||
if name == "":
|
||
self.factors_table.setItem(row, 0, QTableWidgetItem(f"Фактор_{row+1}"))
|
||
else:
|
||
self.factors_table.setItem(row, 0, QTableWidgetItem(name))
|
||
self.factors_table.setItem(row, 1, QTableWidgetItem(percentage))
|
||
self.factors_table.setItem(row, 2, QTableWidgetItem(dilution))
|
||
self.factors_table.setItem(row, 3, QTableWidgetItem(center))
|
||
self.factors_table.setItem(row, 4, QTableWidgetItem(step))
|
||
|
||
|
||
step_type_combo = QComboBox()
|
||
step_type_combo.addItems(["%", "ед."])
|
||
step_type_combo.setCurrentText(step_type)
|
||
step_type_combo.currentTextChanged.connect(lambda: self._on_factor_changed(row, 5))
|
||
self.factors_table.setCellWidget(row, 5, step_type_combo)
|
||
|
||
high_item = QTableWidgetItem(high)
|
||
high_item.setFlags(high_item.flags() & ~Qt.ItemIsEditable)
|
||
high_item.setBackground(QColor(240, 240, 240))
|
||
self.factors_table.setItem(row, 6, high_item)
|
||
|
||
low_item = QTableWidgetItem(low)
|
||
low_item.setFlags(low_item.flags() & ~Qt.ItemIsEditable)
|
||
low_item.setBackground(QColor(240, 240, 240))
|
||
self.factors_table.setItem(row, 7, low_item)
|
||
unit_measure = QComboBox()
|
||
unit_measure.addItems(["мкл", "мл", "л", "мг", "г", "кг"])
|
||
unit_measure.setCurrentText(unit)
|
||
unit_measure.currentTextChanged.connect(lambda: self._on_factor_changed(row, 8))
|
||
|
||
self.factors_table.setCellWidget(row, 8, unit_measure)
|
||
|
||
|
||
return row
|
||
|
||
def _remove_factor_row(self):
|
||
for row in sorted(set(i.row() for i in self.factors_table.selectedItems()), reverse=True):
|
||
if row >= 0:
|
||
self.factors_table.removeRow(row)
|
||
|
||
def _import_reagents_to_factors(self):
|
||
"""Импортирует реагенты из калькулятора как факторы"""
|
||
if not hasattr(self, 'last_medium_result') or self.last_medium_result is None:
|
||
QMessageBox.warning(self, "Предупреждение",
|
||
"Сначала выполните расчёт в калькуляторе сред!")
|
||
return
|
||
|
||
result = self.last_medium_result
|
||
|
||
# Очищаем таблицу факторов
|
||
self.factors_table.setRowCount(0)
|
||
|
||
|
||
self.exp_total_volume.setValue(result['total_volume'])
|
||
self.exp_volume_unit.setCurrentText(result['total_unit'])
|
||
self.exp_solvent.setText(result['solvent_name'])
|
||
for reagent in result['reagents']:
|
||
name = reagent['name']
|
||
if reagent.get('dilution_factor', 1.0) != 1.0:
|
||
name += f"(разб. ×{reagent['dilution_factor']:g})"
|
||
percentage = f"{reagent['percentage']:g}"
|
||
dilution = f"{reagent.get('dilution_factor', 1.0):g}"
|
||
center = str(reagent.get('calculated_amount'))
|
||
step = ""
|
||
step_type = "%"
|
||
high = ""
|
||
low = ""
|
||
unit = reagent["unit"]
|
||
self._add_factor_row(name, percentage, dilution, center, step, step_type, high, low, unit)
|
||
# QMessageBox.information(self, "Успех",
|
||
# f"Импортировано {len(result['reagents'])} факторов из калькулятора сред")
|
||
|
||
def _get_factors_from_table(self) -> List[Dict]:
|
||
"""Собирает данные факторов из таблицы"""
|
||
factors = []
|
||
for row in range(self.factors_table.rowCount()):
|
||
try:
|
||
name_item = self.factors_table.item(row, 0)
|
||
center_item = self.factors_table.item(row, 3)
|
||
step_item = self.factors_table.item(row, 4)
|
||
high_item = self.factors_table.item(row, 6)
|
||
low_item = self.factors_table.item(row, 7)
|
||
unit_item = self.factors_table.cellWidget(row, 8)
|
||
step_combo = self.factors_table.cellWidget(row, 5)
|
||
|
||
if not all([name_item, center_item, high_item, low_item]):
|
||
continue
|
||
|
||
center_text = center_item.text().strip()
|
||
high_text = high_item.text().strip()
|
||
low_text = low_item.text().strip()
|
||
|
||
if not center_text or not high_text or not low_text:
|
||
continue
|
||
|
||
factor = {
|
||
'name': name_item.text(),
|
||
'center': float(center_text),
|
||
'high': float(high_text),
|
||
'low': float(low_text),
|
||
'unit': unit_item.currentText() if unit_item else "г",
|
||
'step': float(step_item.text()) if step_item and step_item.text() else 0,
|
||
'step_type': step_combo.currentText() if step_combo else "ед."
|
||
}
|
||
factors.append(factor)
|
||
except (ValueError, AttributeError) as e:
|
||
print(f"Ошибка в строке {row}: {e}")
|
||
continue
|
||
|
||
return factors
|
||
def _on_factor_changed(self, row, column):
|
||
"""При изменении ячейки пересчитываем уровни"""
|
||
if getattr(self, '_loading_data', False) or column not in [3, 4, 5, 8]:
|
||
return
|
||
|
||
try:
|
||
# Получаем данные
|
||
percentage = float(self.factors_table.item(row, 1).text())
|
||
dilution = float(self.factors_table.item(row, 2).text())
|
||
center = float(self.factors_table.item(row, 3).text())
|
||
step = float(self.factors_table.item(row, 4).text())
|
||
step_type = self.factors_table.cellWidget(row, 5).currentText()
|
||
unit = self.factors_table.cellWidget(row, 8).currentText()
|
||
name = self.factors_table.item(row, 0).text()
|
||
|
||
# Рассчитываем
|
||
factor = FactorData(percentage=percentage, dilution_factor=dilution, name=name, center=center, low=0, high=0, step=step, step_type=step_type, unit=unit)
|
||
result = calculate_factor_levels(factor, self.total_volume_spin.value(), self.volume_unit_combo.currentText())
|
||
if result:
|
||
self.factors_table.blockSignals(True)
|
||
self.factors_table.item(row, 6).setText(self._format_number(result.high))
|
||
self.factors_table.item(row, 7).setText(self._format_number(result.low))
|
||
self.factors_table.blockSignals(False)
|
||
|
||
except (ValueError, AttributeError):
|
||
pass
|
||
|
||
# ========== ВКЛАДКА 3: МАТРИЦА ПЛАНИРОВАНИЯ ==========
|
||
|
||
def _create_design_tab(self):
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
|
||
# Скролл-область для большой матрицы
|
||
scroll = QScrollArea()
|
||
scroll.setWidgetResizable(True)
|
||
scroll_widget = QWidget()
|
||
scroll_layout = QVBoxLayout(scroll_widget)
|
||
|
||
self.design_matrix = QTableWidget()
|
||
scroll_layout.addWidget(self.design_matrix)
|
||
scroll.setWidget(scroll_widget)
|
||
layout.addWidget(scroll)
|
||
|
||
btn_layout = QHBoxLayout()
|
||
self.export_csv_btn = QPushButton("📊 Экспорт в CSV")
|
||
self.export_csv_btn.clicked.connect(self._export_design_csv)
|
||
self.export_csv_btn.setEnabled(False)
|
||
btn_layout.addWidget(self.export_csv_btn)
|
||
btn_layout.addStretch()
|
||
layout.addLayout(btn_layout)
|
||
|
||
self.design_info = QLabel("")
|
||
self.design_info.setStyleSheet("color: #666; padding: 5px;")
|
||
layout.addWidget(self.design_info)
|
||
|
||
return tab
|
||
|
||
def _generate_design(self):
|
||
"""Генерирует план эксперимента"""
|
||
all_factors = self._get_factors_from_table()
|
||
factors = get_active_factors(all_factors)
|
||
i_factors = get_inactive_factors(all_factors)
|
||
|
||
if not factors:
|
||
QMessageBox.warning(self, "Предупреждение", "Добавьте хотя бы один фактор!")
|
||
return
|
||
|
||
try:
|
||
design = generate_factorial_design(
|
||
factors=factors,
|
||
center_points=self.center_points_spin.value(),
|
||
randomize=self.randomize_check.isChecked()
|
||
)
|
||
self.generated_design = design
|
||
self._fill_design_matrix(design, factors, i_factors)
|
||
|
||
self.export_csv_btn.setEnabled(True)
|
||
QMessageBox.information(self, "Успех", f"Сгенерирован план для {len(factors)} факторов ({len(design)} опытов)")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка", f"Ошибка генерации плана: {str(e)}")
|
||
|
||
|
||
def _setup_results_table(self, n_experiments: int):
|
||
"""Настраивает таблицу результатов для ввода данных"""
|
||
# Получаем отклики (для простоты используем один отклик)
|
||
self.results_table.setRowCount(n_experiments)
|
||
self.results_table.setColumnCount(2)
|
||
self.results_table.setHorizontalHeaderLabels(["№ опыта", "Результат"])
|
||
|
||
for i in range(n_experiments):
|
||
self.results_table.setItem(i, 0, QTableWidgetItem(str(i)))
|
||
|
||
def _export_design_csv(self):
|
||
"""Экспортирует матрицу планирования в CSV"""
|
||
if not self.generated_design or self.design_matrix.rowCount() == 0:
|
||
QMessageBox.warning(self, "Предупреждение", "Нет данных для экспорта")
|
||
return
|
||
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self, "Сохранить план", "", "CSV (*.csv);;Все файлы (*)"
|
||
)
|
||
if filename:
|
||
if not filename.endswith('.csv'):
|
||
filename += '.csv'
|
||
|
||
try:
|
||
import csv
|
||
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 = self.design_matrix.horizontalHeaderItem(j)
|
||
headers.append(header.text() if header 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)}")
|
||
|
||
# ========== ВКЛАДКА 4: АНАЛИЗ ==========
|
||
|
||
def _create_analysis_tab(self):
|
||
tab = QWidget()
|
||
layout = QVBoxLayout(tab)
|
||
|
||
# Таблица для ввода результатов
|
||
results_box = QGroupBox("Результаты экспериментов")
|
||
results_layout = QVBoxLayout()
|
||
self.results_table = QTableWidget()
|
||
results_layout.addWidget(self.results_table)
|
||
results_box.setLayout(results_layout)
|
||
layout.addWidget(results_box)
|
||
|
||
# Кнопка анализа
|
||
self.analyze_btn = QPushButton("📈 Провести анализ")
|
||
self.analyze_btn.clicked.connect(self._perform_analysis)
|
||
layout.addWidget(self.analyze_btn)
|
||
|
||
# Вывод анализа
|
||
self.analysis_text = QTextEdit()
|
||
self.analysis_text.setReadOnly(True)
|
||
self.analysis_text.setMaximumHeight(250)
|
||
layout.addWidget(self.analysis_text)
|
||
|
||
return tab
|
||
|
||
def _perform_analysis(self):
|
||
"""Выполняет анализ результатов эксперимента"""
|
||
if not self.generated_design:
|
||
QMessageBox.warning(self, "Предупреждение", "Сначала сгенерируйте план эксперимента!")
|
||
return
|
||
|
||
# Получаем отклики
|
||
responses = [{'name': 'Отклик', 'unit': ''}]
|
||
|
||
# Собираем результаты
|
||
results = []
|
||
for i in range(self.results_table.rowCount()):
|
||
item = self.results_table.item(i, 1)
|
||
if item and item.text():
|
||
try:
|
||
results.append([float(item.text())])
|
||
except ValueError:
|
||
QMessageBox.warning(self, "Ошибка",
|
||
f"Неверное значение в опыте {i+1}")
|
||
return
|
||
else:
|
||
QMessageBox.warning(self, "Ошибка",
|
||
f"Не введены данные для опыта {i+1}")
|
||
return
|
||
|
||
if len(results) != len(self.generated_design):
|
||
QMessageBox.warning(self, "Ошибка",
|
||
f"Введено {len(results)} результатов, ожидается {len(self.generated_design)}")
|
||
return
|
||
|
||
try:
|
||
# Выполняем анализ
|
||
analysis = analyze_experiment(results, self.generated_design, responses)
|
||
|
||
# Выводим результаты
|
||
self.analysis_text.clear()
|
||
self.analysis_text.append("=" * 60)
|
||
self.analysis_text.append("РЕЗУЛЬТАТЫ АНАЛИЗА ЭКСПЕРИМЕНТА")
|
||
self.analysis_text.append("=" * 60)
|
||
|
||
for resp_name, stats in analysis.items():
|
||
self.analysis_text.append(f"\n📊 {resp_name}")
|
||
self.analysis_text.append("-" * 40)
|
||
self.analysis_text.append(f"Среднее значение: {stats['mean']:.4f}")
|
||
self.analysis_text.append(f"Дисперсия: {stats['variance']:.4f}")
|
||
self.analysis_text.append(f"Ст. отклонение: {stats['std_dev']:.4f}")
|
||
self.analysis_text.append(f"Коэф. вариации: {stats['cv']:.2f}%")
|
||
|
||
if stats['n_center'] > 1:
|
||
self.analysis_text.append(f"\nЦентральные точки (n={stats['n_center']}):")
|
||
self.analysis_text.append(f" Дисперсия воспроизводимости: {stats['center_variance']:.4f}")
|
||
|
||
if stats.get('fisher_ratio'):
|
||
self.analysis_text.append(f"\nКритерий Фишера: {stats['fisher_ratio']:.4f}")
|
||
if stats.get('model_adequate'):
|
||
self.analysis_text.append("✅ Модель адекватна")
|
||
else:
|
||
self.analysis_text.append("⚠️ Модель может быть неадекватна")
|
||
|
||
self.analysis_text.append("\n" + "=" * 60)
|
||
self.analysis_text.append("Анализ завершен")
|
||
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка", f"Ошибка анализа: {str(e)}")
|
||
|
||
# ========== МЕТОДЫ РАБОТЫ С JSON ==========
|
||
|
||
def save_project_to_json(self):
|
||
"""Сохраняет весь проект в JSON файл"""
|
||
if not JSON_SUPPORT:
|
||
QMessageBox.warning(self, "Предупреждение", "JSON поддержка не доступна")
|
||
return
|
||
|
||
filename, _ = QFileDialog.getSaveFileName(
|
||
self, "Сохранить проект", "", "JSON Files (*.json);;All Files (*)"
|
||
)
|
||
if not filename:
|
||
return
|
||
|
||
if not filename.endswith('.json'):
|
||
filename += '.json'
|
||
|
||
try:
|
||
project = self._collect_current_data()
|
||
project.save_to_file(filename)
|
||
QMessageBox.information(self, "Успех", f"Проект сохранён в {filename}")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка", f"Не удалось сохранить проект: {str(e)}")
|
||
|
||
def load_project_from_json(self):
|
||
"""Загружает проект из JSON файла"""
|
||
if not JSON_SUPPORT:
|
||
QMessageBox.warning(self, "Предупреждение", "JSON поддержка не доступна")
|
||
return
|
||
|
||
filename, _ = QFileDialog.getOpenFileName(
|
||
self, "Загрузить проект", "", "JSON Files (*.json);;All Files (*)"
|
||
)
|
||
if not filename:
|
||
return
|
||
|
||
try:
|
||
project = ProjectData.load_from_file(filename)
|
||
self._apply_project_data(project)
|
||
# QMessageBox.information(self, "Успех", f"Проект загружен из {filename}")
|
||
except FileNotFoundError:
|
||
QMessageBox.critical(self, "Ошибка", f"Файл не найден: {filename}")
|
||
except Exception as e:
|
||
QMessageBox.critical(self, "Ошибка", f"Не удалось загрузить проект: {str(e)}")
|
||
|
||
def _collect_current_data(self):
|
||
"""Собирает текущие данные из всех виджетов"""
|
||
# Данные калькулятора сред
|
||
reagents = []
|
||
for row in range(self.reagents_table.rowCount()):
|
||
name_item = self.reagents_table.item(row, 0)
|
||
percent_item = self.reagents_table.item(row, 1)
|
||
unit_widget = self.reagents_table.cellWidget(row, 2)
|
||
coeff_item = self.reagents_table.item(row, 3)
|
||
dilution_item = self.reagents_table.item(row, 4)
|
||
|
||
if name_item and percent_item and percent_item.text():
|
||
try:
|
||
reagent = ReagentData(
|
||
name=name_item.text(),
|
||
percentage=float(percent_item.text()),
|
||
unit=unit_widget.currentText() if unit_widget else "мг",
|
||
conversion_factor=float(coeff_item.text()) if coeff_item and coeff_item.text() else 1.0,
|
||
dilution_factor=float(dilution_item.text()) if dilution_item and dilution_item.text() else 1.0
|
||
)
|
||
reagents.append(reagent)
|
||
except ValueError:
|
||
pass
|
||
|
||
# Данные факторов эксперимента
|
||
factors = []
|
||
for row in range(self.factors_table.rowCount()):
|
||
name_item = self.factors_table.item(row, 0)
|
||
center_item = self.factors_table.item(row, 3)
|
||
low_item = self.factors_table.item(row, 7)
|
||
high_item = self.factors_table.item(row, 6)
|
||
step_item = self.factors_table.item(row, 4)
|
||
unit_item = self.factors_table.cellWidget(row, 8)
|
||
step_combo = self.factors_table.cellWidget(row, 5)
|
||
percent_item = self.factors_table.item(row, 1)
|
||
dilution_item = self.factors_table.item(row, 2)
|
||
|
||
if name_item and center_item and center_item.text():
|
||
try:
|
||
factor = FactorData(
|
||
name=name_item.text(),
|
||
center=float(center_item.text()),
|
||
low=float(low_item.text()) if low_item and low_item.text() else 0,
|
||
high=float(high_item.text()) if high_item and high_item.text() else 0,
|
||
step=float(step_item.text()) if step_item and step_item.text() else 0,
|
||
step_type=step_combo.currentText() if step_combo else "ед.",
|
||
unit=unit_item.currentText() if unit_item else "",
|
||
percentage=float(percent_item.text()) if percent_item and percent_item.text() else None,
|
||
dilution_factor=float(dilution_item.text()) if dilution_item and dilution_item.text() else None
|
||
)
|
||
factors.append(factor)
|
||
except ValueError:
|
||
pass
|
||
|
||
# Данные результатов (если есть)
|
||
results_data = None
|
||
if hasattr(self, 'generated_design') and self.generated_design:
|
||
results = []
|
||
for i in range(self.results_table.rowCount()):
|
||
item = self.results_table.item(i, 1)
|
||
if item and item.text():
|
||
try:
|
||
results.append([float(item.text())])
|
||
except ValueError:
|
||
pass
|
||
|
||
if results:
|
||
responses = [ResponseData(name="Отклик", unit="")]
|
||
results_data = ExperimentResultsData(
|
||
design=self.generated_design,
|
||
results=results,
|
||
responses=responses
|
||
)
|
||
|
||
# Создаём проект
|
||
project = create_new_project("Мой проект")
|
||
project.medium_total_volume = self.total_volume_spin.value()
|
||
project.medium_volume_unit = self.volume_unit_combo.currentText()
|
||
project.medium_solvent = self.solvent_input.text()
|
||
project.medium_reagents = reagents
|
||
project.experiment_factors = factors
|
||
project.experiment_center_points = self.center_points_spin.value()
|
||
project.experiment_randomize = self.randomize_check.isChecked()
|
||
project.experiment_results = results_data
|
||
|
||
return project
|
||
|
||
def _apply_project_data(self, project):
|
||
"""Применяет загруженные данные к GUI"""
|
||
self._loading_data = True # <- ОТКЛЮЧАЕМ ОБРАБОТЧИК
|
||
try:
|
||
|
||
# Применяем данные калькулятора сред
|
||
self.total_volume_spin.setValue(project.medium_total_volume)
|
||
|
||
index = self.volume_unit_combo.findText(project.medium_volume_unit)
|
||
if index >= 0:
|
||
self.volume_unit_combo.setCurrentIndex(index)
|
||
|
||
self.solvent_input.setText(project.medium_solvent)
|
||
|
||
# Очищаем и заполняем таблицу реагентов
|
||
self.reagents_table.setRowCount(0)
|
||
for reagent in project.medium_reagents:
|
||
row = self.reagents_table.rowCount()
|
||
self.reagents_table.insertRow(row)
|
||
self.reagents_table.setItem(row, 0, QTableWidgetItem(reagent.name))
|
||
self.reagents_table.setItem(row, 1, QTableWidgetItem(str(reagent.percentage)))
|
||
|
||
unit_combo = QComboBox()
|
||
unit_combo.addItems(["мг", "г", "кг", "мкг", "нг", "мл", "мкл", "л", "нл"])
|
||
unit_combo.setCurrentText(reagent.unit)
|
||
self.reagents_table.setCellWidget(row, 2, unit_combo)
|
||
|
||
self.reagents_table.setItem(row, 3, QTableWidgetItem(str(reagent.conversion_factor)))
|
||
self.reagents_table.setItem(row, 4, QTableWidgetItem(str(reagent.dilution_factor)))
|
||
self.reagents_table.setItem(row, 5, QTableWidgetItem(""))
|
||
|
||
|
||
|
||
# Применяем данные факторов эксперимента
|
||
self.exp_total_volume.setValue(project.experiment_total_volume)
|
||
self.exp_volume_unit.setCurrentText(project.experiment_volume_unit)
|
||
self.exp_solvent.setText(project.experiment_solvent)
|
||
self.factors_table.setRowCount(0)
|
||
for factor in project.experiment_factors:
|
||
if factor.percentage is not None:
|
||
percentage = str(factor.percentage)
|
||
if factor.dilution_factor is not None:
|
||
dilution = str(factor.dilution_factor)
|
||
self._add_factor_row(factor.name, percentage, dilution, str(factor.center),
|
||
str(factor.step), factor.step_type, str(factor.high),
|
||
str(factor.low), factor.unit)
|
||
# Применяем настройки эксперимента
|
||
self.center_points_spin.setValue(project.experiment_center_points)
|
||
self.randomize_check.setChecked(project.experiment_randomize)
|
||
|
||
# Если есть результаты, загружаем их
|
||
if project.experiment_results and project.experiment_results.design:
|
||
self.generated_design = project.experiment_results.design
|
||
# Обновляем отображение матрицы
|
||
self._refresh_design_matrix()
|
||
|
||
# Загружаем результаты в таблицу
|
||
if project.experiment_results.results:
|
||
for i, row_results in enumerate(project.experiment_results.results):
|
||
if i < self.results_table.rowCount() and row_results:
|
||
self.results_table.setItem(i, 1, QTableWidgetItem(str(row_results[0])))
|
||
|
||
# Переключаемся на вкладку с экспериментом
|
||
if self.tab_widget:
|
||
self.tab_widget.setCurrentIndex(0)
|
||
finally:
|
||
self._loading_data = False # <- ВКЛЮЧАЕМ ОБРАТНО
|
||
|
||
def _refresh_design_matrix(self):
|
||
"""Обновляет отображение матрицы планирования"""
|
||
if not self.generated_design:
|
||
return
|
||
|
||
all_factors = self._get_factors_from_table()
|
||
factors = get_active_factors(all_factors)
|
||
i_factors = get_inactive_factors(all_factors)
|
||
|
||
self._fill_design_matrix(self.generated_design, factors, i_factors)
|
||
|
||
n_exp = len(self.generated_design)
|
||
n_factors = len(factors)
|
||
|
||
if hasattr(self, 'export_csv_btn'):
|
||
self.export_csv_btn.setEnabled(True)
|
||
|
||
n_factorial = 2 ** n_factors
|
||
n_center = self.center_points_spin.value()
|
||
self.design_info.setText(
|
||
f"📊 Факторных точек: {n_factorial}, Центральных: {n_center}, Всего: {n_exp}"
|
||
)
|
||
self._setup_results_table(n_exp)
|
||
|
||
# ========== ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ ==========
|
||
|
||
def _fill_design_matrix(self, design, factors, i_factors):
|
||
"""Заполняет матрицу планирования (общая логика)"""
|
||
n_exp = len(design)
|
||
n_factors = len(factors)
|
||
n_i_factors = len(i_factors)
|
||
solvent_name = self.exp_solvent.text()
|
||
total_volume = self.exp_total_volume.value()
|
||
solvent_unit = self.exp_volume_unit.currentText()
|
||
|
||
self.design_matrix.setRowCount(n_exp)
|
||
self.design_matrix.setColumnCount(n_factors + n_i_factors + 3)
|
||
headers = [f['name'] for f in factors] + [f['name'] for f in i_factors] + [solvent_name] + ["Тип"] + ["Отклик"]
|
||
self.design_matrix.setHorizontalHeaderLabels(headers)
|
||
|
||
for exp_idx, exp in enumerate(design):
|
||
remaining = convert_units(total_volume, solvent_unit)
|
||
|
||
# Активные факторы
|
||
for f_idx in range(n_factors):
|
||
value = exp[f"Фактор_{f_idx+1}"]['natural']
|
||
unit = factors[f_idx]['unit']
|
||
remaining -= convert_units(value, unit)
|
||
item = self._create_item(value, unit, exp.get('is_center', False))
|
||
if value == factors[f_idx]['high']:
|
||
item.setBackground(QColor(200, 250, 200))
|
||
elif value ==factors[f_idx]['low']:
|
||
item.setBackground(QColor(250, 200, 200))
|
||
else:
|
||
item.setBackground(QColor(230, 230, 230))
|
||
self.design_matrix.setItem(exp_idx, f_idx, item)
|
||
|
||
# Неактивные факторы
|
||
for f_idx in range(n_i_factors):
|
||
value = i_factors[f_idx]['center']
|
||
unit = i_factors[f_idx]['unit']
|
||
remaining -= convert_units(value, unit)
|
||
item = self._create_item(value, unit, True)
|
||
item.setBackground(QColor(230, 230, 230))
|
||
self.design_matrix.setItem(exp_idx, n_factors + f_idx, item)
|
||
|
||
# Растворитель
|
||
remaining = convert_units(remaining, 'мкл', solvent_unit)
|
||
item = self._create_item(remaining, solvent_unit, False)
|
||
item.setBackground(QColor(200, 200, 255))
|
||
self.design_matrix.setItem(exp_idx, n_factors + n_i_factors, item)
|
||
|
||
# Тип опыта
|
||
type_item = QTableWidgetItem(f"Центр #{exp['center_num']}" if exp.get('is_center') else "Факторная")
|
||
if exp.get('is_center'):
|
||
type_item.setBackground(QColor(200, 200, 200))
|
||
self.design_matrix.setItem(exp_idx, n_factors + n_i_factors + 1, type_item)
|
||
|
||
# Отклик
|
||
self.design_matrix.setItem(exp_idx, n_factors + n_i_factors + 2, QTableWidgetItem(""))
|
||
|
||
self.design_matrix.resizeColumnsToContents()
|
||
|
||
def _create_item(self, value, unit, is_center):
|
||
"""Создаёт QTableWidgetItem с форматированием"""
|
||
display = self._format_number(value)
|
||
if unit:
|
||
display += f" {unit}"
|
||
item = QTableWidgetItem(display)
|
||
if is_center:
|
||
item.setBackground(QColor(255, 255, 200))
|
||
return item
|
||
|
||
|
||
def _format_number(self, value: float) -> str:
|
||
"""Форматирует число для отображения"""
|
||
if value == int(value):
|
||
return str(int(value))
|
||
# Убираем лишние нули
|
||
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 main():
|
||
app = QApplication(sys.argv)
|
||
window = MainWindow()
|
||
window.show()
|
||
sys.exit(app.exec_())
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|