Compare commits
1 Commits
main
..
33b763678d
| Author | SHA1 | Date | |
|---|---|---|---|
| 33b763678d |
@@ -1,9 +1,5 @@
|
|||||||
# Python
|
# Python
|
||||||
backup
|
backup
|
||||||
Backup
|
|
||||||
*.csv
|
|
||||||
*.xls
|
|
||||||
*.xlsx
|
|
||||||
*.json
|
*.json
|
||||||
__pycache__/
|
__pycache__/
|
||||||
*.py[cod]
|
*.py[cod]
|
||||||
@@ -50,6 +46,3 @@ Thumbs.db
|
|||||||
|
|
||||||
# Локальные настройки
|
# Локальные настройки
|
||||||
*.local
|
*.local
|
||||||
|
|
||||||
*.sh
|
|
||||||
*.bash
|
|
||||||
|
|||||||
@@ -0,0 +1,238 @@
|
|||||||
|
# 🧪 Цифровой помощник биохимика
|
||||||
|
|
||||||
|
Биотехнологические инструменты для лаборатории: калькулятор питательных сред и планирование полнофакторного эксперимента (DoE).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 Содержание
|
||||||
|
|
||||||
|
- [Возможности](#-возможности)
|
||||||
|
- [Установка](#-установка)
|
||||||
|
- [Запуск](#-запуск)
|
||||||
|
- [Структура проекта](#-структура-проекта)
|
||||||
|
- [Калькулятор питательных сред](#-калькулятор-питательных-сред)
|
||||||
|
- [Планирование эксперимента](#-планирование-эксперимента)
|
||||||
|
- [Сохранение и загрузка](#-сохранение-и-загрузка)
|
||||||
|
- [Требования](#-требования)
|
||||||
|
- [Лицензия](#-лицензия)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✨ Возможности
|
||||||
|
|
||||||
|
### 🔬 Калькулятор питательных сред
|
||||||
|
- Расчёт состава питательной среды по процентному содержанию компонентов
|
||||||
|
- Поддержка массовых (нг, мкг, мг, г, кг) и объёмных (нл, мкл, мл, л) единиц
|
||||||
|
- Учёт коэффициента пересчёта для каждого реагента
|
||||||
|
- Учёт разбавления реагентов (фактор разбавления)
|
||||||
|
- Автоматический расчёт необходимого количества растворителя
|
||||||
|
- Сохранение и загрузка рецептов в JSON
|
||||||
|
|
||||||
|
### 📊 Планирование эксперимента (DoE)
|
||||||
|
- Полнофакторный план 2ᵏ (k факторов)
|
||||||
|
- Генерация матрицы планирования с центральными точками
|
||||||
|
- Рэндомизация порядка опытов
|
||||||
|
- Ввод и анализ результатов экспериментов
|
||||||
|
- Регрессионный анализ (среднее, дисперсия, стандартное отклонение, CV)
|
||||||
|
- Критерий Фишера для проверки адекватности модели
|
||||||
|
- Экспорт матрицы в CSV
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 Установка
|
||||||
|
|
||||||
|
### Клонирование репозитория
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd nutrient_medium_pyqt
|
||||||
|
2. Создание виртуального окружения (рекомендуется)
|
||||||
|
bash
|
||||||
|
python3 -m venv venv
|
||||||
|
source venv/bin/activate # Linux/Mac
|
||||||
|
# или
|
||||||
|
venv\Scripts\activate # Windows
|
||||||
|
```
|
||||||
|
### Установка зависимостей
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
### ▶️ Запуск
|
||||||
|
Из командной строки
|
||||||
|
```bash
|
||||||
|
python3 main.py
|
||||||
|
```
|
||||||
|
Через скрипт
|
||||||
|
``` bash
|
||||||
|
chmod +x run.sh
|
||||||
|
./run.sh
|
||||||
|
```
|
||||||
|
## 📁 Структура проекта
|
||||||
|
```text
|
||||||
|
nutrient_medium_pyqt/
|
||||||
|
├── main.py # Точка входа
|
||||||
|
├── run.sh # Скрипт запуска
|
||||||
|
├── requirements.txt # Зависимости
|
||||||
|
├── README.md # Документация
|
||||||
|
│
|
||||||
|
└── src/ # Исходный код
|
||||||
|
├── models/ # Модели данных
|
||||||
|
│ ├── reagent.py # Класс Reagent
|
||||||
|
│ ├── medium_model.py # Модель расчёта сред
|
||||||
|
│ └── experiment_model.py # Модель планирования эксперимента
|
||||||
|
│
|
||||||
|
├── views/ # GUI компоненты
|
||||||
|
│ ├── main_window.py # Главное окно
|
||||||
|
│ ├── medium_view.py # Окно калькулятора сред
|
||||||
|
│ └── experiment_view.py # Окно планирования эксперимента
|
||||||
|
│
|
||||||
|
└── controllers/ # Контроллеры
|
||||||
|
├── medium_controller.py # Логика калькулятора
|
||||||
|
└── experiment_controller.py # Логика планирования
|
||||||
|
```
|
||||||
|
## Основные поля
|
||||||
|
|
||||||
|
| Поле | Описание |
|
||||||
|
|:-----|:----------|
|
||||||
|
| **Общее количество** | Общий объём/масса готовой среды |
|
||||||
|
| **Растворитель** | Название растворителя (вода, буфер и т.д.) |
|
||||||
|
| **Название** | Имя реагента |
|
||||||
|
| **%** | Процентное содержание в среде |
|
||||||
|
| **Единица** | Единица измерения реагента |
|
||||||
|
| **Коэфф.** | Коэффициент пересчёта (например, для солей-гидратов) |
|
||||||
|
| **Разбавление (x)** | Во сколько раз разбавлен исходный раствор |
|
||||||
|
|
||||||
|
## Пример использования
|
||||||
|
|
||||||
|
| Шаг | Действие |
|
||||||
|
|:----|:----------|
|
||||||
|
| 1 | Укажите общий объём среды (например, 1000 мл) |
|
||||||
|
| 2 | Добавьте реагенты с их процентным содержанием |
|
||||||
|
| 3 | При необходимости укажите коэффициент пересчёта и разбавление |
|
||||||
|
| 4 | Нажмите **"Рассчитать"** |
|
||||||
|
| 5 | В столбце **"Количество"** отобразятся необходимые объёмы/массы |
|
||||||
|
|
||||||
|
## 📈 Планирование эксперимента
|
||||||
|
|
||||||
|
### Вкладка "Параметры эксперимента"
|
||||||
|
|
||||||
|
#### Факторы
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|:---------|:----------|
|
||||||
|
| **Фактор** | Название независимой переменной |
|
||||||
|
| **Нулевой уровень (0)** | Базовое значение |
|
||||||
|
| **Шаг** | Интервал варьирования |
|
||||||
|
| **Верхний уровень (+1)** | Нулевой уровень + шаг *(вычисляется автоматически)* |
|
||||||
|
| **Нижний уровень (-1)** | Нулевой уровень – шаг *(вычисляется автоматически)* |
|
||||||
|
| **Единица измерения** | °C, pH, г/л и т.д. |
|
||||||
|
|
||||||
|
#### Отклики
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|:---------|:----------|
|
||||||
|
| **Зависимые переменные** | OD600, концентрация продукта и т.д. |
|
||||||
|
|
||||||
|
#### Настройки
|
||||||
|
|
||||||
|
| Параметр | Описание |
|
||||||
|
|:---------|:----------|
|
||||||
|
| **Количество центральных точек** | Для оценки дисперсии воспроизводимости |
|
||||||
|
| **Рэндомизация порядка опытов** | Случайный порядок выполнения |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Вкладка "Матрица планирования"
|
||||||
|
|
||||||
|
| Функция | Описание |
|
||||||
|
|:---------|:----------|
|
||||||
|
| **Отображение плана** | Сгенерированный план эксперимента |
|
||||||
|
| **Факторные точки** | Помечены комбинацией уровней (+/–) |
|
||||||
|
| **Центральные точки** | Выделены жёлтым цветом |
|
||||||
|
| **Экспорт в CSV** | Сохраняет матрицу в файл |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Вкладка "Анализ результатов"
|
||||||
|
|
||||||
|
#### Порядок работы
|
||||||
|
|
||||||
|
1. Введите результаты экспериментов в таблицу
|
||||||
|
2. Нажмите **"Провести регрессионный анализ"**
|
||||||
|
|
||||||
|
#### Результаты анализа
|
||||||
|
|
||||||
|
| Показатель | Описание |
|
||||||
|
|:-----------|:----------|
|
||||||
|
| **Среднее значение отклика** | Центральная тенденция данных |
|
||||||
|
| **Общая дисперсия** | Разброс данных относительно среднего |
|
||||||
|
| **Стандартное отклонение** | Квадратный корень из дисперсии |
|
||||||
|
| **Коэффициент вариации (CV)** | Относительная мера разброса данных |
|
||||||
|
| **Дисперсия воспроизводимости** | Оценивается по центральным точкам |
|
||||||
|
| **Критерий Фишера (F-отношение)** | Проверка адекватности модели |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 💾 Сохранение и загрузка
|
||||||
|
|
||||||
|
### Калькулятор питательных сред
|
||||||
|
|
||||||
|
| Кнопка | Действие |
|
||||||
|
|:-------|:----------|
|
||||||
|
| **💾 Сохранить** | Сохранить рецепт в JSON-файл |
|
||||||
|
| **📂 Загрузить** | Загрузить сохранённый рецепт |
|
||||||
|
|
||||||
|
### Планирование эксперимента
|
||||||
|
|
||||||
|
| Кнопка | Действие |
|
||||||
|
|:-------|:----------|
|
||||||
|
| **📊 Экспорт в CSV** | Сохранить матрицу планирования в CSV-файл |
|
||||||
|
|
||||||
|
## 📦 Требования
|
||||||
|
Пакет Версия Назначение
|
||||||
|
PyQt5 ≥ 5.15.0 Графический интерфейс
|
||||||
|
numpy ≥ 1.19.0 Математические вычисления
|
||||||
|
Проверка установки
|
||||||
|
```bash
|
||||||
|
python3 -c "import PyQt5; import numpy; print('OK')"
|
||||||
|
```
|
||||||
|
📝 Формат JSON (калькулятор сред)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"total_amount": 1000.0,
|
||||||
|
"amount_unit": "мл",
|
||||||
|
"solvent": "Вода",
|
||||||
|
"reagents": [
|
||||||
|
{
|
||||||
|
"name": "Глюкоза",
|
||||||
|
"percentage": 2.0,
|
||||||
|
"unit": "г",
|
||||||
|
"conversion_factor": 1.0,
|
||||||
|
"dilution_factor": 1.0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
## 🐛 Устранение неполадок
|
||||||
|
|
||||||
|
Ошибка "ModuleNotFoundError: No module named 'PyQt5'"
|
||||||
|
```bash
|
||||||
|
pip install PyQt5
|
||||||
|
Ошибка "No module named 'numpy'"
|
||||||
|
bash
|
||||||
|
pip install numpy
|
||||||
|
Проблемы с отображением кириллицы
|
||||||
|
Убедитесь, что в системе установлены русские шрифты
|
||||||
|
```
|
||||||
|
📄 Лицензия
|
||||||
|
© 2026 Цифровой помощник биохимика
|
||||||
|
|
||||||
|
Версия: 1.0.0
|
||||||
|
|
||||||
|
🙏 Благодарности
|
||||||
|
Разработано с использованием:
|
||||||
|
|
||||||
|
PyQt5 — GUI framework
|
||||||
|
|
||||||
|
NumPy — численные вычисления
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
"""Библиотека расчётов для биохимика"""
|
|
||||||
|
|
||||||
from .medium import (
|
|
||||||
calculate_medium_composition,
|
|
||||||
convert_units,
|
|
||||||
VOLUME_UNITS,
|
|
||||||
MASS_UNITS
|
|
||||||
)
|
|
||||||
|
|
||||||
from .doe import (
|
|
||||||
generate_factorial_design,
|
|
||||||
analyze_experiment,
|
|
||||||
calculate_factor_levels,
|
|
||||||
create_factor_from_reagent, # Добавлено
|
|
||||||
FACTOR_TYPES
|
|
||||||
)
|
|
||||||
|
|
||||||
__all__ = [
|
|
||||||
'calculate_medium_composition',
|
|
||||||
'convert_units',
|
|
||||||
'VOLUME_UNITS',
|
|
||||||
'MASS_UNITS',
|
|
||||||
'generate_factorial_design',
|
|
||||||
'analyze_experiment',
|
|
||||||
'calculate_factor_levels',
|
|
||||||
'create_factor_from_reagent',
|
|
||||||
'FACTOR_TYPES',
|
|
||||||
]
|
|
||||||
@@ -1,288 +0,0 @@
|
|||||||
"""
|
|
||||||
Планирование эксперимента (DoE)
|
|
||||||
|
|
||||||
Модуль для генерации полнофакторных планов экспериментов
|
|
||||||
и анализа результатов.
|
|
||||||
|
|
||||||
Основные функции:
|
|
||||||
- generate_factorial_design() - генерация плана
|
|
||||||
- analyze_experiment() - статистический анализ
|
|
||||||
- calculate_factor_levels() - расчёт уровней факторов
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import List, Dict, Tuple, Optional
|
|
||||||
import random
|
|
||||||
import numpy as np
|
|
||||||
from calculations.medium import (
|
|
||||||
calculate_medium_composition,
|
|
||||||
convert_units,
|
|
||||||
VOLUME_UNITS,
|
|
||||||
MASS_UNITS
|
|
||||||
)
|
|
||||||
from calculations.models.project_data import FactorData
|
|
||||||
# Типы расчёта шага
|
|
||||||
FACTOR_TYPES = {
|
|
||||||
'absolute': 'ед.', # абсолютный шаг
|
|
||||||
'relative': '%', # относительный шаг (процент от нулевого уровня)
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_factor_levels(
|
|
||||||
factor: 'FactorData',
|
|
||||||
total_volume: float = 1000,
|
|
||||||
volume_unit: str = "мл"
|
|
||||||
) -> 'FactorData':
|
|
||||||
"""Рассчитывает high/low уровни фактора через калькулятор сред"""
|
|
||||||
|
|
||||||
# Функция для расчёта количества реагента при заданном проценте
|
|
||||||
def calc_amount(percentage: float) -> float:
|
|
||||||
reagents = [{
|
|
||||||
'name': factor.name,
|
|
||||||
'percentage': max(0, percentage),
|
|
||||||
'unit': factor.unit,
|
|
||||||
'dilution_factor': factor.dilution_factor or 1.0
|
|
||||||
}]
|
|
||||||
result = calculate_medium_composition(total_volume, volume_unit, reagents)
|
|
||||||
return result['reagents'][0]['calculated_amount']
|
|
||||||
|
|
||||||
|
|
||||||
# Определяем проценты для верхнего и нижнего уровней
|
|
||||||
if factor.step_type == "%":
|
|
||||||
low = calc_amount(factor.percentage - factor.step) # low - нижний уровень
|
|
||||||
high = calc_amount(factor.percentage + factor.step) # high - верхний уровень
|
|
||||||
else: # "ед."
|
|
||||||
# Конвертируем абсолютный шаг в проценты
|
|
||||||
low = calc_amount(factor.percentage) - factor.step
|
|
||||||
high = calc_amount(factor.center) + factor.step
|
|
||||||
|
|
||||||
# ВОЗВРАЩАЕМ НОВЫЙ ОБЪЕКТ FactorData
|
|
||||||
return FactorData(
|
|
||||||
name=factor.name,
|
|
||||||
center=factor.center,
|
|
||||||
low=low, # low - нижний уровень
|
|
||||||
high=high, # high - верхний уровень
|
|
||||||
step=factor.step,
|
|
||||||
step_type=factor.step_type,
|
|
||||||
unit=factor.unit,
|
|
||||||
percentage=factor.percentage or factor.center,
|
|
||||||
dilution_factor=factor.dilution_factor
|
|
||||||
)
|
|
||||||
|
|
||||||
def calculate_all_factors_levels(factors: List['FactorData'], **kwargs) -> List['FactorData']:
|
|
||||||
"""Рассчитывает уровни для всех факторов"""
|
|
||||||
return [calculate_factor_levels(f, **kwargs) for f in factors]
|
|
||||||
|
|
||||||
def get_active_factors(factors):
|
|
||||||
return [f for f in factors if f['step'] != 0]
|
|
||||||
|
|
||||||
def get_inactive_factors(factors):
|
|
||||||
return [f for f in factors if f['step'] == 0]
|
|
||||||
|
|
||||||
def generate_factorial_design(
|
|
||||||
factors: List[Dict],
|
|
||||||
center_points: int = 3,
|
|
||||||
randomize: bool = True
|
|
||||||
) -> List[Dict]:
|
|
||||||
"""
|
|
||||||
Генерирует полнофакторный план 2^k с правильным порядком изменения факторов:
|
|
||||||
- фактор 1 меняется через 1 эксперимент (2^0)
|
|
||||||
- фактор 2 меняется через 2 эксперимента (2^1)
|
|
||||||
- фактор 3 меняется через 4 эксперимента (2^2)
|
|
||||||
- и т.д.
|
|
||||||
"""
|
|
||||||
|
|
||||||
k = len(factors)
|
|
||||||
if k == 0:
|
|
||||||
return []
|
|
||||||
|
|
||||||
n_factorial = 2 ** k
|
|
||||||
design = []
|
|
||||||
|
|
||||||
# Генерация факторных точек в правильном порядке (двоичный счётчик)
|
|
||||||
for i in range(n_factorial):
|
|
||||||
experiment = {}
|
|
||||||
for j in range(k):
|
|
||||||
# Правильный порядок битов: младший бит - первый фактор
|
|
||||||
# (j) - для прямого порядка: фактор 1 меняется чаще всего
|
|
||||||
coded_level = -1 if (i >> j) & 1 == 0 else 1
|
|
||||||
|
|
||||||
natural_value = factors[j]['low'] if coded_level == -1 else factors[j]['high']
|
|
||||||
|
|
||||||
experiment[f"Фактор_{j+1}"] = {
|
|
||||||
'coded': coded_level,
|
|
||||||
'natural': natural_value,
|
|
||||||
'name': factors[j]['name'],
|
|
||||||
'unit': factors[j].get('unit', '')
|
|
||||||
}
|
|
||||||
design.append(experiment)
|
|
||||||
|
|
||||||
# Добавление центральных точек
|
|
||||||
for i in range(center_points):
|
|
||||||
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].get('unit', '')
|
|
||||||
}
|
|
||||||
center_experiment['is_center'] = True
|
|
||||||
center_experiment['center_num'] = i + 1
|
|
||||||
design.append(center_experiment)
|
|
||||||
|
|
||||||
# Перемешивание порядка (опционально)
|
|
||||||
if randomize:
|
|
||||||
random.shuffle(design)
|
|
||||||
|
|
||||||
return design
|
|
||||||
|
|
||||||
|
|
||||||
def analyze_experiment(
|
|
||||||
results: List[List[float]],
|
|
||||||
design: List[Dict],
|
|
||||||
responses: List[Dict]
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
ПРОВОДИТ СТАТИСТИЧЕСКИЙ АНАЛИЗ РЕЗУЛЬТАТОВ ЭКСПЕРИМЕНТА
|
|
||||||
|
|
||||||
ПАРАМЕТРЫ:
|
|
||||||
----------
|
|
||||||
results : List[List[float]]
|
|
||||||
Матрица результатов. Каждая строка - эксперимент,
|
|
||||||
каждый столбец - отклик.
|
|
||||||
Размер: [n_experiments, n_responses]
|
|
||||||
|
|
||||||
design : List[Dict]
|
|
||||||
План эксперимента, возвращённый generate_factorial_design()
|
|
||||||
|
|
||||||
responses : List[Dict]
|
|
||||||
Список откликов. Каждый отклик - словарь с ключами:
|
|
||||||
- name (str): название отклика
|
|
||||||
- unit (str): единица измерения
|
|
||||||
|
|
||||||
ВОЗВРАЩАЕТ:
|
|
||||||
-----------
|
|
||||||
Dict
|
|
||||||
Словарь с анализом для каждого отклика:
|
|
||||||
{
|
|
||||||
'Название отклика': {
|
|
||||||
'mean': float, # среднее значение всех опытов
|
|
||||||
'variance': float, # общая дисперсия
|
|
||||||
'std_dev': float, # стандартное отклонение
|
|
||||||
'cv': float, # коэффициент вариации (%)
|
|
||||||
'factorial_values': list, # значения в факторных точках
|
|
||||||
'center_values': list, # значения в центральных точках
|
|
||||||
'center_variance': float, # дисперсия воспроизводимости
|
|
||||||
'n_factorial': int, # количество факторных точек
|
|
||||||
'n_center': int, # количество центральных точек
|
|
||||||
'fisher_ratio': float, # критерий Фишера (если применимо)
|
|
||||||
'model_adequate': bool|None # адекватность модели
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ПРИМЕР ИСПОЛЬЗОВАНИЯ:
|
|
||||||
---------------------
|
|
||||||
>>> factors = [{'name': 'X', 'low': 0, 'high': 10, 'center': 5, 'unit': ''}]
|
|
||||||
>>> design = generate_factorial_design(factors, center_points=2)
|
|
||||||
>>> results = [[2.0], [8.0], [5.1], [4.9]] # 2 факторные + 2 центральные
|
|
||||||
>>> responses = [{'name': 'Выход', 'unit': '%'}]
|
|
||||||
>>> analysis = analyze_experiment(results, design, responses)
|
|
||||||
>>> print(analysis['Выход']['mean'])
|
|
||||||
5.0
|
|
||||||
"""
|
|
||||||
|
|
||||||
analysis = {}
|
|
||||||
|
|
||||||
for resp_idx, response in enumerate(responses):
|
|
||||||
resp_name = response.get('name', f'Отклик_{resp_idx+1}')
|
|
||||||
|
|
||||||
# Собираем все значения для этого отклика
|
|
||||||
y_values = [results[i][resp_idx] for i in range(len(results))]
|
|
||||||
|
|
||||||
# Базовые статистики
|
|
||||||
mean_y = np.mean(y_values)
|
|
||||||
variance = np.var(y_values, ddof=1) if len(y_values) > 1 else 0
|
|
||||||
std_dev = np.std(y_values, ddof=1) if len(y_values) > 1 else 0
|
|
||||||
cv = (std_dev / mean_y) * 100 if mean_y != 0 else 0
|
|
||||||
|
|
||||||
# Разделяем факторные и центральные точки
|
|
||||||
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])
|
|
||||||
|
|
||||||
# Анализ воспроизводимости
|
|
||||||
center_variance = np.var(center_y, ddof=1) if len(center_y) > 1 else 0
|
|
||||||
|
|
||||||
# Критерий Фишера для проверки адекватности модели
|
|
||||||
fisher_ratio = None
|
|
||||||
model_adequate = None
|
|
||||||
|
|
||||||
if len(center_y) > 1 and len(factorial_y) > 1:
|
|
||||||
factorial_variance = np.var(factorial_y, ddof=1)
|
|
||||||
if factorial_variance > 0 and center_variance > 0:
|
|
||||||
fisher_ratio = max(factorial_variance, center_variance) / min(factorial_variance, center_variance)
|
|
||||||
# Критическое значение F (приблизительное, для p=0.05)
|
|
||||||
# Более точное требует знания степеней свободы
|
|
||||||
model_adequate = fisher_ratio < 4.0
|
|
||||||
|
|
||||||
analysis[resp_name] = {
|
|
||||||
'mean': mean_y,
|
|
||||||
'variance': variance,
|
|
||||||
'std_dev': std_dev,
|
|
||||||
'cv': cv,
|
|
||||||
'factorial_values': factorial_y,
|
|
||||||
'center_values': center_y,
|
|
||||||
'center_variance': center_variance,
|
|
||||||
'n_factorial': len(factorial_y),
|
|
||||||
'n_center': len(center_y),
|
|
||||||
'fisher_ratio': fisher_ratio,
|
|
||||||
'model_adequate': model_adequate
|
|
||||||
}
|
|
||||||
|
|
||||||
return analysis
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def create_factor_from_reagent(
|
|
||||||
reagent: Dict,
|
|
||||||
total_volume: float,
|
|
||||||
volume_unit: str,
|
|
||||||
step_percent: float = 10.0
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
СОЗДАЁТ ФАКТОР ИЗ РЕАГЕНТА (для интеграции калькулятора и DoE)
|
|
||||||
|
|
||||||
Преобразует рассчитанный реагент в фактор для планирования эксперимента.
|
|
||||||
|
|
||||||
Параметры:
|
|
||||||
reagent: рассчитанный реагент (из calculate_medium_composition)
|
|
||||||
total_volume: общий объём среды
|
|
||||||
volume_unit: единица объёма
|
|
||||||
step_percent: шаг варьирования в процентах от нулевого уровня
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
Dict: фактор для использования в generate_factorial_design()
|
|
||||||
"""
|
|
||||||
center_value = reagent.get('undiluted_amount', reagent.get('calculated_amount', 0))
|
|
||||||
step_value = center_value * step_percent / 100
|
|
||||||
|
|
||||||
high_level, low_level = calculate_factor_levels(
|
|
||||||
center_value, step_value, "ед."
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'name': reagent['name'],
|
|
||||||
'center': center_value,
|
|
||||||
'low': low_level,
|
|
||||||
'high': high_level,
|
|
||||||
'step': step_value,
|
|
||||||
'step_type': 'абс',
|
|
||||||
'unit': reagent.get('unit', volume_unit),
|
|
||||||
'percentage': reagent.get('percentage', 0),
|
|
||||||
'dilution_factor': reagent.get('dilution_factor', 1.0)
|
|
||||||
}
|
|
||||||
@@ -1,210 +0,0 @@
|
|||||||
"""
|
|
||||||
Расчёт питательных сред
|
|
||||||
|
|
||||||
Этот модуль содержит функции для расчёта состава питательных сред
|
|
||||||
на основе процентов, коэффициентов конверсии и разбавления.
|
|
||||||
|
|
||||||
Основная функция: calculate_medium_composition()
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import List, Dict, Tuple
|
|
||||||
|
|
||||||
# Константы для конверсии единиц измерения
|
|
||||||
# Базовые единицы: мкл для объёма, мг для массы
|
|
||||||
VOLUME_UNITS = {
|
|
||||||
'нл': 0.001, # нанолитры -> микролитры
|
|
||||||
'мкл': 1.0, # микролитры (база)
|
|
||||||
'мл': 1000.0, # миллилитры -> микролитры
|
|
||||||
'л': 1000000.0, # литры -> микролитры
|
|
||||||
}
|
|
||||||
|
|
||||||
MASS_UNITS = {
|
|
||||||
'нг': 0.000001, # нанограммы -> миллиграммы
|
|
||||||
'мкг': 0.001, # микрограммы -> миллиграммы
|
|
||||||
'мг': 1.0, # миллиграммы (база)
|
|
||||||
'г': 1000.0, # граммы -> миллиграммы
|
|
||||||
'кг': 1000000.0, # килограммы -> миллиграммы
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def convert_units(value: float, from_unit: str, to_unit: str = None) -> float:
|
|
||||||
"""
|
|
||||||
Конвертирует значение между единицами объёма или массы
|
|
||||||
|
|
||||||
Параметры:
|
|
||||||
value: числовое значение
|
|
||||||
from_unit: исходная единица (например "мл" или "мг")
|
|
||||||
to_unit: целевая единица (если None, конвертирует в базовую)
|
|
||||||
|
|
||||||
Возвращает:
|
|
||||||
float: сконвертированное значение
|
|
||||||
|
|
||||||
Пример:
|
|
||||||
>>> convert_units(100, 'мл') # конвертирует в базовую (мкл)
|
|
||||||
100000.0
|
|
||||||
>>> convert_units(500, 'мкл', 'мл')
|
|
||||||
0.5
|
|
||||||
"""
|
|
||||||
# Определяем тип единицы (объём или масса)
|
|
||||||
if from_unit in VOLUME_UNITS:
|
|
||||||
units_map = VOLUME_UNITS
|
|
||||||
elif from_unit in MASS_UNITS:
|
|
||||||
units_map = MASS_UNITS
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Неизвестная единица измерения: {from_unit}")
|
|
||||||
# Конвертируем в базовую единицу (мкл для объёма, мг для массы)
|
|
||||||
value_in_base = value * units_map[from_unit]
|
|
||||||
# Если нужна конвертация в другую единицу
|
|
||||||
if from_unit and to_unit in units_map:
|
|
||||||
return value_in_base / units_map[to_unit]
|
|
||||||
|
|
||||||
return value_in_base
|
|
||||||
|
|
||||||
|
|
||||||
def calculate_medium_composition(
|
|
||||||
total_volume: float,
|
|
||||||
volume_unit: str,
|
|
||||||
reagents: List[Dict],
|
|
||||||
solvent_name: str = "Вода"
|
|
||||||
) -> Dict:
|
|
||||||
"""
|
|
||||||
РАССЧИТЫВАЕТ СОСТАВ ПИТАТЕЛЬНОЙ СРЕДЫ
|
|
||||||
|
|
||||||
Эта функция является основной для расчёта питательных сред.
|
|
||||||
|
|
||||||
ВХОДНЫЕ ПАРАМЕТРЫ:
|
|
||||||
---------------
|
|
||||||
total_volume : float
|
|
||||||
Общий объём/количество среды (например 1000)
|
|
||||||
|
|
||||||
volume_unit : str
|
|
||||||
Единица измерения общего объёма: "нл", "мкл", "мл", "л"
|
|
||||||
|
|
||||||
reagents : List[Dict]
|
|
||||||
Список реагентов. Каждый реагент - словарь с ключами:
|
|
||||||
- name (str): название реагента
|
|
||||||
- percentage (float): процентное содержание в среде (0-100)
|
|
||||||
- unit (str): единица измерения реагента (нг, мкг, мг, г, кг, нл, мкл, мл, л)
|
|
||||||
- dilution_factor (float): фактор разбавления (необяз., по умолч. 1.0)
|
|
||||||
|
|
||||||
Пример реагента:
|
|
||||||
{
|
|
||||||
'name': 'Глюкоза',
|
|
||||||
'percentage': 2.5,
|
|
||||||
'unit': 'г',
|
|
||||||
'dilution_factor': 1.0
|
|
||||||
}
|
|
||||||
|
|
||||||
solvent_name : str, optional
|
|
||||||
Название растворителя (по умолчанию "Вода")
|
|
||||||
|
|
||||||
ВОЗВРАЩАЕМЫЙ СЛОВАРЬ:
|
|
||||||
--------------------
|
|
||||||
{
|
|
||||||
'total_volume': float, # Исходный объём
|
|
||||||
'total_unit': str, # Единица измерения
|
|
||||||
'solvent_name': str, # Название растворителя
|
|
||||||
'solvent_volume': float, # Объём растворителя
|
|
||||||
'solvent_percentage': float, # Процент растворителя
|
|
||||||
'reagents': List[Dict] # Список реагентов с рассчитанными количествами
|
|
||||||
}
|
|
||||||
|
|
||||||
Каждый реагент в возвращаемом списке содержит:
|
|
||||||
- все исходные поля
|
|
||||||
- calculated_amount (float): рассчитанное количество реагента
|
|
||||||
- undiluted_amount (float): количество до разбавления
|
|
||||||
- amount_unit (str): единица измерения (скопирована из unit)
|
|
||||||
|
|
||||||
ПРИМЕР ИСПОЛЬЗОВАНИЯ (в CLI):
|
|
||||||
-----------------------------
|
|
||||||
>>> reagents = [
|
|
||||||
... {'name': 'Глюкоза', 'percentage': 2.0, 'unit': 'г', 'conversion_factor': 1.0},
|
|
||||||
... {'name': 'Пептон', 'percentage': 1.0, 'unit': 'г', 'conversion_factor': 1.0}
|
|
||||||
... ]
|
|
||||||
>>> result = calculate_medium_composition(1000, 'мл', reagents)
|
|
||||||
>>> print(f"Растворитель: {result['solvent_volume']} мл")
|
|
||||||
Растворитель: 970.0 мл
|
|
||||||
>>> for r in result['reagents']:
|
|
||||||
... print(f"{r['name']}: {r['calculated_amount']} {r['unit']}")
|
|
||||||
Глюкоза: 20.0 г
|
|
||||||
Пептон: 10.0 г
|
|
||||||
"""
|
|
||||||
|
|
||||||
# Проверка входных данных
|
|
||||||
|
|
||||||
if total_volume <= 0:
|
|
||||||
raise ValueError(f"Общий объём должен быть положительным: {total_volume}")
|
|
||||||
|
|
||||||
if volume_unit not in VOLUME_UNITS:
|
|
||||||
raise ValueError(f"Неизвестная единица объёма: {volume_unit}")
|
|
||||||
|
|
||||||
# Суммируем проценты всех реагентов
|
|
||||||
total_percentage = sum(r.get('percentage', 0) for r in reagents)
|
|
||||||
|
|
||||||
if total_percentage > 100:
|
|
||||||
raise ValueError(
|
|
||||||
f"Сумма процентов ({total_percentage:.2f}%) превышает 100%"
|
|
||||||
)
|
|
||||||
|
|
||||||
# Конвертируем общий объём в базовую единицу (мкл)
|
|
||||||
total_base = convert_units(total_volume, volume_unit)
|
|
||||||
|
|
||||||
results = []
|
|
||||||
total_diluted_volume_base = 0 # объём разбавленных реагентов в мкл
|
|
||||||
|
|
||||||
for reagent in reagents:
|
|
||||||
# Извлекаем параметры с значениями по умолчанию
|
|
||||||
percentage = reagent.get('percentage', 0)
|
|
||||||
unit = reagent.get('unit', 'мг')
|
|
||||||
if unit in VOLUME_UNITS:
|
|
||||||
base_unit = "мкл"
|
|
||||||
elif unit in MASS_UNITS:
|
|
||||||
base_unit = "мг"
|
|
||||||
else:
|
|
||||||
raise ValueError(f"Неизвестная единица измерения: {from_unit}")
|
|
||||||
# conversion_factor = reagent.get('conversion_factor', 1.0)
|
|
||||||
dilution_factor = reagent.get('dilution_factor', 1.0)
|
|
||||||
|
|
||||||
# Проверяем, является ли реагент жидкостью (объём) или твёрдым веществом (масса)
|
|
||||||
is_volume = unit in VOLUME_UNITS
|
|
||||||
|
|
||||||
# 1. Объём реагента в среде (исходя из процента)
|
|
||||||
amount_in_base = (percentage / 100) * total_base
|
|
||||||
# 2. Применяем коэффициент конверсии
|
|
||||||
# adjusted_amount_base = amount_in_base * conversion_factor
|
|
||||||
|
|
||||||
# 3. Конвертируем в нужную единицу (без учёта разбавления)
|
|
||||||
# undiluted_amount = convert_units(adjusted_amount_base, volume_unit, unit)
|
|
||||||
undiluted_amount = convert_units(amount_in_base, base_unit, unit)
|
|
||||||
# 4. Применяем разбавление
|
|
||||||
if dilution_factor <= 0:
|
|
||||||
dilution_factor = 1.0
|
|
||||||
diluted_amount = undiluted_amount * dilution_factor
|
|
||||||
# 5. Для объёмных реагентов учитываем в расчёте растворителя
|
|
||||||
if is_volume:
|
|
||||||
reagent_volume_base = convert_units(diluted_amount, unit)
|
|
||||||
total_diluted_volume_base += reagent_volume_base
|
|
||||||
|
|
||||||
# Сохраняем результат
|
|
||||||
reagent_result = reagent.copy()
|
|
||||||
reagent_result['calculated_amount'] = diluted_amount
|
|
||||||
reagent_result['undiluted_amount'] = undiluted_amount
|
|
||||||
reagent_result['amount_unit'] = unit
|
|
||||||
results.append(reagent_result)
|
|
||||||
|
|
||||||
# Рассчитываем объём растворителя
|
|
||||||
solvent_volume_base = total_base - total_diluted_volume_base
|
|
||||||
if solvent_volume_base < 0:
|
|
||||||
solvent_volume_base = 0
|
|
||||||
|
|
||||||
solvent_volume = convert_units(solvent_volume_base, 'мкл', volume_unit)
|
|
||||||
solvent_percentage = 100 - total_percentage
|
|
||||||
|
|
||||||
return {
|
|
||||||
'total_volume': total_volume,
|
|
||||||
'total_unit': volume_unit,
|
|
||||||
'solvent_name': solvent_name,
|
|
||||||
'solvent_volume': solvent_volume,
|
|
||||||
'solvent_percentage': solvent_percentage,
|
|
||||||
'reagents': results
|
|
||||||
}
|
|
||||||
@@ -1,223 +0,0 @@
|
|||||||
"""
|
|
||||||
Модель данных проекта для сохранения/загрузки в JSON
|
|
||||||
"""
|
|
||||||
|
|
||||||
from typing import List, Dict, Optional
|
|
||||||
from dataclasses import dataclass, asdict
|
|
||||||
from datetime import datetime
|
|
||||||
import json
|
|
||||||
|
|
||||||
VERSION="alpha_0.3"
|
|
||||||
"""
|
|
||||||
Условно
|
|
||||||
0.1 - Разработка калькулятора сред
|
|
||||||
0.2 - Разработка факторов эксперимента
|
|
||||||
0.3 - разработка матрицы планирования
|
|
||||||
"""
|
|
||||||
@dataclass
|
|
||||||
class ReagentData:
|
|
||||||
"""Данные реагента"""
|
|
||||||
name: str
|
|
||||||
percentage: float
|
|
||||||
unit: str
|
|
||||||
conversion_factor: float
|
|
||||||
dilution_factor: float
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict) -> 'ReagentData':
|
|
||||||
return cls(
|
|
||||||
name=data['name'],
|
|
||||||
percentage=data['percentage'],
|
|
||||||
unit=data['unit'],
|
|
||||||
conversion_factor=data.get('conversion_factor', 1.0),
|
|
||||||
dilution_factor=data.get('dilution_factor', 1.0)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class FactorData:
|
|
||||||
"""Данные фактора эксперимента"""
|
|
||||||
name: str
|
|
||||||
center: float
|
|
||||||
low: float
|
|
||||||
high: float
|
|
||||||
step: float
|
|
||||||
step_type: str
|
|
||||||
unit: str
|
|
||||||
percentage: Optional[float] = None
|
|
||||||
dilution_factor: Optional[float] = None
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
|
||||||
d = asdict(self)
|
|
||||||
# Удаляем None значения
|
|
||||||
return {k: v for k, v in d.items() if v is not None}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict) -> 'FactorData':
|
|
||||||
return cls(
|
|
||||||
name=data['name'],
|
|
||||||
center=data['center'],
|
|
||||||
low=data['low'],
|
|
||||||
high=data['high'],
|
|
||||||
step=data.get('step', 0),
|
|
||||||
step_type=data.get('step_type', 'абс'),
|
|
||||||
unit=data.get('unit', ''),
|
|
||||||
percentage=data.get('percentage'),
|
|
||||||
dilution_factor=data.get('dilution_factor')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ResponseData:
|
|
||||||
"""Данные отклика эксперимента"""
|
|
||||||
name: str
|
|
||||||
unit: str
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
|
||||||
return asdict(self)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict) -> 'ResponseData':
|
|
||||||
return cls(
|
|
||||||
name=data['name'],
|
|
||||||
unit=data.get('unit', '')
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ExperimentResultsData:
|
|
||||||
"""Результаты эксперимента"""
|
|
||||||
design: List[Dict] # План эксперимента
|
|
||||||
results: List[List[float]] # Результаты измерений
|
|
||||||
responses: List[ResponseData] # Отклики
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
|
||||||
return {
|
|
||||||
'design': self.design,
|
|
||||||
'results': self.results,
|
|
||||||
'responses': [r.to_dict() for r in self.responses]
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict) -> 'ExperimentResultsData':
|
|
||||||
return cls(
|
|
||||||
design=data['design'],
|
|
||||||
results=data['results'],
|
|
||||||
responses=[ResponseData.from_dict(r) for r in data['responses']]
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class ProjectData:
|
|
||||||
"""Полные данные проекта"""
|
|
||||||
# Информация о проекте
|
|
||||||
project_name: str
|
|
||||||
created_at: str
|
|
||||||
modified_at: str
|
|
||||||
version: str = VERSION
|
|
||||||
|
|
||||||
# Данные калькулятора сред
|
|
||||||
medium_total_volume: float = 1000.0
|
|
||||||
medium_volume_unit: str = "мл"
|
|
||||||
medium_solvent: str = "Вода"
|
|
||||||
medium_reagents: List[ReagentData] = None
|
|
||||||
|
|
||||||
# Данные эксперимента
|
|
||||||
experiment_total_volume: float = 1000.0
|
|
||||||
experiment_volume_unit: str = "мл"
|
|
||||||
experiment_solvent: str = "Вода"
|
|
||||||
experiment_factors: List[FactorData] = None
|
|
||||||
experiment_responses: List[ResponseData] = None
|
|
||||||
experiment_center_points: int = 3
|
|
||||||
experiment_randomize: bool = True
|
|
||||||
experiment_results: Optional[ExperimentResultsData] = None
|
|
||||||
|
|
||||||
def __post_init__(self):
|
|
||||||
if self.medium_reagents is None:
|
|
||||||
self.medium_reagents = []
|
|
||||||
if self.experiment_factors is None:
|
|
||||||
self.experiment_factors = []
|
|
||||||
if self.experiment_responses is None:
|
|
||||||
self.experiment_responses = []
|
|
||||||
|
|
||||||
def to_dict(self) -> Dict:
|
|
||||||
"""Конвертирует в словарь для JSON"""
|
|
||||||
return {
|
|
||||||
'project_info': {
|
|
||||||
'name': self.project_name,
|
|
||||||
'created_at': self.created_at,
|
|
||||||
'modified_at': self.modified_at,
|
|
||||||
'version': self.version
|
|
||||||
},
|
|
||||||
'medium_calculator': {
|
|
||||||
'total_volume': self.medium_total_volume,
|
|
||||||
'volume_unit': self.medium_volume_unit,
|
|
||||||
'solvent': self.medium_solvent,
|
|
||||||
'reagents': [r.to_dict() for r in self.medium_reagents]
|
|
||||||
},
|
|
||||||
'experiment': {
|
|
||||||
'total_volume': self.medium_total_volume,
|
|
||||||
'volume_unit': self.medium_volume_unit,
|
|
||||||
'solvent': self.medium_solvent,
|
|
||||||
'factors': [f.to_dict() for f in self.experiment_factors],
|
|
||||||
'responses': [r.to_dict() for r in self.experiment_responses],
|
|
||||||
'center_points': self.experiment_center_points,
|
|
||||||
'randomize': self.experiment_randomize,
|
|
||||||
'results': self.experiment_results.to_dict() if self.experiment_results else None
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def from_dict(cls, data: Dict) -> 'ProjectData':
|
|
||||||
"""Создаёт объект из словаря"""
|
|
||||||
project_info = data.get('project_info', {})
|
|
||||||
medium = data.get('medium_calculator', {})
|
|
||||||
experiment = data.get('experiment', {})
|
|
||||||
|
|
||||||
# Создаём объект
|
|
||||||
obj = cls(
|
|
||||||
project_name=project_info.get('name', 'Новый проект'),
|
|
||||||
created_at=project_info.get('created_at', datetime.now().isoformat()),
|
|
||||||
modified_at=project_info.get('modified_at', datetime.now().isoformat()),
|
|
||||||
version=project_info.get('version', '1.0'),
|
|
||||||
medium_total_volume=medium.get('total_volume', 1000.0),
|
|
||||||
medium_volume_unit=medium.get('volume_unit', 'мл'),
|
|
||||||
medium_solvent=medium.get('solvent', 'Вода'),
|
|
||||||
medium_reagents=[ReagentData.from_dict(r) for r in medium.get('reagents', [])],
|
|
||||||
experiment_total_volume=experiment.get('total_volume', 1000.0),
|
|
||||||
experiment_volume_unit=experiment.get('volume_unit', 'мл'),
|
|
||||||
experiment_solvent=experiment.get('solvent', 'Вода'),
|
|
||||||
experiment_factors=[FactorData.from_dict(f) for f in experiment.get('factors', [])],
|
|
||||||
experiment_responses=[ResponseData.from_dict(r) for r in experiment.get('responses', [])],
|
|
||||||
experiment_center_points=experiment.get('center_points', 3),
|
|
||||||
experiment_randomize=experiment.get('randomize', True),
|
|
||||||
experiment_results=ExperimentResultsData.from_dict(experiment['results'])
|
|
||||||
if experiment.get('results') else None
|
|
||||||
)
|
|
||||||
return obj
|
|
||||||
|
|
||||||
def save_to_file(self, filename: str):
|
|
||||||
"""Сохраняет проект в JSON файл"""
|
|
||||||
self.modified_at = datetime.now().isoformat()
|
|
||||||
with open(filename, 'w', encoding='utf-8') as f:
|
|
||||||
json.dump(self.to_dict(), f, ensure_ascii=False, indent=2)
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def load_from_file(cls, filename: str) -> 'ProjectData':
|
|
||||||
"""Загружает проект из JSON файла"""
|
|
||||||
with open(filename, 'r', encoding='utf-8') as f:
|
|
||||||
data = json.load(f)
|
|
||||||
return cls.from_dict(data)
|
|
||||||
|
|
||||||
|
|
||||||
def create_new_project(name: str = "Новый проект") -> ProjectData:
|
|
||||||
"""Создаёт новый проект с текущей датой"""
|
|
||||||
now = datetime.now().isoformat()
|
|
||||||
return ProjectData(
|
|
||||||
project_name=name,
|
|
||||||
created_at=now,
|
|
||||||
modified_at=now
|
|
||||||
)
|
|
||||||
@@ -1,152 +0,0 @@
|
|||||||
"""
|
|
||||||
Интерфейс командной строки для использования вычислительных функций
|
|
||||||
Позволяет использовать расчёты без графического интерфейса
|
|
||||||
"""
|
|
||||||
|
|
||||||
import json
|
|
||||||
import argparse
|
|
||||||
from typing import List, Dict
|
|
||||||
|
|
||||||
from calculations import (
|
|
||||||
calculate_medium_composition,
|
|
||||||
generate_factorial_design,
|
|
||||||
analyze_experiment,
|
|
||||||
convert_units,
|
|
||||||
VOLUME_UNITS,
|
|
||||||
MASS_UNITS
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def demo_medium_calculation():
|
|
||||||
"""Демонстрация расчёта питательной среды"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("ДЕМОНСТРАЦИЯ РАСЧЁТА ПИТАТЕЛЬНОЙ СРЕДЫ")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Пример реагентов
|
|
||||||
reagents = [
|
|
||||||
{
|
|
||||||
'name': 'Глюкоза',
|
|
||||||
'percentage': 2.0,
|
|
||||||
'unit': 'г',
|
|
||||||
'conversion_factor': 1.0,
|
|
||||||
'dilution_factor': 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Пептон',
|
|
||||||
'percentage': 1.0,
|
|
||||||
'unit': 'г',
|
|
||||||
'conversion_factor': 1.0,
|
|
||||||
'dilution_factor': 1.0
|
|
||||||
},
|
|
||||||
{
|
|
||||||
'name': 'Дрожжевой экстракт',
|
|
||||||
'percentage': 0.5,
|
|
||||||
'unit': 'г',
|
|
||||||
'conversion_factor': 1.0,
|
|
||||||
'dilution_factor': 1.0
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
print("\nИсходные данные:")
|
|
||||||
print(f"Общий объём: 1000 мл")
|
|
||||||
print("Реагенты:")
|
|
||||||
for r in reagents:
|
|
||||||
print(f" - {r['name']}: {r['percentage']}%")
|
|
||||||
|
|
||||||
# Расчёт
|
|
||||||
result = calculate_medium_composition(
|
|
||||||
total_volume=1000,
|
|
||||||
volume_unit='мл',
|
|
||||||
reagents=reagents,
|
|
||||||
solvent_name='Вода'
|
|
||||||
)
|
|
||||||
|
|
||||||
print("\nРезультаты расчёта:")
|
|
||||||
print(f"Растворитель ({result['solvent_name']}): {result['solvent_volume']:.2f} {result['total_unit']} "
|
|
||||||
f"({result['solvent_percentage']:.1f}%)")
|
|
||||||
print("\nКоличества реагентов:")
|
|
||||||
for r in result['reagents']:
|
|
||||||
print(f" - {r['name']}: {r['calculated_amount']:.4f} {r['unit']}")
|
|
||||||
|
|
||||||
|
|
||||||
def demo_doe_calculation():
|
|
||||||
"""Демонстрация планирования эксперимента"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("ДЕМОНСТРАЦИЯ ПЛАНИРОВАНИЯ ЭКСПЕРИМЕНТА")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
# Факторы
|
|
||||||
factors = [
|
|
||||||
{'name': 'Температура', 'low': 25, 'high': 37, 'center': 31, 'unit': '°C'},
|
|
||||||
{'name': 'pH', 'low': 6.5, 'high': 7.5, 'center': 7.0, 'unit': ''}
|
|
||||||
]
|
|
||||||
|
|
||||||
print("\nФакторы эксперимента:")
|
|
||||||
for f in factors:
|
|
||||||
print(f" - {f['name']}: {f['low']} – {f['high']} {f['unit']} (центр: {f['center']})")
|
|
||||||
|
|
||||||
# Генерация плана
|
|
||||||
design = generate_factorial_design(factors, center_points=3, randomize=True)
|
|
||||||
|
|
||||||
print(f"\nСгенерировано {len(design)} опытов:")
|
|
||||||
for i, exp in enumerate(design):
|
|
||||||
is_center = exp.get('is_center', False)
|
|
||||||
type_str = "Центральная" if is_center else "Факторная"
|
|
||||||
|
|
||||||
values = []
|
|
||||||
for key in sorted(exp.keys()):
|
|
||||||
if key.startswith('Фактор_'):
|
|
||||||
values.append(f"{exp[key]['natural']}{exp[key]['unit']}")
|
|
||||||
|
|
||||||
print(f" {i+1}. {type_str}: {', '.join(values)}")
|
|
||||||
|
|
||||||
|
|
||||||
def demo_unit_conversion():
|
|
||||||
"""Демонстрация конвертации единиц"""
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("ДЕМОНСТРАЦИЯ КОНВЕРТАЦИИ ЕДИНИЦ")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
print("\nОбъёмные единицы (база: мкл):")
|
|
||||||
for unit, factor in VOLUME_UNITS.items():
|
|
||||||
print(f" 1 {unit} = {factor} мкл")
|
|
||||||
|
|
||||||
print("\nМассовые единицы (база: мг):")
|
|
||||||
for unit, factor in MASS_UNITS.items():
|
|
||||||
print(f" 1 {unit} = {factor} мг")
|
|
||||||
|
|
||||||
print("\nПримеры конвертации:")
|
|
||||||
print(f" 100 мл = {convert_units(100, 'мл', 'мкл'):.0f} мкл")
|
|
||||||
print(f" 5000 мкл = {convert_units(5000, 'мкл', 'мл'):.2f} мл")
|
|
||||||
print(f" 2 г = {convert_units(2, 'г', 'мг'):.0f} мг")
|
|
||||||
print(f" 500 мг = {convert_units(500, 'мг', 'г'):.2f} г")
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
|
||||||
parser = argparse.ArgumentParser(description='Биохимический помощник - командная строка')
|
|
||||||
parser.add_argument('--demo', choices=['medium', 'doe', 'units', 'all'],
|
|
||||||
default='all', help='Демонстрация расчётов')
|
|
||||||
|
|
||||||
args = parser.parse_args()
|
|
||||||
|
|
||||||
if args.demo in ['medium', 'all']:
|
|
||||||
demo_medium_calculation()
|
|
||||||
|
|
||||||
if args.demo in ['doe', 'all']:
|
|
||||||
demo_doe_calculation()
|
|
||||||
|
|
||||||
if args.demo in ['units', 'all']:
|
|
||||||
demo_unit_conversion()
|
|
||||||
|
|
||||||
print("\n" + "=" * 60)
|
|
||||||
print("Советы по использованию библиотеки:")
|
|
||||||
print(" from calculations import *")
|
|
||||||
print(" result = calculate_medium_composition(...)")
|
|
||||||
print(" design = generate_factorial_design(...)")
|
|
||||||
print(" analysis = analyze_experiment(...)")
|
|
||||||
print("=" * 60)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
main()
|
|
||||||
@@ -1,36 +1,16 @@
|
|||||||
# main.py
|
|
||||||
"""
|
|
||||||
Биохимический помощник - точка входа в приложение
|
|
||||||
TODO:
|
|
||||||
- Добавить информацию о количестве раствора в DOE ОК
|
|
||||||
- Не считать фактор, если его шаг 0 ОК
|
|
||||||
- Добавить столбец в матрицу планирования с информацией о количестве добавленного растворителя, учитывая все реагенты
|
|
||||||
- Начать делать анализ
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
import os
|
import os
|
||||||
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||||
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
from PyQt5.QtWidgets import QApplication
|
||||||
from gui import MainWindow
|
from src.views import MainWindow
|
||||||
from theme import Fonts, setup_emoji_support
|
|
||||||
|
|
||||||
|
|
||||||
# Добавляем текущую директорию в путь
|
|
||||||
sys.path.insert(0, os.path.dirname(__file__))
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
app = QApplication(sys.argv)
|
app = QApplication(sys.argv)
|
||||||
|
app.setStyle('Fusion')
|
||||||
# Настраиваем поддержку эмодзи
|
assistant = MainWindow()
|
||||||
setup_emoji_support(app)
|
assistant.show()
|
||||||
|
|
||||||
window = MainWindow()
|
|
||||||
window.show()
|
|
||||||
sys.exit(app.exec_())
|
sys.exit(app.exec_())
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
PyQt5>=5.15.0
|
PyQt5>=5.15.0
|
||||||
numpy>=1.21.0
|
numpy>=1.19.0
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Цифровой помощник биохимика - основная библиотека"""
|
||||||
|
|
||||||
|
__version__ = "1.0.0"
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
from .medium_controller import MediumController
|
||||||
|
from .experiment_controller import ExperimentController
|
||||||
|
|
||||||
|
__all__ = ['MediumController', 'ExperimentController']
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from ..models.experiment_model import ExperimentModel
|
||||||
|
|
||||||
|
class ExperimentController:
|
||||||
|
def __init__(self, view):
|
||||||
|
self.model = ExperimentModel()
|
||||||
|
self.view = view
|
||||||
|
|
||||||
|
def update_model_from_view(self):
|
||||||
|
factors = self.view.get_factors_data()
|
||||||
|
self.model.set_factors(factors)
|
||||||
|
|
||||||
|
responses = []
|
||||||
|
for row in range(self.view.responses_table.rowCount()):
|
||||||
|
name_item = self.view.responses_table.item(row, 0)
|
||||||
|
unit_item = self.view.responses_table.item(row, 1)
|
||||||
|
if name_item:
|
||||||
|
responses.append({
|
||||||
|
'name': name_item.text(),
|
||||||
|
'unit': unit_item.text() if unit_item else ""
|
||||||
|
})
|
||||||
|
self.model.set_responses(responses)
|
||||||
|
self.model.set_center_points(self.view.center_points_spin.value())
|
||||||
|
self.model.set_randomize(self.view.randomize_check.isChecked())
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from PyQt5.QtWidgets import QFileDialog, QMessageBox, QTableWidgetItem, QComboBox
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QColor
|
||||||
|
import json
|
||||||
|
from ..models.medium_model import MediumModel
|
||||||
|
from ..models.reagent import Reagent
|
||||||
|
|
||||||
|
class MediumController:
|
||||||
|
def __init__(self, view):
|
||||||
|
self.model = MediumModel()
|
||||||
|
self.view = view
|
||||||
|
self._connect_signals()
|
||||||
|
self._setup_initial_data()
|
||||||
|
|
||||||
|
def _connect_signals(self):
|
||||||
|
self.view.add_row_btn.clicked.connect(self.add_reagent_row)
|
||||||
|
self.view.remove_row_btn.clicked.connect(self.remove_reagent_row)
|
||||||
|
self.view.calculate_btn.clicked.connect(self._perform_calculation)
|
||||||
|
self.view.save_btn.clicked.connect(self.save_composition)
|
||||||
|
self.view.load_btn.clicked.connect(self.load_composition)
|
||||||
|
self.view.solvent_input.textChanged.connect(self.view.update_solvent_name)
|
||||||
|
|
||||||
|
def _setup_initial_data(self):
|
||||||
|
self.view.add_initial_rows()
|
||||||
|
|
||||||
|
def add_reagent_row(self):
|
||||||
|
self.view.add_new_row()
|
||||||
|
|
||||||
|
def remove_reagent_row(self):
|
||||||
|
self.view.remove_selected_row()
|
||||||
|
|
||||||
|
def _perform_calculation(self):
|
||||||
|
try:
|
||||||
|
self._update_model_from_view()
|
||||||
|
results, solvent_amount, solvent_percentage = self.model.calculate_amounts()
|
||||||
|
self.view.update_results(results)
|
||||||
|
self.view.update_solvent_result(solvent_amount, self.model.amount_unit)
|
||||||
|
self.view.update_solvent_percent(solvent_percentage)
|
||||||
|
except ValueError as e:
|
||||||
|
self.view.show_error(f"Ошибка в данных: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
self.view.show_error(f"Неожиданная ошибка: {str(e)}")
|
||||||
|
|
||||||
|
def _update_model_from_view(self):
|
||||||
|
self.model.reagents.clear()
|
||||||
|
self.model.total_amount = self.view.amount_input.value()
|
||||||
|
self.model.amount_unit = self.view.amount_unit_combo.currentText()
|
||||||
|
self.model.solvent = self.view.solvent_input.text()
|
||||||
|
for row in range(1, self.view.table.rowCount()):
|
||||||
|
name_item = self.view.table.item(row, 0)
|
||||||
|
percentage_item = self.view.table.item(row, 1)
|
||||||
|
unit_widget = self.view.table.cellWidget(row, 2)
|
||||||
|
conversion_item = self.view.table.item(row, 3)
|
||||||
|
dilution_item = self.view.table.item(row, 4)
|
||||||
|
if not all([name_item, percentage_item, conversion_item]):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
name = name_item.text()
|
||||||
|
percentage = float(percentage_item.text())
|
||||||
|
unit = unit_widget.currentText() if unit_widget else "мг"
|
||||||
|
conversion_factor = float(conversion_item.text())
|
||||||
|
dilution_factor = float(dilution_item.text()) if dilution_item else 1.0
|
||||||
|
reagent = Reagent(name, percentage, unit, conversion_factor)
|
||||||
|
reagent.dilution_factor = dilution_factor
|
||||||
|
self.model.reagents.append(reagent)
|
||||||
|
except ValueError as e:
|
||||||
|
raise ValueError(f"Ошибка в строке {row + 1}: {str(e)}")
|
||||||
|
|
||||||
|
def save_composition(self):
|
||||||
|
filename, _ = QFileDialog.getSaveFileName(self.view, "Сохранить состав среды", "", "JSON Files (*.json);;All Files (*)")
|
||||||
|
if filename:
|
||||||
|
if not filename.lower().endswith('.json'):
|
||||||
|
filename += '.json'
|
||||||
|
try:
|
||||||
|
self._update_model_from_view()
|
||||||
|
self.model.save_to_file(filename)
|
||||||
|
QMessageBox.information(self.view, "Успех", "Состав среды успешно сохранён!")
|
||||||
|
except Exception as e:
|
||||||
|
self.view.show_error(f"Ошибка сохранения: {str(e)}")
|
||||||
|
|
||||||
|
def load_composition(self):
|
||||||
|
filename, _ = QFileDialog.getOpenFileName(self.view, "Загрузить состав среды", "", "JSON Files (*.json);;All Files (*)")
|
||||||
|
if filename:
|
||||||
|
try:
|
||||||
|
self.model.load_from_file(filename)
|
||||||
|
self._update_view_from_model()
|
||||||
|
QMessageBox.information(self.view, "Успех", "Состав среды успешно загружен")
|
||||||
|
except FileNotFoundError:
|
||||||
|
QMessageBox.critical(self.view, "Ошибка", f"Файл не найден: {filename}")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
QMessageBox.critical(self.view, "Ошибка", f"Неверный формат JSON-файла: {str(e)}")
|
||||||
|
except Exception as e:
|
||||||
|
QMessageBox.critical(self.view, "Ошибка", f"Ошибка при загрузке состава: {str(e)}")
|
||||||
|
|
||||||
|
def _update_view_from_model(self):
|
||||||
|
while self.view.table.rowCount() > 1:
|
||||||
|
self.view.table.removeRow(1)
|
||||||
|
if self.view.table.rowCount() == 0:
|
||||||
|
self.view.add_solvent_row()
|
||||||
|
self.view.amount_input.setValue(self.model.total_amount)
|
||||||
|
index = self.view.amount_unit_combo.findText(self.model.amount_unit)
|
||||||
|
if index >= 0:
|
||||||
|
self.view.amount_unit_combo.setCurrentIndex(index)
|
||||||
|
self.view.solvent_input.setText(self.model.solvent)
|
||||||
|
self.view.update_solvent_name()
|
||||||
|
for reagent in self.model.reagents:
|
||||||
|
row = self.view.table.rowCount()
|
||||||
|
self.view.table.insertRow(row)
|
||||||
|
self.view.table.setItem(row, 0, QTableWidgetItem(reagent.name))
|
||||||
|
self.view.table.setItem(row, 1, QTableWidgetItem(f"{reagent.percentage:.2f}"))
|
||||||
|
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}"))
|
||||||
|
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()
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .reagent import Reagent
|
||||||
|
from .medium_model import MediumModel
|
||||||
|
from .experiment_model import ExperimentModel
|
||||||
|
|
||||||
|
__all__ = ['Reagent', 'MediumModel', 'ExperimentModel']
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
import numpy as np
|
||||||
|
from typing import List, Dict, Any
|
||||||
|
|
||||||
|
class ExperimentModel:
|
||||||
|
def __init__(self):
|
||||||
|
self.factors = []
|
||||||
|
self.responses = []
|
||||||
|
self.center_points = 3
|
||||||
|
self.randomize = True
|
||||||
|
|
||||||
|
def set_factors(self, factors: List[Dict]):
|
||||||
|
self.factors = factors
|
||||||
|
|
||||||
|
def set_responses(self, responses: List[Dict]):
|
||||||
|
self.responses = responses
|
||||||
|
|
||||||
|
def set_center_points(self, n: int):
|
||||||
|
self.center_points = n
|
||||||
|
|
||||||
|
def set_randomize(self, value: bool):
|
||||||
|
self.randomize = value
|
||||||
|
|
||||||
|
def calculate_factorial_design(self) -> List[Dict]:
|
||||||
|
k = len(self.factors)
|
||||||
|
if k == 0:
|
||||||
|
return []
|
||||||
|
n_factorial = 2 ** k
|
||||||
|
design = []
|
||||||
|
for i in range(n_factorial):
|
||||||
|
experiment = {}
|
||||||
|
for j in range(k):
|
||||||
|
coded_level = -1 if (i >> (k - 1 - j)) & 1 == 0 else 1
|
||||||
|
natural_value = self.factors[j]['low'] if coded_level == -1 else self.factors[j]['high']
|
||||||
|
experiment[f"Фактор_{j+1}"] = {
|
||||||
|
'coded': coded_level,
|
||||||
|
'natural': natural_value,
|
||||||
|
'name': self.factors[j]['name'],
|
||||||
|
'unit': self.factors[j]['unit']
|
||||||
|
}
|
||||||
|
design.append(experiment)
|
||||||
|
for i in range(self.center_points):
|
||||||
|
center_experiment = {}
|
||||||
|
for j in range(k):
|
||||||
|
center_experiment[f"Фактор_{j+1}"] = {
|
||||||
|
'coded': 0,
|
||||||
|
'natural': self.factors[j]['center'],
|
||||||
|
'name': self.factors[j]['name'],
|
||||||
|
'unit': self.factors[j]['unit']
|
||||||
|
}
|
||||||
|
center_experiment['is_center'] = True
|
||||||
|
center_experiment['center_num'] = i + 1
|
||||||
|
design.append(center_experiment)
|
||||||
|
if self.randomize:
|
||||||
|
import random
|
||||||
|
random.shuffle(design)
|
||||||
|
return design
|
||||||
|
|
||||||
|
def analyze_results(self, results: List[List[float]], design: List[Dict]) -> Dict:
|
||||||
|
analysis = {}
|
||||||
|
for resp_idx, response in enumerate(self.responses):
|
||||||
|
resp_name = response.get('name', f'Отклик_{resp_idx+1}')
|
||||||
|
y_values = [results[i][resp_idx] for i in range(len(results))]
|
||||||
|
mean_y = np.mean(y_values)
|
||||||
|
variance = np.var(y_values, ddof=1) if len(y_values) > 1 else 0
|
||||||
|
std_dev = np.std(y_values, ddof=1) if len(y_values) > 1 else 0
|
||||||
|
cv = (std_dev / mean_y) * 100 if mean_y != 0 else 0
|
||||||
|
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])
|
||||||
|
center_variance = np.var(center_y, ddof=1) if len(center_y) > 1 else 0
|
||||||
|
analysis[resp_name] = {
|
||||||
|
'mean': mean_y, 'variance': variance, 'std_dev': std_dev, 'cv': cv,
|
||||||
|
'factorial_values': factorial_y, 'center_values': center_y, 'center_variance': center_variance,
|
||||||
|
'n_factorial': len(factorial_y), 'n_center': len(center_y)
|
||||||
|
}
|
||||||
|
return analysis
|
||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import json
|
||||||
|
from typing import List, Tuple
|
||||||
|
from .reagent import Reagent
|
||||||
|
|
||||||
|
VOLUME_UNITS = {
|
||||||
|
'нл': 0.001, 'мкл': 1.0, 'мл': 1000.0, 'л': 1000000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
MASS_UNITS = {
|
||||||
|
'нг': 0.000001, 'мкг': 0.001, 'мг': 1.0, 'г': 1000.0, 'кг': 1000000.0
|
||||||
|
}
|
||||||
|
|
||||||
|
class MediumModel:
|
||||||
|
def __init__(self):
|
||||||
|
self.total_amount = 100.0
|
||||||
|
self.amount_unit = 'мл'
|
||||||
|
self.solvent = 'Вода'
|
||||||
|
self.reagents = []
|
||||||
|
|
||||||
|
def convert_amount(self, amount_base: float, target_unit: str, is_volume: bool) -> float:
|
||||||
|
if is_volume:
|
||||||
|
conversion_factor = VOLUME_UNITS.get(target_unit, 1.0)
|
||||||
|
else:
|
||||||
|
conversion_factor = MASS_UNITS.get(target_unit, 1.0)
|
||||||
|
return amount_base / conversion_factor
|
||||||
|
|
||||||
|
def calculate_amounts(self) -> Tuple[List[float], float, float]:
|
||||||
|
results = []
|
||||||
|
if not self.reagents:
|
||||||
|
return results, self.total_amount, 100.0
|
||||||
|
|
||||||
|
total_percentage = sum(r.percentage for r in self.reagents)
|
||||||
|
if total_percentage > 100:
|
||||||
|
raise ValueError(f"Сумма процентов реагентов ({total_percentage:.2f}%) превышает 100%")
|
||||||
|
|
||||||
|
total_in_base = self.total_amount * VOLUME_UNITS[self.amount_unit]
|
||||||
|
|
||||||
|
undiluted_amounts = []
|
||||||
|
for reagent in self.reagents:
|
||||||
|
amount_in_base = (reagent.percentage / 100) * total_in_base
|
||||||
|
is_volume = reagent.unit in VOLUME_UNITS
|
||||||
|
adjusted_amount = amount_in_base * reagent.conversion_factor
|
||||||
|
final_amount = self.convert_amount(adjusted_amount, reagent.unit, is_volume)
|
||||||
|
undiluted_amounts.append(final_amount)
|
||||||
|
|
||||||
|
diluted_amounts = []
|
||||||
|
total_diluted_volume_base = 0
|
||||||
|
for i, reagent in enumerate(self.reagents):
|
||||||
|
dilution_factor = getattr(reagent, 'dilution_factor', 1.0)
|
||||||
|
if dilution_factor <= 0:
|
||||||
|
dilution_factor = 1.0
|
||||||
|
diluted_amount = undiluted_amounts[i] * dilution_factor
|
||||||
|
diluted_amounts.append(diluted_amount)
|
||||||
|
is_volume = reagent.unit in VOLUME_UNITS
|
||||||
|
if is_volume:
|
||||||
|
reagent_volume_base = diluted_amount * VOLUME_UNITS[reagent.unit]
|
||||||
|
else:
|
||||||
|
reagent_volume_base = 0
|
||||||
|
total_diluted_volume_base += reagent_volume_base
|
||||||
|
|
||||||
|
solvent_volume_base = total_in_base - total_diluted_volume_base
|
||||||
|
solvent_amount = solvent_volume_base / VOLUME_UNITS[self.amount_unit]
|
||||||
|
if solvent_amount < 0:
|
||||||
|
solvent_amount = 0
|
||||||
|
solvent_percentage = 100 - total_percentage
|
||||||
|
|
||||||
|
return diluted_amounts, solvent_amount, solvent_percentage
|
||||||
|
|
||||||
|
def save_to_file(self, filename: str):
|
||||||
|
data = {
|
||||||
|
'total_amount': self.total_amount,
|
||||||
|
'amount_unit': self.amount_unit,
|
||||||
|
'solvent': self.solvent,
|
||||||
|
'reagents': [{'name': r.name, 'percentage': r.percentage, 'unit': r.unit,
|
||||||
|
'conversion_factor': r.conversion_factor, 'dilution_factor': getattr(r, 'dilution_factor', 1.0)}
|
||||||
|
for r in self.reagents]
|
||||||
|
}
|
||||||
|
with open(filename, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(data, f, ensure_ascii=False, indent=4)
|
||||||
|
|
||||||
|
def load_from_file(self, filename: str):
|
||||||
|
with open(filename, 'r', encoding='utf-8') as f:
|
||||||
|
data = json.load(f)
|
||||||
|
self.total_amount = data['total_amount']
|
||||||
|
self.amount_unit = data['amount_unit']
|
||||||
|
self.solvent = data['solvent']
|
||||||
|
self.reagents.clear()
|
||||||
|
for r_data in data['reagents']:
|
||||||
|
reagent = Reagent(r_data['name'], r_data['percentage'], r_data['unit'], r_data.get('conversion_factor', 1.0))
|
||||||
|
reagent.dilution_factor = r_data.get('dilution_factor', 1.0)
|
||||||
|
self.reagents.append(reagent)
|
||||||
|
|
||||||
|
def add_reagent(self, name: str, percentage: float, unit: str, conversion_factor: float = 1.0, dilution_factor: float = 1.0):
|
||||||
|
reagent = Reagent(name, percentage, unit, conversion_factor)
|
||||||
|
reagent.dilution_factor = dilution_factor
|
||||||
|
self.reagents.append(reagent)
|
||||||
|
|
||||||
|
def remove_reagent(self, index: int):
|
||||||
|
if 0 <= index < len(self.reagents):
|
||||||
|
del self.reagents[index]
|
||||||
|
|
||||||
|
def clear_reagents(self):
|
||||||
|
self.reagents.clear()
|
||||||
|
|
||||||
|
def get_reagent_count(self) -> int:
|
||||||
|
return len(self.reagents)
|
||||||
|
|
||||||
|
def set_total_amount(self, amount: float, unit: str):
|
||||||
|
self.total_amount = amount
|
||||||
|
self.amount_unit = unit
|
||||||
|
|
||||||
|
def set_solvent(self, solvent_name: str):
|
||||||
|
self.solvent = solvent_name
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
class Reagent:
|
||||||
|
def __init__(self, name: str, percentage: float, unit: str, conversion_factor: float = 1.0):
|
||||||
|
self.name = name
|
||||||
|
self.percentage = percentage
|
||||||
|
self.unit = unit
|
||||||
|
self.conversion_factor = conversion_factor
|
||||||
|
self.dilution_factor = 1.0
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"Reagent(name={self.name}, percentage={self.percentage}, unit={self.unit})"
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
"""Утилиты и вспомогательные функции"""
|
||||||
|
|
||||||
|
__all__ = []
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
from .main_window import MainWindow
|
||||||
|
from .medium_view import MediumCalculatorWindow
|
||||||
|
from .experiment_view import ExperimentDesignWindow
|
||||||
|
|
||||||
|
__all__ = ['MainWindow', 'MediumCalculatorWindow', 'ExperimentDesignWindow']
|
||||||
@@ -0,0 +1,470 @@
|
|||||||
|
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 csv
|
||||||
|
import random
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
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; }
|
||||||
|
QPushButton#danger { background-color: #f44336; }
|
||||||
|
QPushButton#danger:hover { background-color: #da190b; }
|
||||||
|
QTableWidget { gridline-color: #ddd; }
|
||||||
|
QHeaderView::section { background-color: #1565C0; color: white; padding: 8px; 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()
|
||||||
|
|
||||||
|
# Вкладка параметров
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
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, "📝 Параметры эксперимента")
|
||||||
|
|
||||||
|
# Вкладка матрицы планирования
|
||||||
|
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, "📊 Матрица планирования")
|
||||||
|
|
||||||
|
# Вкладка анализа
|
||||||
|
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()
|
||||||
|
close_btn = QPushButton("Закрыть")
|
||||||
|
close_btn.clicked.connect(self.close)
|
||||||
|
btn_layout.addStretch()
|
||||||
|
btn_layout.addWidget(close_btn)
|
||||||
|
layout.addLayout(btn_layout)
|
||||||
|
|
||||||
|
self.generated_design = None
|
||||||
|
self.factors_data = None
|
||||||
|
|
||||||
|
def on_factor_changed(self, item):
|
||||||
|
row = item.row()
|
||||||
|
col = item.column()
|
||||||
|
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)
|
||||||
|
if self.factors_table.item(row, 3):
|
||||||
|
self.factors_table.item(row, 3).setText(f"{high:.3f}".rstrip('0').rstrip('.'))
|
||||||
|
if self.factors_table.item(row, 4):
|
||||||
|
self.factors_table.item(row, 4).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):
|
||||||
|
k = len(factors)
|
||||||
|
if k == 0:
|
||||||
|
return []
|
||||||
|
n_factorial = 2 ** k
|
||||||
|
design = []
|
||||||
|
for i in range(n_factorial):
|
||||||
|
experiment = {}
|
||||||
|
for j in range(k):
|
||||||
|
coded_level = -1 if (i >> (k - 1 - j)) & 1 == 0 else 1
|
||||||
|
natural_value = factors[j]['low'] if coded_level == -1 else 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():
|
||||||
|
random.shuffle(design)
|
||||||
|
return design
|
||||||
|
|
||||||
|
def generate_design_matrix(self):
|
||||||
|
factors = self.get_factors_data()
|
||||||
|
if len(factors) == 0:
|
||||||
|
QMessageBox.warning(self, "Предупреждение", "Добавьте хотя бы один фактор!")
|
||||||
|
return
|
||||||
|
|
||||||
|
self.factors_data = factors
|
||||||
|
design = self.calculate_factorial_design(factors)
|
||||||
|
self.generated_design = design
|
||||||
|
|
||||||
|
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Факторных точек: {n_factorial}\nЦентральных точек: {n_center}\nВсего опытов: {n_experiments}")
|
||||||
|
|
||||||
|
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):
|
||||||
|
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 = [self.design_matrix.item(i, j).text() if self.design_matrix.item(i, j) else "" for j in range(self.design_matrix.columnCount())]
|
||||||
|
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
|
||||||
|
if self.generated_design is None:
|
||||||
|
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)
|
||||||
|
|
||||||
|
for i, row in enumerate(results):
|
||||||
|
for j, val in enumerate(row):
|
||||||
|
if val is None:
|
||||||
|
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.generated_design
|
||||||
|
|
||||||
|
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)
|
||||||
|
variance = np.var(y_values, ddof=1) if len(y_values) > 1 else 0
|
||||||
|
std_dev = np.std(y_values, ddof=1) if len(y_values) > 1 else 0
|
||||||
|
cv = (std_dev / mean_y) * 100 if mean_y != 0 else 0
|
||||||
|
|
||||||
|
self.analysis_output.append(f"Среднее значение: {mean_y:.4f}")
|
||||||
|
self.analysis_output.append(f"Общая дисперсия: {variance:.4f}")
|
||||||
|
self.analysis_output.append(f"Стандартное отклонение: {std_dev:.4f}")
|
||||||
|
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) if len(center_y) > 1 else 0
|
||||||
|
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}")
|
||||||
|
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_error(self, message: str):
|
||||||
|
QMessageBox.critical(self, "Ошибка", message)
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout, QPushButton, QLabel, QWidget, QFrame)
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QFont
|
||||||
|
|
||||||
|
class MainWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
self.setWindowTitle("Цифровой помощник биохимика - Главное меню")
|
||||||
|
self.setGeometry(300, 200, 700, 500)
|
||||||
|
self.setStyleSheet("""
|
||||||
|
QMainWindow { background-color: qlineargradient(x1:0, y1:0, x2:1, y2:1, stop:0 #e8f4f8, stop:1 #f0f0f0); }
|
||||||
|
QPushButton { background-color: #2196F3; color: white; border: none; padding: 15px; font-size: 16px; font-weight: bold; border-radius: 8px; }
|
||||||
|
QPushButton:hover { background-color: #1976D2; }
|
||||||
|
QLabel { color: #333; font-size: 14px; }
|
||||||
|
""")
|
||||||
|
self._init_ui()
|
||||||
|
self.medium_calculator = None
|
||||||
|
self.experiment_window = None
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
central_widget = QWidget()
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
layout = QVBoxLayout(central_widget)
|
||||||
|
layout.setSpacing(20)
|
||||||
|
layout.setContentsMargins(50, 50, 50, 50)
|
||||||
|
|
||||||
|
title_label = QLabel("Цифровой помощник биохимика")
|
||||||
|
title_font = QFont()
|
||||||
|
title_font.setPointSize(20)
|
||||||
|
title_font.setBold(True)
|
||||||
|
title_label.setFont(title_font)
|
||||||
|
title_label.setAlignment(Qt.AlignCenter)
|
||||||
|
title_label.setStyleSheet("color: #1565C0;")
|
||||||
|
layout.addWidget(title_label)
|
||||||
|
|
||||||
|
subtitle_label = QLabel("Биотехнологические инструменты для лаборатории")
|
||||||
|
subtitle_font = QFont()
|
||||||
|
subtitle_font.setPointSize(12)
|
||||||
|
subtitle_label.setFont(subtitle_font)
|
||||||
|
subtitle_label.setAlignment(Qt.AlignCenter)
|
||||||
|
subtitle_label.setStyleSheet("color: #666;")
|
||||||
|
layout.addWidget(subtitle_label)
|
||||||
|
|
||||||
|
layout.addSpacing(20)
|
||||||
|
|
||||||
|
btn_medium = QPushButton("🧪 Калькулятор питательных сред")
|
||||||
|
btn_medium.setMinimumHeight(80)
|
||||||
|
btn_medium.clicked.connect(self.open_medium_calculator)
|
||||||
|
layout.addWidget(btn_medium)
|
||||||
|
|
||||||
|
desc1_label = QLabel("Расчёт состава питательной среды с учётом процентного содержания,\nразбавления реагентов и автоматическим расчётом растворителя")
|
||||||
|
desc1_label.setAlignment(Qt.AlignCenter)
|
||||||
|
desc1_label.setWordWrap(True)
|
||||||
|
desc1_label.setStyleSheet("color: #555; font-size: 11px;")
|
||||||
|
layout.addWidget(desc1_label)
|
||||||
|
|
||||||
|
layout.addSpacing(15)
|
||||||
|
|
||||||
|
btn_experiment = QPushButton("📊 Планирование эксперимента (DoE)")
|
||||||
|
btn_experiment.setMinimumHeight(80)
|
||||||
|
btn_experiment.clicked.connect(self.open_experiment_designer)
|
||||||
|
layout.addWidget(btn_experiment)
|
||||||
|
|
||||||
|
desc2_label = QLabel("Дизайн эксперимента, оптимизация процессов,\nмногомерный анализ и визуализация")
|
||||||
|
desc2_label.setAlignment(Qt.AlignCenter)
|
||||||
|
desc2_label.setWordWrap(True)
|
||||||
|
desc2_label.setStyleSheet("color: #555; font-size: 11px;")
|
||||||
|
layout.addWidget(desc2_label)
|
||||||
|
|
||||||
|
layout.addSpacing(15)
|
||||||
|
|
||||||
|
line = QFrame()
|
||||||
|
line.setFrameShape(QFrame.HLine)
|
||||||
|
line.setFrameShadow(QFrame.Sunken)
|
||||||
|
layout.addWidget(line)
|
||||||
|
|
||||||
|
bottom_layout = QHBoxLayout()
|
||||||
|
version_label = QLabel("Версия 1.0.0 | © 2026 Цифровой помощник биохимика")
|
||||||
|
version_label.setStyleSheet("color: #999; font-size: 10px;")
|
||||||
|
bottom_layout.addWidget(version_label)
|
||||||
|
bottom_layout.addStretch()
|
||||||
|
btn_exit = QPushButton("Выход")
|
||||||
|
btn_exit.setMaximumWidth(150)
|
||||||
|
btn_exit.setStyleSheet("QPushButton { background-color: #f44336; padding: 8px; font-size: 14px; } QPushButton:hover { background-color: #da190b; }")
|
||||||
|
btn_exit.clicked.connect(self.close)
|
||||||
|
bottom_layout.addWidget(btn_exit)
|
||||||
|
layout.addLayout(bottom_layout)
|
||||||
|
|
||||||
|
def open_medium_calculator(self):
|
||||||
|
from .medium_view import MediumCalculatorWindow
|
||||||
|
from ..controllers.medium_controller import MediumController
|
||||||
|
self.medium_calculator = MediumCalculatorWindow()
|
||||||
|
self.medium_controller = MediumController(self.medium_calculator)
|
||||||
|
self.medium_calculator.show()
|
||||||
|
|
||||||
|
def open_experiment_designer(self):
|
||||||
|
from .experiment_view import ExperimentDesignWindow
|
||||||
|
from ..controllers.experiment_controller import ExperimentController
|
||||||
|
self.experiment_window = ExperimentDesignWindow()
|
||||||
|
self.experiment_controller = ExperimentController(self.experiment_window)
|
||||||
|
self.experiment_window.show()
|
||||||
@@ -0,0 +1,207 @@
|
|||||||
|
from PyQt5.QtWidgets import (QMainWindow, QVBoxLayout, QHBoxLayout, QTableWidget, QTableWidgetItem,
|
||||||
|
QPushButton, QLabel, QDoubleSpinBox, QComboBox, QLineEdit, QWidget,
|
||||||
|
QMessageBox, QGroupBox, QFrame)
|
||||||
|
from PyQt5.QtCore import Qt
|
||||||
|
from PyQt5.QtGui import QFont, QColor
|
||||||
|
|
||||||
|
class MediumCalculatorWindow(QMainWindow):
|
||||||
|
def __init__(self):
|
||||||
|
super().__init__()
|
||||||
|
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; }
|
||||||
|
QPushButton { background-color: #2196F3; color: white; border: none; padding: 8px 15px; border-radius: 4px; font-weight: bold; }
|
||||||
|
QPushButton:hover { background-color: #1976D2; }
|
||||||
|
QPushButton#danger { background-color: #f44336; }
|
||||||
|
QPushButton#success { background-color: #4CAF50; }
|
||||||
|
QTableWidget { gridline-color: #ddd; background-color: white; }
|
||||||
|
QHeaderView::section { background-color: #1565C0; color: white; padding: 8px; font-weight: bold; }
|
||||||
|
""")
|
||||||
|
self._init_ui()
|
||||||
|
|
||||||
|
def _init_ui(self):
|
||||||
|
central_widget = QWidget()
|
||||||
|
self.setCentralWidget(central_widget)
|
||||||
|
layout = QVBoxLayout(central_widget)
|
||||||
|
layout.setSpacing(15)
|
||||||
|
layout.setContentsMargins(20, 20, 20, 20)
|
||||||
|
|
||||||
|
title_label = QLabel("Калькулятор питательных сред")
|
||||||
|
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()
|
||||||
|
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)
|
||||||
|
amount_layout.addWidget(self.amount_input)
|
||||||
|
self.amount_unit_combo = QComboBox()
|
||||||
|
self.amount_unit_combo.addItems(["нл", "мкл", "мл", "л"])
|
||||||
|
self.amount_unit_combo.setCurrentText("мл")
|
||||||
|
amount_layout.addWidget(self.amount_unit_combo)
|
||||||
|
params_layout.addLayout(amount_layout)
|
||||||
|
solvent_layout = QHBoxLayout()
|
||||||
|
solvent_layout.addWidget(QLabel("Растворитель:"))
|
||||||
|
self.solvent_input = QLineEdit("Вода")
|
||||||
|
solvent_layout.addWidget(self.solvent_input)
|
||||||
|
params_layout.addLayout(solvent_layout)
|
||||||
|
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)
|
||||||
|
self.table.setHorizontalHeaderLabels(["Название", "%", "Единица", "Коэфф.", "Разбавление (x)", "Количество"])
|
||||||
|
self.table.setAlternatingRowColors(True)
|
||||||
|
self.table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||||
|
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()
|
||||||
|
self.add_row_btn = QPushButton("➕ Добавить реагент")
|
||||||
|
self.remove_row_btn = QPushButton("➖ Удалить реагент")
|
||||||
|
self.remove_row_btn.setObjectName("danger")
|
||||||
|
btn_layout.addWidget(self.add_row_btn)
|
||||||
|
btn_layout.addWidget(self.remove_row_btn)
|
||||||
|
btn_layout.addStretch()
|
||||||
|
self.calculate_btn = QPushButton("🧮 Рассчитать")
|
||||||
|
self.calculate_btn.setObjectName("success")
|
||||||
|
self.save_btn = QPushButton("💾 Сохранить")
|
||||||
|
self.load_btn = QPushButton("📂 Загрузить")
|
||||||
|
btn_layout.addWidget(self.calculate_btn)
|
||||||
|
btn_layout.addWidget(self.save_btn)
|
||||||
|
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_layout.addWidget(info_label)
|
||||||
|
info_layout.addStretch()
|
||||||
|
layout.addWidget(info_frame)
|
||||||
|
|
||||||
|
def add_initial_rows(self):
|
||||||
|
self.add_solvent_row()
|
||||||
|
self.add_new_row()
|
||||||
|
|
||||||
|
def add_solvent_row(self):
|
||||||
|
row = self.table.rowCount()
|
||||||
|
self.table.insertRow(row)
|
||||||
|
solvent_item = QTableWidgetItem(self.solvent_input.text())
|
||||||
|
solvent_item.setFlags(solvent_item.flags() & ~Qt.ItemIsEditable)
|
||||||
|
solvent_item.setBackground(QColor(230, 230, 230))
|
||||||
|
font = QFont()
|
||||||
|
font.setBold(True)
|
||||||
|
solvent_item.setFont(font)
|
||||||
|
self.table.setItem(row, 0, solvent_item)
|
||||||
|
for col in [1, 3, 4]:
|
||||||
|
item = QTableWidgetItem("-")
|
||||||
|
item.setFlags(item.flags() & ~Qt.ItemIsEditable)
|
||||||
|
item.setBackground(QColor(230, 230, 230))
|
||||||
|
self.table.setItem(row, col, item)
|
||||||
|
unit_item = QTableWidgetItem(self.amount_unit_combo.currentText())
|
||||||
|
unit_item.setFlags(unit_item.flags() & ~Qt.ItemIsEditable)
|
||||||
|
unit_item.setBackground(QColor(230, 230, 230))
|
||||||
|
self.table.setItem(row, 2, unit_item)
|
||||||
|
result_item = QTableWidgetItem("")
|
||||||
|
result_item.setFlags(result_item.flags() & ~Qt.ItemIsEditable)
|
||||||
|
self.table.setItem(row, 5, result_item)
|
||||||
|
|
||||||
|
def update_solvent_name(self):
|
||||||
|
if self.table.rowCount() > 0 and self.table.item(0, 0):
|
||||||
|
self.table.item(0, 0).setText(self.solvent_input.text())
|
||||||
|
|
||||||
|
def add_new_row(self):
|
||||||
|
row = self.table.rowCount()
|
||||||
|
self.table.insertRow(row)
|
||||||
|
self.table.setItem(row, 0, QTableWidgetItem(f"Реагент_{row}"))
|
||||||
|
self.table.setItem(row, 1, QTableWidgetItem("0"))
|
||||||
|
unit_combo = QComboBox()
|
||||||
|
unit_combo.addItems(["нг", "мкг", "мг", "г", "кг", "нл", "мкл", "мл", "л"])
|
||||||
|
unit_combo.setCurrentText("мг")
|
||||||
|
self.table.setCellWidget(row, 2, unit_combo)
|
||||||
|
self.table.setItem(row, 3, QTableWidgetItem("1"))
|
||||||
|
self.table.setItem(row, 4, QTableWidgetItem("1"))
|
||||||
|
self.table.setItem(row, 5, QTableWidgetItem(""))
|
||||||
|
|
||||||
|
def remove_selected_row(self):
|
||||||
|
for row in sorted(set(item.row() for item in self.table.selectedItems()), reverse=True):
|
||||||
|
if row > 0:
|
||||||
|
self.table.removeRow(row)
|
||||||
|
|
||||||
|
def get_table_data(self):
|
||||||
|
data = []
|
||||||
|
for row in range(1, self.table.rowCount()):
|
||||||
|
name = self.table.item(row, 0).text() if self.table.item(row, 0) else ""
|
||||||
|
percent = self.table.item(row, 1).text() if self.table.item(row, 1) else "0"
|
||||||
|
unit_widget = self.table.cellWidget(row, 2)
|
||||||
|
unit = unit_widget.currentText() if unit_widget else "мг"
|
||||||
|
coeff = self.table.item(row, 3).text() if self.table.item(row, 3) else "1"
|
||||||
|
dilution = self.table.item(row, 4).text() if self.table.item(row, 4) else "1"
|
||||||
|
data.append([name, percent, unit, coeff, dilution])
|
||||||
|
return data
|
||||||
|
|
||||||
|
def update_solvent_percent(self, solvent_percent: float):
|
||||||
|
if self.table.rowCount() > 0 and self.table.item(0, 1):
|
||||||
|
self.table.item(0, 1).setText(self.format_number(solvent_percent))
|
||||||
|
|
||||||
|
def update_solvent_result(self, solvent_amount: float, unit: str):
|
||||||
|
if self.table.rowCount() > 0:
|
||||||
|
if self.table.item(0, 5):
|
||||||
|
self.table.item(0, 5).setText(self.format_number(solvent_amount))
|
||||||
|
self.table.item(0, 5).setBackground(QColor(220, 255, 220))
|
||||||
|
if self.table.item(0, 2):
|
||||||
|
self.table.item(0, 2).setText(unit)
|
||||||
|
|
||||||
|
def update_results(self, results: list):
|
||||||
|
for row, amount in enumerate(results, start=1):
|
||||||
|
if row < self.table.rowCount() and self.table.item(row, 5):
|
||||||
|
self.table.item(row, 5).setText(self.format_number(amount))
|
||||||
|
self.table.item(row, 5).setBackground(QColor(220, 255, 220))
|
||||||
|
|
||||||
|
def clear_results(self):
|
||||||
|
for row in range(self.table.rowCount()):
|
||||||
|
if self.table.item(row, 5):
|
||||||
|
self.table.item(row, 5).setText("")
|
||||||
|
if row == 0:
|
||||||
|
self.table.item(row, 5).setBackground(QColor(230, 230, 230))
|
||||||
|
else:
|
||||||
|
self.table.item(row, 5).setBackground(QColor(250, 250, 250))
|
||||||
|
if self.table.rowCount() > 0 and self.table.item(0, 1):
|
||||||
|
self.table.item(0, 1).setText("")
|
||||||
|
if self.table.rowCount() > 0 and self.table.item(0, 2):
|
||||||
|
self.table.item(0, 2).setText(self.amount_unit_combo.currentText())
|
||||||
|
|
||||||
|
def format_number(self, value):
|
||||||
|
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 show_error(self, message: str):
|
||||||
|
QMessageBox.critical(self, "Ошибка", message)
|
||||||
@@ -1,745 +0,0 @@
|
|||||||
# theme.py
|
|
||||||
"""
|
|
||||||
Настройки темы и цветовой схемы приложения
|
|
||||||
Централизованное управление стилями для единообразного внешнего вида
|
|
||||||
"""
|
|
||||||
|
|
||||||
from PyQt5.QtCore import Qt
|
|
||||||
from PyQt5.QtGui import QFontDatabase, QFont
|
|
||||||
|
|
||||||
# ========== ОСНОВНЫЕ ЦВЕТА ==========
|
|
||||||
class Colors:
|
|
||||||
"""Цветовая палитра приложения"""
|
|
||||||
|
|
||||||
# Основные цвета (Primary)
|
|
||||||
PRIMARY = "#3498db" # Синий - основной акцент
|
|
||||||
PRIMARY_DARK = "#777777" # Тёмно-синий (наведение)
|
|
||||||
PRIMARY_LIGHT = "#5dade2" # Светло-синий
|
|
||||||
PRIMARY_BG = "#ebf5fb" # Фоновый для primary элементов
|
|
||||||
|
|
||||||
# Успех/Позитив (Success)
|
|
||||||
SUCCESS = "#27ae60" # Зелёный
|
|
||||||
SUCCESS_DARK = "#219a52" # Тёмно-зелёный
|
|
||||||
SUCCESS_LIGHT = "#2ecc71" # Светло-зелёный
|
|
||||||
SUCCESS_BG = "#d5f5e3" # Фоновый для успеха
|
|
||||||
|
|
||||||
# Опасность/Ошибка (Danger)
|
|
||||||
DANGER = "#e74c3c" # Красный
|
|
||||||
DANGER_DARK = "#c0392b" # Тёмно-красный
|
|
||||||
DANGER_LIGHT = "#ec7063" # Светло-красный
|
|
||||||
DANGER_BG = "#fadbd8" # Фоновый для ошибок
|
|
||||||
|
|
||||||
# Предупреждение (Warning)
|
|
||||||
WARNING = "#f39c12" # Оранжевый
|
|
||||||
WARNING_DARK = "#e67e22" # Тёмно-оранжевый
|
|
||||||
WARNING_LIGHT = "#f5b041" # Светло-оранжевый
|
|
||||||
WARNING_BG = "#fef9e7" # Фоновый для предупреждений
|
|
||||||
|
|
||||||
# Информация (Info)
|
|
||||||
INFO = "#34495e" # Тёмно-синий/серый
|
|
||||||
INFO_LIGHT = "#5d6d7e" # Светлый вариант
|
|
||||||
|
|
||||||
# Нейтральные цвета (Neutral)
|
|
||||||
WHITE = "#ffffff"
|
|
||||||
BLACK = "#000000"
|
|
||||||
GRAY_100 = "#f8f9fa"
|
|
||||||
GRAY_200 = "#ecf0f1"
|
|
||||||
GRAY_300 = "#dee2e6"
|
|
||||||
GRAY_400 = "#ced4da"
|
|
||||||
GRAY_500 = "#adb5bd"
|
|
||||||
GRAY_600 = "#6c757d"
|
|
||||||
GRAY_700 = "#495057"
|
|
||||||
GRAY_800 = "#343a40"
|
|
||||||
GRAY_900 = "#212529"
|
|
||||||
|
|
||||||
# Цвета для таблиц
|
|
||||||
TABLE_ALTERNATE_ROW = "#f9f9f9"
|
|
||||||
TABLE_CENTER_POINT = "#ffffc8" # Жёлтый для центральных точек
|
|
||||||
TABLE_HIGHLIGHT = "#d4efdf" # Светло-зелёный для подсветки
|
|
||||||
|
|
||||||
# Цвета для текста
|
|
||||||
TEXT_PRIMARY = GRAY_900
|
|
||||||
TEXT_SECONDARY = GRAY_600
|
|
||||||
TEXT_MUTED = GRAY_500
|
|
||||||
TEXT_ON_PRIMARY = WHITE
|
|
||||||
TEXT_ON_DARK = WHITE
|
|
||||||
|
|
||||||
# Цвета для границ
|
|
||||||
BORDER_LIGHT = GRAY_300
|
|
||||||
BORDER_DEFAULT = GRAY_400
|
|
||||||
BORDER_DARK = GRAY_600
|
|
||||||
|
|
||||||
# Цвет для заголовка
|
|
||||||
TITLE_COLOR = "#2c3e50"
|
|
||||||
|
|
||||||
# Прозрачность
|
|
||||||
TRANSPARENT = "transparent"
|
|
||||||
OVERLAY = "rgba(0, 0, 0, 0.5)"
|
|
||||||
OVERLAY_LIGHT = "rgba(0, 0, 0, 0.1)"
|
|
||||||
|
|
||||||
# ========== НАСТРОЙКИ ШРИФТОВ ==========
|
|
||||||
class Fonts:
|
|
||||||
"""Настройки шрифтов"""
|
|
||||||
|
|
||||||
FAMILY_PRIMARY = "Segoe UI, Arial, sans-serif"
|
|
||||||
FAMILY_MONO = "Consolas, Monaco, monospace"
|
|
||||||
|
|
||||||
# Размеры
|
|
||||||
SIZE_TINY = 10
|
|
||||||
SIZE_SMALL = 11
|
|
||||||
SIZE_NORMAL = 12
|
|
||||||
SIZE_MEDIUM = 14
|
|
||||||
SIZE_LARGE = 16
|
|
||||||
SIZE_XLARGE = 18
|
|
||||||
SIZE_XXLARGE = 24
|
|
||||||
|
|
||||||
# Вес (жирность)
|
|
||||||
WEIGHT_NORMAL = 400
|
|
||||||
WEIGHT_MEDIUM = 500
|
|
||||||
WEIGHT_SEMIBOLD = 600
|
|
||||||
WEIGHT_BOLD = 700
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_title_font(cls):
|
|
||||||
"""Возвращает шрифт для заголовков"""
|
|
||||||
font = QFont(cls.FAMILY_PRIMARY.split(',')[0])
|
|
||||||
font.setPointSize(cls.SIZE_XXLARGE)
|
|
||||||
font.setBold(True)
|
|
||||||
return font
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_heading_font(cls, size=SIZE_LARGE):
|
|
||||||
"""Возвращает шрифт для подзаголовков"""
|
|
||||||
font = QFont(cls.FAMILY_PRIMARY.split(',')[0])
|
|
||||||
font.setPointSize(size)
|
|
||||||
font.setBold(True)
|
|
||||||
return font
|
|
||||||
|
|
||||||
@classmethod
|
|
||||||
def get_normal_font(cls):
|
|
||||||
"""Возвращает обычный шрифт"""
|
|
||||||
font = QFont(cls.FAMILY_PRIMARY.split(',')[0])
|
|
||||||
font.setPointSize(cls.SIZE_NORMAL)
|
|
||||||
return font
|
|
||||||
@classmethod
|
|
||||||
def get_emoji_font(cls):
|
|
||||||
"""Возвращает шрифт с поддержкой эмодзи"""
|
|
||||||
# Получаем список доступных шрифтов
|
|
||||||
available_fonts = QFontDatabase().families()
|
|
||||||
|
|
||||||
# Приоритетный список шрифтов с поддержкой эмодзи
|
|
||||||
emoji_fonts = [
|
|
||||||
"Segoe UI Emoji", # Windows 10/11
|
|
||||||
"Apple Color Emoji", # macOS
|
|
||||||
"Noto Color Emoji", # Linux
|
|
||||||
"EmojiOne Color", # Альтернативный
|
|
||||||
"Twemoji Mozilla", # Firefox
|
|
||||||
"Symbola", # Основные эмодзи
|
|
||||||
"Segoe UI Symbol", # Windows (старые версии)
|
|
||||||
]
|
|
||||||
|
|
||||||
# Ищем первый доступный шрифт
|
|
||||||
for font_name in emoji_fonts:
|
|
||||||
if font_name in available_fonts:
|
|
||||||
font = QFont(font_name)
|
|
||||||
font.setPointSize(cls.SIZE_NORMAL)
|
|
||||||
return font
|
|
||||||
|
|
||||||
# Если шрифты с эмодзи не найдены, возвращаем обычный шрифт
|
|
||||||
# и добавляем fallback для эмодзи через семейство шрифтов
|
|
||||||
fallback_font = cls.get_normal_font()
|
|
||||||
fallback_font.setFamily(f"{cls.FAMILY_PRIMARY}, Segoe UI Emoji, Apple Color Emoji")
|
|
||||||
return fallback_font
|
|
||||||
|
|
||||||
def setup_emoji_support(app: QApplication):
|
|
||||||
"""
|
|
||||||
Настраивает поддержку эмодзи во всём приложении
|
|
||||||
|
|
||||||
Параметры:
|
|
||||||
app: экземпляр QApplication
|
|
||||||
"""
|
|
||||||
# Устанавливаем шрифт по умолчанию с поддержкой эмодзи
|
|
||||||
font = Fonts.get_emoji_font()
|
|
||||||
app.setFont(font)
|
|
||||||
|
|
||||||
# Дополнительно настраиваем атрибуты шрифта для лучшего отображения
|
|
||||||
font.setStyleStrategy(QFont.PreferAntialias)
|
|
||||||
|
|
||||||
# Для Windows: включаем поддержку DirectWrite для лучшего отображения
|
|
||||||
try:
|
|
||||||
app.setAttribute(Qt.AA_UseHighDpiPixmaps, True)
|
|
||||||
app.setAttribute(Qt.AA_EnableHighDpiScaling, True)
|
|
||||||
except:
|
|
||||||
pass # Не все версии PyQt5 поддерживают эти атрибуты
|
|
||||||
|
|
||||||
|
|
||||||
# ========== ОТСТУПЫ И РАЗМЕРЫ ==========
|
|
||||||
class Spacing:
|
|
||||||
"""Отступы и размеры элементов"""
|
|
||||||
|
|
||||||
XS = 4 # Очень маленький
|
|
||||||
SM = 8 # Маленький
|
|
||||||
MD = 12 # Средний
|
|
||||||
LG = 16 # Большой
|
|
||||||
XL = 24 # Очень большой
|
|
||||||
XXL = 32 # Максимальный
|
|
||||||
|
|
||||||
# Размеры элементов
|
|
||||||
BUTTON_HEIGHT = 32
|
|
||||||
INPUT_HEIGHT = 30
|
|
||||||
TABLE_ROW_HEIGHT = 25
|
|
||||||
|
|
||||||
# Скругления
|
|
||||||
BORDER_RADIUS_SM = 4
|
|
||||||
BORDER_RADIUS_MD = 6
|
|
||||||
BORDER_RADIUS_LG = 8
|
|
||||||
BORDER_RADIUS_XL = 12
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# ========== СТИЛЬ ЗАГОЛОВКА ==========
|
|
||||||
|
|
||||||
class TitleStyles:
|
|
||||||
@staticmethod
|
|
||||||
def main_title():
|
|
||||||
return f"""
|
|
||||||
color: {Colors.TITLE_COLOR};
|
|
||||||
padding: {Spacing.XL}px;
|
|
||||||
font-size: {Fonts.SIZE_XLARGE}px;
|
|
||||||
font-weight: {Fonts.WEIGHT_BOLD};
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== СТИЛИ КНОПОК ==========
|
|
||||||
class ButtonStyles:
|
|
||||||
"""Стили для разных типов кнопок"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _base_style():
|
|
||||||
return f"""
|
|
||||||
border: none;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
padding: {Spacing.SM}px {Spacing.LG}px;
|
|
||||||
font-weight: {Fonts.WEIGHT_SEMIBOLD};
|
|
||||||
font-size: {Fonts.SIZE_NORMAL}px;
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def primary():
|
|
||||||
"""Основная кнопка (синяя)"""
|
|
||||||
return f"""
|
|
||||||
QPushButton {{
|
|
||||||
background-color: {Colors.PRIMARY};
|
|
||||||
color: {Colors.TEXT_ON_PRIMARY};
|
|
||||||
{ButtonStyles._base_style()}
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{
|
|
||||||
background-color: {Colors.PRIMARY_LIGHT};
|
|
||||||
}}
|
|
||||||
QPushButton:pressed {{
|
|
||||||
background-color: {Colors.PRIMARY_DARK};
|
|
||||||
}}
|
|
||||||
QPushButton:disabled {{
|
|
||||||
background-color: {Colors.GRAY_400};
|
|
||||||
color: {Colors.GRAY_600};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def success():
|
|
||||||
"""Кнопка успеха (зелёная)"""
|
|
||||||
return f"""
|
|
||||||
QPushButton {{
|
|
||||||
background-color: {Colors.SUCCESS};
|
|
||||||
color: {Colors.TEXT_ON_PRIMARY};
|
|
||||||
{ButtonStyles._base_style()}
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{
|
|
||||||
background-color: {Colors.SUCCESS_DARK};
|
|
||||||
}}
|
|
||||||
QPushButton:pressed {{
|
|
||||||
background-color: {Colors.SUCCESS_LIGHT};
|
|
||||||
}}
|
|
||||||
QPushButton:disabled {{
|
|
||||||
background-color: {Colors.GRAY_400};
|
|
||||||
color: {Colors.GRAY_600};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def danger():
|
|
||||||
"""Кнопка опасности/удаления (красная)"""
|
|
||||||
return f"""
|
|
||||||
QPushButton {{
|
|
||||||
background-color: {Colors.DANGER};
|
|
||||||
color: {Colors.TEXT_ON_PRIMARY};
|
|
||||||
{ButtonStyles._base_style()}
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{
|
|
||||||
background-color: {Colors.DANGER_DARK};
|
|
||||||
}}
|
|
||||||
QPushButton:pressed {{
|
|
||||||
background-color: {Colors.DANGER_LIGHT};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def warning():
|
|
||||||
"""Кнопка предупреждения (оранжевая)"""
|
|
||||||
return f"""
|
|
||||||
QPushButton {{
|
|
||||||
background-color: {Colors.WARNING};
|
|
||||||
color: {Colors.TEXT_ON_PRIMARY};
|
|
||||||
{ButtonStyles._base_style()}
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{
|
|
||||||
background-color: {Colors.WARNING_DARK};
|
|
||||||
}}
|
|
||||||
QPushButton:pressed {{
|
|
||||||
background-color: {Colors.WARNING_LIGHT};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def outline():
|
|
||||||
"""Контурная кнопка"""
|
|
||||||
return f"""
|
|
||||||
QPushButton {{
|
|
||||||
background-color: {Colors.TRANSPARENT};
|
|
||||||
color: {Colors.PRIMARY};
|
|
||||||
border: 1px solid {Colors.PRIMARY};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
padding: {Spacing.SM}px {Spacing.LG}px;
|
|
||||||
font-weight: {Fonts.WEIGHT_SEMIBOLD};
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{
|
|
||||||
background-color: {Colors.PRIMARY_BG};
|
|
||||||
}}
|
|
||||||
QPushButton:pressed {{
|
|
||||||
background-color: {Colors.PRIMARY_LIGHT};
|
|
||||||
color: {Colors.TEXT_ON_PRIMARY};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def ghost():
|
|
||||||
"""Прозрачная кнопка (только текст)"""
|
|
||||||
return f"""
|
|
||||||
QPushButton {{
|
|
||||||
background-color: {Colors.TRANSPARENT};
|
|
||||||
color: {Colors.PRIMARY};
|
|
||||||
border: none;
|
|
||||||
padding: {Spacing.SM}px {Spacing.LG}px;
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{
|
|
||||||
background-color: {Colors.PRIMARY_BG};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== СТИЛИ ПОЛЕЙ ВВОДА ==========
|
|
||||||
class InputStyles:
|
|
||||||
"""Стили для полей ввода"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def default():
|
|
||||||
return f"""
|
|
||||||
QComboBox {{
|
|
||||||
border: 1px solid {Colors.BORDER_DEFAULT};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
padding: 1px;
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
min-height: {Spacing.INPUT_HEIGHT - 16}px;
|
|
||||||
}}
|
|
||||||
QLineEdit, QDoubleSpinBox, QSpinBox {{
|
|
||||||
border: 1px solid {Colors.BORDER_DEFAULT};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
padding: {Spacing.SM}px;
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
min-height: {Spacing.INPUT_HEIGHT - 16}px;
|
|
||||||
}}
|
|
||||||
QLineEdit:focus, QDoubleSpinBox:focus, QSpinBox:focus, QComboBox:focus {{
|
|
||||||
border: 1px solid {Colors.PRIMARY};
|
|
||||||
outline: none;
|
|
||||||
}}
|
|
||||||
QLineEdit:disabled, QDoubleSpinBox:disabled, QSpinBox:disabled {{
|
|
||||||
background-color: {Colors.GRAY_200};
|
|
||||||
color: {Colors.GRAY_600};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
@staticmethod
|
|
||||||
def error():
|
|
||||||
return f"""
|
|
||||||
QLineEdit, QDoubleSpinBox, QSpinBox {{
|
|
||||||
border: 1px solid {Colors.DANGER};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
padding: {Spacing.SM}px;
|
|
||||||
background-color: {Colors.DANGER_BG};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def success_border():
|
|
||||||
return f"""
|
|
||||||
QLineEdit, QDoubleSpinBox, QSpinBox {{
|
|
||||||
border: 1px solid {Colors.SUCCESS};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
padding: {Spacing.SM}px;
|
|
||||||
background-color: {Colors.SUCCESS_BG};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== СТИЛИ ТАБЛИЦ ==========
|
|
||||||
class TableStyles:
|
|
||||||
"""Стили для таблиц"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def default():
|
|
||||||
return f"""
|
|
||||||
QTableWidget {{
|
|
||||||
gridline-color: {Colors.BORDER_LIGHT};
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
alternate-background-color: {Colors.TABLE_ALTERNATE_ROW};
|
|
||||||
selection-background-color: {Colors.PRIMARY_BG};
|
|
||||||
selection-color: {Colors.TEXT_PRIMARY};
|
|
||||||
}}
|
|
||||||
QHeaderView::section {{
|
|
||||||
background-color: {Colors.INFO};
|
|
||||||
color: {Colors.TEXT_ON_DARK};
|
|
||||||
padding: {Spacing.SM}px;
|
|
||||||
font-weight: {Fonts.WEIGHT_BOLD};
|
|
||||||
border: none;
|
|
||||||
}}
|
|
||||||
QTableWidget::item {{
|
|
||||||
padding: {Spacing.XS}px;
|
|
||||||
}}
|
|
||||||
QTableWidget::item:selected {{
|
|
||||||
background-color: {Colors.PRIMARY_BG};
|
|
||||||
color: {Colors.TEXT_PRIMARY};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def compact():
|
|
||||||
"""Компактный стиль таблицы"""
|
|
||||||
return f"""
|
|
||||||
QTableWidget {{
|
|
||||||
gridline-color: {Colors.BORDER_LIGHT};
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
}}
|
|
||||||
QHeaderView::section {{
|
|
||||||
background-color: {Colors.INFO};
|
|
||||||
color: {Colors.TEXT_ON_DARK};
|
|
||||||
padding: {Spacing.XS}px;
|
|
||||||
font-weight: {Fonts.WEIGHT_BOLD};
|
|
||||||
}}
|
|
||||||
QTableWidget::item {{
|
|
||||||
padding: {Spacing.XS}px;
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== СТИЛИ ВКЛАДОК ==========
|
|
||||||
class TabStyles:
|
|
||||||
"""Стили для вкладок (табов)"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def default():
|
|
||||||
return f"""
|
|
||||||
QTabWidget::pane {{
|
|
||||||
border: 1px solid {Colors.BORDER_DEFAULT};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
}}
|
|
||||||
QTabBar::tab {{
|
|
||||||
background-color: {Colors.GRAY_200};
|
|
||||||
padding: {Spacing.SM}px {Spacing.XL}px;
|
|
||||||
margin-right: {Spacing.XS}px;
|
|
||||||
border-top-left-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
border-top-right-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
}}
|
|
||||||
QTabBar::tab:selected {{
|
|
||||||
background-color: {Colors.PRIMARY};
|
|
||||||
color: {Colors.TEXT_ON_PRIMARY};
|
|
||||||
}}
|
|
||||||
QTabBar::tab:hover:!selected {{
|
|
||||||
background-color: {Colors.PRIMARY_BG};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== СТИЛИ ГРУПП ==========
|
|
||||||
class GroupBoxStyles:
|
|
||||||
"""Стили для групп (GroupBox)"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def default():
|
|
||||||
return f"""
|
|
||||||
QGroupBox {{
|
|
||||||
font-weight: {Fonts.WEIGHT_BOLD};
|
|
||||||
border: 2px solid {Colors.BORDER_DEFAULT};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
margin-top: {Spacing.LG}px;
|
|
||||||
padding-top: {Spacing.LG}px;
|
|
||||||
font-size: {Fonts.SIZE_MEDIUM}px;
|
|
||||||
}}
|
|
||||||
QGroupBox::title {{
|
|
||||||
|
|
||||||
subcontrol-origin: margin;
|
|
||||||
left: {Spacing.LG}px;
|
|
||||||
padding: 10 {Spacing.MD}px;
|
|
||||||
color: {Colors.INFO};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def card():
|
|
||||||
"""Стиль карточки"""
|
|
||||||
return f"""
|
|
||||||
QGroupBox {{
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
border: 1px solid {Colors.BORDER_DEFAULT};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_LG}px;
|
|
||||||
margin-top: {Spacing.LG}px;
|
|
||||||
padding-top: {Spacing.LG}px;
|
|
||||||
}}
|
|
||||||
QGroupBox::title {{
|
|
||||||
subcontrol-origin: margin;
|
|
||||||
left: {Spacing.LG}px;
|
|
||||||
padding: 10 {Spacing.MD}px;
|
|
||||||
color: {Colors.PRIMARY};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== СТИЛИ ПРОГРЕССА ==========
|
|
||||||
class ProgressStyles:
|
|
||||||
"""Стили для индикаторов прогресса"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def default():
|
|
||||||
return f"""
|
|
||||||
QProgressBar {{
|
|
||||||
border: 1px solid {Colors.BORDER_DEFAULT};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
text-align: center;
|
|
||||||
background-color: {Colors.GRAY_200};
|
|
||||||
}}
|
|
||||||
QProgressBar::chunk {{
|
|
||||||
background-color: {Colors.PRIMARY};
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def success():
|
|
||||||
return f"""
|
|
||||||
QProgressBar::chunk {{
|
|
||||||
background-color: {Colors.SUCCESS};
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== СТИЛИ СТАТУСОВ ==========
|
|
||||||
class StatusStyles:
|
|
||||||
"""Стили для статусных сообщений"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def info():
|
|
||||||
return f"""
|
|
||||||
background-color: {Colors.PRIMARY_BG};
|
|
||||||
color: {Colors.PRIMARY_DARK};
|
|
||||||
padding: {Spacing.MD}px;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
border-left: 4px solid {Colors.PRIMARY};
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def success():
|
|
||||||
return f"""
|
|
||||||
background-color: {Colors.SUCCESS_BG};
|
|
||||||
color: {Colors.SUCCESS_DARK};
|
|
||||||
padding: {Spacing.MD}px;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
border-left: 4px solid {Colors.SUCCESS};
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def warning():
|
|
||||||
return f"""
|
|
||||||
background-color: {Colors.WARNING_BG};
|
|
||||||
color: {Colors.WARNING_DARK};
|
|
||||||
padding: {Spacing.MD}px;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
border-left: 4px solid {Colors.WARNING};
|
|
||||||
"""
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def error():
|
|
||||||
return f"""
|
|
||||||
background-color: {Colors.DANGER_BG};
|
|
||||||
color: {Colors.DANGER_DARK};
|
|
||||||
padding: {Spacing.MD}px;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
border-left: 4px solid {Colors.DANGER};
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== ПОЛНАЯ ТЕМА ==========
|
|
||||||
def get_full_stylesheet():
|
|
||||||
"""
|
|
||||||
Возвращает полную таблицу стилей для приложения
|
|
||||||
"""
|
|
||||||
|
|
||||||
return f"""
|
|
||||||
/* СТИЛИ ДЛЯ ВСЕХ КНОПОК ПО УМОЛЧАНИЮ */
|
|
||||||
QPushButton {{
|
|
||||||
background-color: {Colors.PRIMARY};
|
|
||||||
color: {Colors.TEXT_ON_PRIMARY};
|
|
||||||
border: none;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
padding: {Spacing.SM}px {Spacing.LG}px;
|
|
||||||
font-weight: {Fonts.WEIGHT_SEMIBOLD};
|
|
||||||
font-size: {Fonts.SIZE_NORMAL}px;
|
|
||||||
}}
|
|
||||||
QPushButton:hover {{
|
|
||||||
background-color: {Colors.PRIMARY_LIGHT};
|
|
||||||
}}
|
|
||||||
QPushButton:pressed {{
|
|
||||||
background-color: {Colors.PRIMARY_DARK};
|
|
||||||
}}
|
|
||||||
|
|
||||||
/* Глобальные стили */
|
|
||||||
QLabel#mainTitle {{
|
|
||||||
{TitleStyles.main_title()}
|
|
||||||
}}
|
|
||||||
QMainWindow {{
|
|
||||||
background-color: {Colors.GRAY_200};
|
|
||||||
}}
|
|
||||||
|
|
||||||
QWidget {{
|
|
||||||
font-family: {Fonts.FAMILY_PRIMARY};
|
|
||||||
font-size: {Fonts.SIZE_NORMAL}px;
|
|
||||||
color: {Colors.TEXT_PRIMARY};
|
|
||||||
}}
|
|
||||||
|
|
||||||
QLabel {{
|
|
||||||
color: {Colors.TEXT_PRIMARY};
|
|
||||||
}}
|
|
||||||
|
|
||||||
/* Кнопки с идентификаторами */
|
|
||||||
QPushButton#primary {{
|
|
||||||
{ButtonStyles.primary()}
|
|
||||||
}}
|
|
||||||
|
|
||||||
QPushButton#success {{
|
|
||||||
{ButtonStyles.success()}
|
|
||||||
}}
|
|
||||||
|
|
||||||
QPushButton#danger {{
|
|
||||||
{ButtonStyles.danger()}
|
|
||||||
}}
|
|
||||||
|
|
||||||
QPushButton#warning {{
|
|
||||||
{ButtonStyles.warning()}
|
|
||||||
}}
|
|
||||||
|
|
||||||
QPushButton#outline {{
|
|
||||||
{ButtonStyles.outline()}
|
|
||||||
}}
|
|
||||||
|
|
||||||
/* Группы */
|
|
||||||
{GroupBoxStyles.default()}
|
|
||||||
|
|
||||||
/* Таблицы */
|
|
||||||
{TableStyles.default()}
|
|
||||||
|
|
||||||
/* Вкладки */
|
|
||||||
{TabStyles.default()}
|
|
||||||
|
|
||||||
/* Поля ввода */
|
|
||||||
{InputStyles.default()}
|
|
||||||
|
|
||||||
/* ScrollArea */
|
|
||||||
QScrollArea {{
|
|
||||||
border: none;
|
|
||||||
background-color: {Colors.TRANSPARENT};
|
|
||||||
}}
|
|
||||||
|
|
||||||
QScrollBar:vertical {{
|
|
||||||
border: none;
|
|
||||||
background-color: {Colors.GRAY_200};
|
|
||||||
width: {Spacing.LG}px;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
QScrollBar::handle:vertical {{
|
|
||||||
background-color: {Colors.GRAY_500};
|
|
||||||
min-height: {Spacing.XL}px;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_SM}px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
QScrollBar::handle:vertical:hover {{
|
|
||||||
background-color: {Colors.GRAY_600};
|
|
||||||
}}
|
|
||||||
|
|
||||||
QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{
|
|
||||||
border: none;
|
|
||||||
background: none;
|
|
||||||
}}
|
|
||||||
|
|
||||||
/* ToolBar */
|
|
||||||
QToolBar {{
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
border-bottom: 1px solid {Colors.BORDER_DEFAULT};
|
|
||||||
padding: {Spacing.SM}px;
|
|
||||||
spacing: {Spacing.SM}px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
QToolBar QToolButton {{
|
|
||||||
background-color: {Colors.TRANSPARENT};
|
|
||||||
padding: {Spacing.SM}px {Spacing.LG}px;
|
|
||||||
border-radius: {Spacing.BORDER_RADIUS_MD}px;
|
|
||||||
}}
|
|
||||||
|
|
||||||
QToolBar QToolButton:hover {{
|
|
||||||
background-color: {Colors.GRAY_200};
|
|
||||||
}}
|
|
||||||
|
|
||||||
/* Message Box */
|
|
||||||
QMessageBox {{
|
|
||||||
background-color: {Colors.WHITE};
|
|
||||||
}}
|
|
||||||
|
|
||||||
QMessageBox QPushButton {{
|
|
||||||
min-width: 80px;
|
|
||||||
padding: {Spacing.SM}px;
|
|
||||||
}}
|
|
||||||
"""
|
|
||||||
|
|
||||||
# ========== ВСПОМОГАТЕЛЬНЫЕ ФУНКЦИИ ==========
|
|
||||||
def apply_theme(widget):
|
|
||||||
"""
|
|
||||||
Применяет тему к виджету и всем его дочерним элементам
|
|
||||||
"""
|
|
||||||
from PyQt5.QtWidgets import QApplication
|
|
||||||
|
|
||||||
# Устанавливаем стиль приложения
|
|
||||||
app = QApplication.instance()
|
|
||||||
if app:
|
|
||||||
app.setStyleSheet(get_full_stylesheet())
|
|
||||||
|
|
||||||
# Дополнительно устанавливаем виджету
|
|
||||||
if widget:
|
|
||||||
widget.setStyleSheet(get_full_stylesheet())
|
|
||||||
|
|
||||||
def get_primary_color():
|
|
||||||
"""Возвращает основной цвет приложения"""
|
|
||||||
return Colors.PRIMARY
|
|
||||||
|
|
||||||
def get_success_color():
|
|
||||||
"""Возвращает цвет успеха"""
|
|
||||||
return Colors.SUCCESS
|
|
||||||
|
|
||||||
def get_danger_color():
|
|
||||||
"""Возвращает цвет опасности"""
|
|
||||||
return Colors.DANGER
|
|
||||||
|
|
||||||
def get_warning_color():
|
|
||||||
"""Возвращает цвет предупреждения"""
|
|
||||||
return Colors.WARNING
|
|
||||||
Reference in New Issue
Block a user