czty commited on
Commit
a9e46a4
·
verified ·
1 Parent(s): b1425d0

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. BioScientist/.DS_Store +0 -0
  2. BioScientist/.env.example +12 -0
  3. BioScientist/.gitignore +11 -0
  4. BioScientist/.ssh/config +5 -0
  5. BioScientist/.ssh/id_ed25519 +7 -0
  6. BioScientist/.ssh/id_ed25519.pub +1 -0
  7. BioScientist/README.md +237 -0
  8. BioScientist/agent_system/__init__.py +11 -0
  9. BioScientist/agent_system/bioinfomcp_converter.py +254 -0
  10. BioScientist/agent_system/engines/__init__.py +5 -0
  11. BioScientist/agent_system/engines/bioinfo_platform_pipleline.svg +34 -0
  12. BioScientist/agent_system/engines/e1_v1_sequence.svg +99 -0
  13. BioScientist/agent_system/engines/e1_validator.py +1400 -0
  14. BioScientist/agent_system/engines/t1_consultant.py +625 -0
  15. BioScientist/agent_system/engines/test_gemini.py +25 -0
  16. BioScientist/agent_system/engines/v1_executor.py +1348 -0
  17. BioScientist/agent_system/engines/v1_executor_backup/__init__.py +3 -0
  18. BioScientist/agent_system/engines/v1_executor_backup/agent/__init__.py +1 -0
  19. BioScientist/agent_system/engines/v1_executor_backup/agent/__pycache__/__init__.cpython-311.pyc +0 -0
  20. BioScientist/agent_system/engines/v1_executor_backup/agent/__pycache__/__init__.cpython-313.pyc +0 -0
  21. BioScientist/agent_system/engines/v1_executor_backup/agent/a1.py +0 -0
  22. BioScientist/agent_system/engines/v1_executor_backup/agent/env_collection.py +313 -0
  23. BioScientist/agent_system/engines/v1_executor_backup/agent/function_generator.py +119 -0
  24. BioScientist/agent_system/engines/v1_executor_backup/agent/qa_llm.py +50 -0
  25. BioScientist/agent_system/engines/v1_executor_backup/agent/react.py +465 -0
  26. BioScientist/agent_system/engines/v1_executor_backup/config.py +99 -0
  27. BioScientist/agent_system/engines/v1_executor_backup/env_desc.py +221 -0
  28. BioScientist/agent_system/engines/v1_executor_backup/env_desc_cm.py +219 -0
  29. BioScientist/agent_system/engines/v1_executor_backup/eval/__init__.py +3 -0
  30. BioScientist/agent_system/engines/v1_executor_backup/eval/biomni_eval1.py +324 -0
  31. BioScientist/agent_system/engines/v1_executor_backup/llm.py +276 -0
  32. BioScientist/agent_system/engines/v1_executor_backup/mcp_config_bioscientist_generated.yaml +0 -0
  33. BioScientist/agent_system/engines/v1_executor_backup/task/__init__.py +0 -0
  34. BioScientist/agent_system/engines/v1_executor_backup/task/base_task.py +18 -0
  35. BioScientist/agent_system/engines/v1_executor_backup/task/hle.py +146 -0
  36. BioScientist/agent_system/engines/v1_executor_backup/task/lab_bench.py +115 -0
  37. BioScientist/agent_system/engines/v1_executor_backup/tool/__init__.py +1 -0
  38. BioScientist/agent_system/engines/v1_executor_backup/tool/biochemistry.py +1026 -0
  39. BioScientist/agent_system/engines/v1_executor_backup/tool/bioimaging.py +1394 -0
  40. BioScientist/agent_system/engines/v1_executor_backup/tool/biophysics.py +513 -0
  41. BioScientist/agent_system/engines/v1_executor_backup/tool/cancer_biology.py +1292 -0
  42. BioScientist/agent_system/engines/v1_executor_backup/tool/cell_biology.py +742 -0
  43. BioScientist/agent_system/engines/v1_executor_backup/tool/database.py +0 -0
  44. BioScientist/agent_system/engines/v1_executor_backup/tool/genetics.py +1672 -0
  45. BioScientist/agent_system/engines/v1_executor_backup/tool/genomics.py +0 -0
  46. BioScientist/agent_system/engines/v1_executor_backup/tool/glycoengineering.py +146 -0
  47. BioScientist/agent_system/engines/v1_executor_backup/tool/immunology.py +1972 -0
  48. BioScientist/agent_system/engines/v1_executor_backup/tool/lab_automation.py +654 -0
  49. BioScientist/agent_system/engines/v1_executor_backup/tool/literature.py +403 -0
  50. BioScientist/agent_system/engines/v1_executor_backup/tool/microbiology.py +1618 -0
BioScientist/.DS_Store ADDED
Binary file (14.3 kB). View file
 
BioScientist/.env.example ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ AZURE_OPENAI_API_VERSION = "2024-12-01-preview" # or the latest version available
2
+ MODEL_NAME = "<model-name>" # e.g., "gpt-4o", "gpt-4o-mini", "gpt-4.1-turbo", "gpt-4.1-mini"
3
+
4
+ # If you are using Azure OpenAI, set the following variables
5
+ AZURE_OPENAI_ENDPOINT='https://<azure-endpoint>.cognitiveservices.azure.com/'
6
+ AZURE_OPENAI_KEY='<azure-key>'
7
+
8
+ # If you are using OpenAI, set the following variable
9
+ OPENAI_API_KEY = '<openai-key>'
10
+
11
+ # If you want to test it into an Anthropic Environment, set the following variable
12
+ ANTHROPIC_API_KEY = '<anthropic-key>'
BioScientist/.gitignore ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .env
2
+ agent_system/toolbase/output/
3
+ agent_system/toolbase/mcp_batch_from_help_txt/
4
+ agent_system/toolbase/mcp_batch_from_manual_txt/
5
+ shared_knowledge/
6
+ data/
7
+ agent_system/toolbase/output/benchmark/outputs/
8
+ *.safetensors
9
+ *.pt
10
+ *.bin
11
+ *.pth
BioScientist/.ssh/config ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Host github.com
2
+ HostName github.com
3
+ User git
4
+ IdentityFile /225040511/.ssh/id_ed25519
5
+ IdentitiesOnly yes
BioScientist/.ssh/id_ed25519 ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ -----BEGIN OPENSSH PRIVATE KEY-----
2
+ b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
3
+ QyNTUxOQAAACD5TjQiYWgUxn31139cNMR2LVw7LwefRg7EzzwP0w5v0QAAAJgx8g6IMfIO
4
+ iAAAAAtzc2gtZWQyNTUxOQAAACD5TjQiYWgUxn31139cNMR2LVw7LwefRg7EzzwP0w5v0Q
5
+ AAAEBfMcilCyky26QzJUwewu0iHwy/NiAz8FTeWBwccGlXgPlONCJhaBTGffXXf1w0xHYt
6
+ XDsvB59GDsTPPA/TDm/RAAAAEGN6dHkxOUBnbWFpbC5jb20BAgMEBQ==
7
+ -----END OPENSSH PRIVATE KEY-----
BioScientist/.ssh/id_ed25519.pub ADDED
@@ -0,0 +1 @@
 
 
1
+ ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPlONCJhaBTGffXXf1w0xHYtXDsvB59GDsTPPA/TDm/R czty19@gmail.com
BioScientist/README.md ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # BioScientist Agent System
2
+
3
+ An orchestration layer for **agentic bioinformatics workflows** that combines:
4
+
5
+ - Dynamic MCP tool registration
6
+ - Dual-mode reasoning (`T1`) and execution (`V1`)
7
+ - Layered validation (`E1`) with Agent System integration
8
+ - File-based shared memory for continual improvement
9
+
10
+ ---
11
+
12
+ ## Why This Repository Exists
13
+
14
+ `BioScientist/agent_system` is designed to run practical computational biology tasks with a structured loop:
15
+
16
+ 1. Propose hypotheses and strategies
17
+ 2. Validate them with increasing rigor (L1 -> L4)
18
+ 3. Capture execution evidence and insights
19
+ 4. Reuse those insights for better next-round planning
20
+
21
+ This gives you a reproducible bridge between **LLM reasoning**, **MCP tools**, and **real data artifacts**.
22
+
23
+ ---
24
+
25
+ ## Architecture Overview
26
+
27
+ ### Core Components
28
+
29
+ - `V1 Executor` (`engines/v1_executor.py`)
30
+ - Pipeline-style task executor
31
+ - Routes tools through MCP servers
32
+ - Supports local and Docker backends
33
+ - Persists run artifacts and reports
34
+
35
+ - `T1 Consultant` (`engines/t1_consultant.py`)
36
+ - Reflection and strategy engine
37
+ - Generates hypotheses (with data-grounded operations)
38
+ - Ranks hypotheses by historical success proxies
39
+
40
+ - `E1 Validator` (`engines/e1_validator.py`)
41
+ - Multi-level validation engine:
42
+ - `L1`: rule consistency
43
+ - `L2`: Agent System/MCP readiness
44
+ - `L3`: lightweight data-backed validation
45
+ - `L4`: extended data-backed validation
46
+ - Calls Agent System runtime `A1.go(...)` when available
47
+
48
+ - `Shared Knowledge Space` (`shared_memory.py`)
49
+ - File-based memory bridge across modules
50
+ - Stores experiment reports, hypotheses, validation reports, insights, and summary statistics
51
+
52
+ - `Orchestrator` (`orchestrator.py`)
53
+ - Unified entrypoint wiring `V1 + T1 + E1`
54
+ - Exposes high-level modes like:
55
+ - `execute`
56
+ - `consult`
57
+ - `autopilot`
58
+ - `hypothesis-generate`
59
+ - `hypothesis-loop`
60
+
61
+ ### Runtime Flow (Hypothesis Loop)
62
+
63
+ ```text
64
+ User Query
65
+ |
66
+ v
67
+ T1: generate + rank hypotheses
68
+ |
69
+ v
70
+ E1: optional MCP registration -> validation (L1/L2/L3/L4)
71
+ |
72
+ v
73
+ Agent System runtime (A1 + add_mcp + go) [when enabled/available]
74
+ |
75
+ v
76
+ Artifacts + Reports + Insights -> Shared Knowledge
77
+ ```
78
+
79
+ ---
80
+
81
+ ## Project Layout (Key Paths)
82
+
83
+ ```text
84
+ BioScientist/
85
+ ├── agent_system/
86
+ │ ├── main.py
87
+ │ ├── orchestrator.py
88
+ │ ├── engines/
89
+ │ │ ├── v1_executor.py
90
+ │ │ ├── t1_consultant.py
91
+ │ │ └── e1_validator.py
92
+ │ ├── shared_memory.py
93
+ │ ├── toolbase/
94
+ │ │ ├── data/biomni_data/
95
+ │ │ └── register_mcp_servers_to_biomni.py
96
+ │ └── results/
97
+ └── README.md
98
+ ```
99
+
100
+ ---
101
+
102
+ ## Environment Setup (Required)
103
+
104
+ Our software environment is large. We provide a single setup script to bootstrap dependencies.
105
+
106
+ ### 1) Activate the E1 environment first
107
+
108
+ ```bash
109
+ conda activate biomni_e1
110
+ ```
111
+
112
+ ### 2) Install the official pip package first
113
+
114
+ ```bash
115
+ pip install biomni --upgrade
116
+ ```
117
+
118
+ ### 3) Run the unified setup script
119
+
120
+ ```bash
121
+ # from the repository root
122
+ bash setup.sh
123
+ ```
124
+
125
+ ### 4) (Optional, recommended) Install the latest Biomni from source
126
+
127
+ ```bash
128
+ pip install git+https://github.com/snap-stanford/Biomni.git@main
129
+ ```
130
+
131
+ ---
132
+
133
+ ## Quick Start: Hypothesis Loop (Single-Cell Normalization)
134
+
135
+ ### 1) Set Environment Variables
136
+
137
+ Use your current setup pattern:
138
+
139
+ ```bash
140
+ export GEMINI_API_KEY="<YOUR_GEMINI_API_KEY>"
141
+ export T1_MODEL_BACKEND="gemini"
142
+ export GEMINI_MODEL="gemini-2.5-flash-lite"
143
+ export GEMINI_TEMPERATURE="0.2"
144
+ export GEMINI_TIMEOUT_SECONDS="60"
145
+ export BIOMNI_PATH=/225040511/project/Biomni/data
146
+ export BIOMNI_SOURCE="Gemini"
147
+ ```
148
+
149
+ Optional (recommended for explicit control):
150
+
151
+ ```bash
152
+ export BIOCLAW_BIOMNI_ROOT=/225040511/project/BioScientist/agent_system/engines/v1_executor_backup
153
+ ```
154
+
155
+ ### 2) Run the End-to-End Loop
156
+
157
+ ```bash
158
+ python -m agent_system.main \
159
+ --project_root /225040511/project/BioScientist \
160
+ hypothesis-loop \
161
+ --task_scope single_cell_normalization \
162
+ --user_query "Give me new ideas for normalizing single-cell data" \
163
+ --n 2 \
164
+ --top_k 1 \
165
+ --validate_top_m 1 \
166
+ --validation_level L4 \
167
+ --register_mcp true
168
+ ```
169
+
170
+ ### 3) Where to Find Outputs
171
+
172
+ - Loop-level result:
173
+ - `agent_system/results/hypothesis-loop_<timestamp>.json`
174
+ - Validation runtime reports:
175
+ - `agent_system/results/e1_runtime/*.json`
176
+ - Data-backed L3/L4 artifacts:
177
+ - `agent_system/results/l3_reports/*.json`
178
+ - `agent_system/results/l4_reports/*.json`
179
+ - Agent System execution payloads:
180
+ - `agent_system/results/biomni_exec/*.json`
181
+
182
+ ---
183
+
184
+ ## Other CLI Modes
185
+
186
+ ### Generate hypotheses only
187
+
188
+ ```bash
189
+ python -m agent_system.main \
190
+ --project_root /225040511/project/BioScientist \
191
+ hypothesis-generate \
192
+ --task_scope single_cell_normalization \
193
+ --user_query "Give me new ideas for normalizing single-cell data" \
194
+ --n 10 \
195
+ --top_k 5
196
+ ```
197
+
198
+ ### Register MCP servers only
199
+
200
+ ```bash
201
+ python -m agent_system.main \
202
+ --project_root /225040511/project/BioScientist \
203
+ register-mcp
204
+ ```
205
+
206
+ ### Consult mode (strategy only)
207
+
208
+ ```bash
209
+ python -m agent_system.main \
210
+ --project_root /225040511/project/BioScientist \
211
+ consult \
212
+ --task_scope single_cell_normalization \
213
+ --user_goal "Design a robust normalization strategy for cross-batch scRNA-seq."
214
+ ```
215
+
216
+ ---
217
+
218
+ ## Notes and Troubleshooting
219
+
220
+ - If Agent System runtime returns API/provider errors (for example region restrictions), E1 may return `inconclusive` even when local data checks succeed.
221
+ - L3/L4 still produce useful tabular evidence (`rows_scanned`, `missing_rate`, numeric summaries, relevance scores).
222
+ - If you want fully offline execution, switch to a local model source (for example Ollama) and update relevant environment variables.
223
+ - Large MCP sets are automatically reduced in E1 probe mode via minimal config generation for faster startup.
224
+
225
+ ---
226
+
227
+ ## Status Semantics
228
+
229
+ - `success`: validation/execution reached expected criteria
230
+ - `inconclusive`: partial evidence available but one or more critical external/runtime checks failed
231
+ - `failed`: contradiction or hard runtime failure detected
232
+
233
+ ---
234
+
235
+ ## License
236
+
237
+ This repository is released under the MIT License (see `LICENSE`).
BioScientist/agent_system/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Dual-mode agent system for BioinfoMCP.
2
+
3
+ This package introduces:
4
+ - BioinfoV1Executor: deterministic execution engine
5
+ - BioinfoT1Consultant: reflection and strategy engine
6
+ - SharedKnowledgeSpace: bridge for reports, insights, and pipeline configs
7
+ """
8
+
9
+ from .orchestrator import DualModeAgentSystem
10
+
11
+ __all__ = ["DualModeAgentSystem"]
BioScientist/agent_system/bioinfomcp_converter.py ADDED
@@ -0,0 +1,254 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import requests
4
+ import ast # this is just for check code syntax whether or not it is correct
5
+ import re
6
+ from typing import List
7
+ import pymupdf
8
+ from dotenv import load_dotenv
9
+ from pathlib import Path
10
+
11
+ load_dotenv()
12
+
13
+
14
+ '''
15
+ # 0. Check whether that particular library is already installed (otherwise cannot run the help function)
16
+ # 1. Run the --help function
17
+ # 2. Let GPT-4 analyze the result from the help manual, attain the
18
+ a) What tools are there
19
+ i) For each tools, what is the input, output
20
+ ii) the CLI command, error handling
21
+ iii) capture output
22
+ iv) check output whether it is correct
23
+ '''
24
+
25
+
26
+ class BioinfoMCP():
27
+ def __init__(self, model="openai"):
28
+ prompt_path = Path(__file__).resolve().parent / "system_prompt.txt"
29
+ with open(prompt_path, "r", encoding="utf-8") as file:
30
+ self.sys_prompt = file.read()
31
+ self.backend_model = model.lower()
32
+ self.api_model_name = os.getenv('MODEL_NAME')
33
+ self.client = None
34
+
35
+ if self.backend_model == "azure":
36
+ from openai import AzureOpenAI
37
+ # Load Environment Keys
38
+ api_subscription_key = os.getenv('AZURE_OPENAI_KEY')
39
+ api_version = os.getenv('AZURE_OPENAI_API_VERSION')
40
+ api_endpoint = os.getenv('AZURE_OPENAI_ENDPOINT')
41
+ # initialize the Azure Client
42
+ self.client = AzureOpenAI(
43
+ api_version=api_version,
44
+ azure_endpoint=api_endpoint,
45
+ api_key=api_subscription_key,
46
+ )
47
+ self.api_model_name = self.api_model_name or os.getenv("AZURE_OPENAI_MODEL")
48
+
49
+ elif self.backend_model == "openai":
50
+ from openai import OpenAI
51
+ # Load Environment Keys
52
+ openai_api_key = os.getenv('OPENAI_API_KEY')
53
+ # initialize the OpenAI Client
54
+ self.client = OpenAI(
55
+ api_key=openai_api_key
56
+ )
57
+ self.api_model_name = self.api_model_name or os.getenv("OPENAI_MODEL_NAME")
58
+
59
+ elif self.backend_model == "gemini":
60
+ # Gemini uses HTTP API directly via requests
61
+ self.gemini_api_key = os.getenv("GEMINI_API_KEY")
62
+ self.api_model_name = self.api_model_name or os.getenv("GEMINI_MODEL_NAME") or "gemini-1.5-pro"
63
+ self.gemini_api_base = os.getenv("GEMINI_API_BASE", "https://generativelanguage.googleapis.com")
64
+ fallback_raw = os.getenv("GEMINI_MODEL_FALLBACKS", "gemini-2.0-flash,gemini-1.5-pro,gemini-1.5-flash")
65
+ self.gemini_model_fallbacks: List[str] = [m.strip() for m in fallback_raw.split(",") if m.strip()]
66
+ self.max_help_chars = int(os.getenv("BIOINFOMCP_MAX_HELP_CHARS", "60000"))
67
+ else:
68
+ raise ValueError(f"Unsupported model backend: {model}. Use one of openai/azure/gemini")
69
+
70
+ print(f"Succesfully created backend={self.backend_model}, model={self.api_model_name}")
71
+
72
+
73
+
74
+ def is_tool_available(self, tool_name):
75
+ """Check whether that tool is installed or not"""
76
+ try:
77
+ _ = subprocess.run([tool_name, '--version'],
78
+ capture_output=True, timeout=20, text=True)
79
+ print(f"{tool_name} is installed")
80
+ return True
81
+ except:
82
+ print(f"{tool_name} is not installed!")
83
+ return False
84
+
85
+ def extract_help_document(self, tool_name, manual, run_help_command=False):
86
+ """Extract help text from tool"""
87
+ if not run_help_command: # document provided by user, no need to run the help command
88
+ manual_path = Path(str(manual))
89
+ if manual_path.suffix.lower() == ".pdf" and manual_path.exists():
90
+ manual_doc = pymupdf.open(f'{manual}')
91
+ manual_content = ""
92
+ for page in manual_doc:
93
+ text = page.get_text()
94
+ manual_content += text
95
+ return manual_content
96
+ if manual_path.exists():
97
+ return manual_path.read_text(encoding="utf-8", errors="ignore")
98
+ return str(manual)
99
+
100
+ if self.is_tool_available(tool_name):
101
+ try:
102
+ result = subprocess.run([tool_name, manual],
103
+ capture_output=True, timeout=30, text=True)
104
+ return result.stdout + result.stderr
105
+ except:
106
+ return None
107
+ return None
108
+
109
+ def _call_openai_like(self, prompt):
110
+ response = self.client.chat.completions.create(
111
+ messages=[
112
+ {
113
+ "role" : "system",
114
+ "content": [{"type": "text", "text": self.sys_prompt}],
115
+ },
116
+ {
117
+ "role" : "user",
118
+ "content": [{"type": "text", "text": prompt}],
119
+ }
120
+ ],
121
+ temperature=0.1,
122
+ model=self.api_model_name
123
+ )
124
+ return response.choices[0].message.content
125
+
126
+ def _call_gemini(self, prompt):
127
+ if not getattr(self, "gemini_api_key", None):
128
+ raise ValueError("GEMINI_API_KEY is required for gemini backend")
129
+ models = [self.api_model_name] + [m for m in self.gemini_model_fallbacks if m != self.api_model_name]
130
+ headers = {
131
+ "Content-Type": "application/json",
132
+ "x-goog-api-key": self.gemini_api_key,
133
+ }
134
+ errors = []
135
+ for model_name in models:
136
+ url = f"{self.gemini_api_base}/v1beta/models/{model_name}:generateContent"
137
+ payload = {
138
+ "system_instruction": {
139
+ "parts": [{"text": self.sys_prompt}]
140
+ },
141
+ "contents": [
142
+ {
143
+ "role": "user",
144
+ "parts": [{"text": prompt}],
145
+ }
146
+ ],
147
+ "generationConfig": {
148
+ "temperature": 0.1,
149
+ },
150
+ }
151
+ try:
152
+ resp = requests.post(url, headers=headers, json=payload, timeout=180)
153
+ if resp.status_code >= 400:
154
+ body = resp.text[:1200]
155
+ errors.append(f"{model_name}: HTTP {resp.status_code} - {body}")
156
+ continue
157
+ data = resp.json()
158
+ candidates = data.get("candidates", [])
159
+ if not candidates:
160
+ errors.append(f"{model_name}: empty candidates")
161
+ continue
162
+ parts = candidates[0].get("content", {}).get("parts", [])
163
+ texts = [p.get("text", "") for p in parts if isinstance(p, dict)]
164
+ joined = "\n".join(texts).strip()
165
+ if joined:
166
+ return joined
167
+ errors.append(f"{model_name}: empty text in candidate parts")
168
+ except Exception as exc:
169
+ errors.append(f"{model_name}: exception {exc}")
170
+ continue
171
+ raise RuntimeError("Gemini request failed across all models. " + " | ".join(errors[-3:]))
172
+
173
+ def _call_llm(self, prompt):
174
+ if self.backend_model in ("openai", "azure"):
175
+ return self._call_openai_like(prompt)
176
+ if self.backend_model == "gemini":
177
+ return self._call_gemini(prompt)
178
+ raise ValueError(f"Unsupported backend: {self.backend_model}")
179
+
180
+ def generate_prompt(self, tool_name, help_docs):
181
+ """Generate the Prompt that later will be send to the OpenAI client"""
182
+ prompt = f"""
183
+ Convert the following bioinformatics tool into an MCP tool definition.
184
+
185
+ Tool Name: {tool_name}
186
+ Help Document:
187
+ {help_docs}
188
+
189
+ Parse the Input parameters correctly, follow the MCP best practices, and provide the complete python code with the @mcp.tool() decorator.
190
+ """
191
+ return prompt
192
+
193
+ def parse_mcpcode(self, gpt_response):
194
+ code_block = re.findall('```python\n(.*?)\n```', gpt_response, re.DOTALL)
195
+ if not code_block:
196
+ return (0, "There is no python code found in the gpt_response", None)
197
+
198
+ code = code_block[0]
199
+ try:
200
+ ast.parse(code)
201
+ except SyntaxError as e:
202
+ return (0, f"Code is not working with the following SyntaxError: {e}", code)
203
+
204
+ # Check the @mcp.tool() decorator
205
+ if '@mcp.tool' not in code:
206
+ return (0, "Code is missing the @mcp.tool() decorator", code)
207
+
208
+ return (1, None, code)
209
+
210
+ def refine_after_feedback(self, tool_name, code, error_message):
211
+ prompt = f"""
212
+ The initial code block for {tool_name}:
213
+ ```python
214
+ {code}
215
+ ```
216
+ contains the following Error:
217
+ {error_message}
218
+
219
+ Please fix the code and ensure that
220
+ 1. Cover every internal functions of the tool
221
+ 2. Has proper error handling
222
+ 3. Validates input parameters
223
+ 4. Returns structured output
224
+ 5. Follows MCP best practices
225
+
226
+ Provide only the corrected python code.
227
+
228
+ """
229
+ response_content = self._call_llm(prompt)
230
+ # output_file = open(f'./mcp_result/raw_{tool_name}', 'w')
231
+ # output_file.write(response_content)
232
+ return self.parse_mcpcode(response_content)
233
+
234
+ def autogenerate_mcp_tool(self, tool_name, manual, run_help_command):
235
+ """Autogenerate the MCP Tool using the extracted help documents"""
236
+ help_docs = self.extract_help_document(tool_name, manual, run_help_command)
237
+ if help_docs is None:
238
+ help_docs = ""
239
+ if self.backend_model == "gemini":
240
+ max_chars = getattr(self, "max_help_chars", 60000)
241
+ if len(help_docs) > max_chars:
242
+ kept = help_docs[:max_chars]
243
+ help_docs = (
244
+ f"{kept}\n\n"
245
+ f"[Truncated help document: original_length={len(help_docs)}, kept={max_chars}]"
246
+ )
247
+ # web_docs = self.fetch_web_docs(tool_name)
248
+ prompt = self.generate_prompt(tool_name, help_docs)
249
+ response_content = self._call_llm(prompt)
250
+ #print(response_content)
251
+ # output_file = open(f'./mcp_result/raw_{tool_name}', 'w')
252
+ # output_file.write(response.choices[0].message.content)
253
+
254
+ return self.parse_mcpcode(response_content)
BioScientist/agent_system/engines/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ from .v1_executor import BioinfoV1Executor
2
+ from .t1_consultant import BioinfoT1Consultant
3
+ from .e1_validator import BioinfoE1Validator
4
+
5
+ __all__ = ["BioinfoV1Executor", "BioinfoT1Consultant", "BioinfoE1Validator"]
BioScientist/agent_system/engines/bioinfo_platform_pipleline.svg ADDED
BioScientist/agent_system/engines/e1_v1_sequence.svg ADDED
BioScientist/agent_system/engines/e1_validator.py ADDED
@@ -0,0 +1,1400 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import csv
4
+ import json
5
+ import os
6
+ import subprocess
7
+ import sys
8
+ import importlib.util
9
+ import statistics
10
+ import urllib.request
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+ from typing import Any
14
+
15
+ from ..schemas import Hypothesis, Insight, ValidationReport
16
+ from ..shared_memory import SharedKnowledgeSpace
17
+
18
+
19
+ class BioinfoE1Validator:
20
+ """
21
+ E1 validator: layered and cost-aware hypothesis validation.
22
+ First version:
23
+ - L1: logical consistency / rule checks
24
+ - L2: light tooling readiness check using Biomni + registered MCP servers
25
+ - L3: lightweight data-backed validation on biomni_data
26
+ - L4: extended data-backed validation with fuller passes and numeric checks
27
+ """
28
+
29
+ LEVEL_CONFIDENCE = {
30
+ "L1": 0.30,
31
+ "L2": 0.60,
32
+ "L3": 0.85,
33
+ "L4": 0.95,
34
+ }
35
+
36
+ def __init__(
37
+ self,
38
+ memory: SharedKnowledgeSpace,
39
+ biomni_root: str | Path,
40
+ mcp_config_path: str | Path,
41
+ project_root: str | Path,
42
+ ):
43
+ self.memory = memory
44
+ self.biomni_root = Path(biomni_root).resolve()
45
+ self.mcp_config_path = Path(mcp_config_path).resolve()
46
+ self.project_root = Path(project_root).resolve()
47
+ self._registration_state: dict[str, Any] | None = None
48
+ self._biomni_probe_cache: dict[str, Any] | None = None
49
+ self._biomni_agent_cache: dict[str, Any] | None = None
50
+
51
+ def _resolve_register_script(self) -> Path | None:
52
+ candidates = [
53
+ # when project_root=/.../BioScientist/agent_system
54
+ self.project_root / "toolbase" / "register_mcp_servers_to_biomni.py",
55
+ # when project_root=/.../BioScientist
56
+ self.project_root / "agent_system" / "toolbase" / "register_mcp_servers_to_biomni.py",
57
+ # fallback based on current file location
58
+ Path(__file__).resolve().parents[1] / "toolbase" / "register_mcp_servers_to_biomni.py",
59
+ ]
60
+ for c in candidates:
61
+ if c.exists():
62
+ return c
63
+ return None
64
+
65
+ def ensure_mcp_registered(self, dry_run: bool = False) -> dict[str, Any]:
66
+ script = self._resolve_register_script()
67
+ if script is None:
68
+ return {
69
+ "ok": False,
70
+ "status": "missing_script",
71
+ "error": (
72
+ "register script not found under either "
73
+ f"{self.project_root}/toolbase or {self.project_root}/agent_system/toolbase"
74
+ ),
75
+ }
76
+ cmd = [
77
+ sys.executable,
78
+ str(script),
79
+ "--biomni-root",
80
+ str(self.biomni_root),
81
+ "--config-out",
82
+ str(self.mcp_config_path),
83
+ ]
84
+ if dry_run:
85
+ cmd.append("--dry-run")
86
+ try:
87
+ completed = subprocess.run(cmd, capture_output=True, text=True, check=False)
88
+ state = {
89
+ "ok": completed.returncode == 0,
90
+ "status": "registered" if completed.returncode == 0 else "failed",
91
+ "return_code": completed.returncode,
92
+ "stdout_tail": "\n".join((completed.stdout or "").splitlines()[-20:]),
93
+ "stderr_tail": "\n".join((completed.stderr or "").splitlines()[-20:]),
94
+ "config_path": str(self.mcp_config_path),
95
+ }
96
+ self._registration_state = state
97
+ return state
98
+ except Exception as exc:
99
+ state = {"ok": False, "status": "failed", "error": str(exc), "config_path": str(self.mcp_config_path)}
100
+ self._registration_state = state
101
+ return state
102
+
103
+ def _coerce_hypothesis(self, hypothesis: Hypothesis | dict[str, Any]) -> dict[str, Any]:
104
+ return hypothesis.to_dict() if isinstance(hypothesis, Hypothesis) else hypothesis
105
+
106
+ def _prepare_biomni_import(self) -> dict[str, Any]:
107
+ """
108
+ Ensure `import biomni` works without requiring pip install -e.
109
+ """
110
+ info = {
111
+ "biomni_root": str(self.biomni_root),
112
+ "path_injected": False,
113
+ "package_dir_exists": (self.biomni_root / "biomni").exists(),
114
+ }
115
+ root_str = str(self.biomni_root)
116
+ if root_str not in sys.path:
117
+ sys.path.insert(0, root_str)
118
+ info["path_injected"] = True
119
+ return info
120
+
121
+ def _resolve_biomni_data_root(self) -> Path:
122
+ env = os.getenv("BIOCLAW_BIOMNI_DATA_ROOT", "").strip()
123
+ if env:
124
+ p = Path(env).expanduser().resolve()
125
+ if p.exists():
126
+ return p
127
+ candidates = [
128
+ self.project_root / "agent_system" / "toolbase" / "data" / "biomni_data",
129
+ self.project_root / "toolbase" / "data" / "biomni_data",
130
+ Path("/225040511/project/BioScientist/agent_system/toolbase/data/biomni_data"),
131
+ ]
132
+ for c in candidates:
133
+ if c.exists():
134
+ return c
135
+ return candidates[0]
136
+
137
+ def _resolve_biomni_llm_source(self) -> tuple[str, str]:
138
+ """
139
+ Resolve runtime LLM/source for Biomni execution.
140
+ Priority:
141
+ 1) BIOCLAW_BIOMNI_LLM / BIOCLAW_BIOMNI_SOURCE
142
+ 2) GEMINI_MODEL / BIOMNI_SOURCE
143
+ 3) safe Gemini defaults
144
+ """
145
+ llm = (
146
+ os.getenv("BIOCLAW_BIOMNI_LLM", "").strip()
147
+ or os.getenv("GEMINI_MODEL", "").strip()
148
+ or "gemini-2.5-flash-lite"
149
+ )
150
+ source = (
151
+ os.getenv("BIOCLAW_BIOMNI_SOURCE", "").strip()
152
+ or os.getenv("BIOMNI_SOURCE", "").strip()
153
+ or "Gemini"
154
+ )
155
+ return llm, source
156
+
157
+ def _quick_biomni_probe(self, hypothesis_text: str = "", force: bool = False) -> dict[str, Any]:
158
+ """
159
+ Minimal-cost Biomni runtime probe.
160
+ Reuses cached probe; otherwise builds/uses a cached Biomni agent context.
161
+ """
162
+ if self._biomni_probe_cache is not None and not force:
163
+ cached = dict(self._biomni_probe_cache)
164
+ cached["cache_hit"] = True
165
+ return cached
166
+
167
+ t0 = datetime.now(timezone.utc)
168
+ ctx = self._get_biomni_agent_context(hypothesis_text, force_rebuild=force)
169
+ dt = datetime.now(timezone.utc) - t0
170
+ if not ctx.get("ok", False):
171
+ result = {
172
+ "ok": False,
173
+ "status": "biomni_probe_failed",
174
+ "error": str(ctx.get("error", "unknown")),
175
+ "mcp_registration_ok": bool(ctx.get("trace", {}).get("mcp_registration_ok", False)),
176
+ "duration_ms": int(dt.total_seconds() * 1000),
177
+ "cache_hit": False,
178
+ "mcp_config_used": str(ctx.get("trace", {}).get("mcp_config_used", self.mcp_config_path)),
179
+ **(ctx.get("trace", {}) if isinstance(ctx.get("trace", {}), dict) else {}),
180
+ }
181
+ self._biomni_probe_cache = result
182
+ return result
183
+
184
+ trace = ctx.get("trace", {}) if isinstance(ctx.get("trace", {}), dict) else {}
185
+ result = {
186
+ "ok": True,
187
+ "status": "ready",
188
+ "mcp_registration_ok": True,
189
+ "custom_tool_count": int(trace.get("custom_tool_count", 0)),
190
+ "related_tool_hits": 0,
191
+ "duration_ms": int(dt.total_seconds() * 1000),
192
+ "cache_hit": False,
193
+ "mcp_config_used": str(trace.get("mcp_config_used", self.mcp_config_path)),
194
+ "loaded_server_count": int(trace.get("loaded_server_count", -1)),
195
+ "minimal_config_selected_count": int(trace.get("minimal_config_selected_count", 0)),
196
+ "full_config_server_count": int(trace.get("full_config_server_count", 0)),
197
+ "path_injected": bool(trace.get("path_injected", False)),
198
+ "package_dir_exists": bool(trace.get("package_dir_exists", False)),
199
+ "biomni_root": str(trace.get("biomni_root", self.biomni_root)),
200
+ }
201
+ self._biomni_probe_cache = result
202
+ return result
203
+
204
+ def _get_biomni_agent_context(self, hypothesis_text: str = "", force_rebuild: bool = False) -> dict[str, Any]:
205
+ """
206
+ Build/reuse a Biomni A1 agent with MCP loaded.
207
+ Returns a context dict:
208
+ {
209
+ ok: bool,
210
+ agent: A1 | None,
211
+ trace: dict[str, Any],
212
+ error: str
213
+ }
214
+ """
215
+ if self._biomni_agent_cache is not None and not force_rebuild:
216
+ return self._biomni_agent_cache
217
+
218
+ registration = self._registration_state or self.ensure_mcp_registered(dry_run=False)
219
+ if not registration.get("ok", False):
220
+ ctx = {
221
+ "ok": False,
222
+ "agent": None,
223
+ "trace": {"mcp_registration_ok": False},
224
+ "error": "mcp_registration_failed",
225
+ }
226
+ self._biomni_agent_cache = ctx
227
+ return ctx
228
+
229
+ deps_ok, missing_modules, deps_evidence = self._check_l2_dependencies()
230
+ if not deps_ok:
231
+ ctx = {
232
+ "ok": False,
233
+ "agent": None,
234
+ "trace": {
235
+ "mcp_registration_ok": True,
236
+ "missing_modules": missing_modules,
237
+ },
238
+ "error": deps_evidence,
239
+ }
240
+ self._biomni_agent_cache = ctx
241
+ return ctx
242
+
243
+ try:
244
+ import_info = self._prepare_biomni_import()
245
+ from biomni.agent import A1
246
+ llm_name, llm_source = self._resolve_biomni_llm_source()
247
+
248
+ min_cfg = self._build_minimal_mcp_config(hypothesis_text)
249
+ used_config_path = str(self.mcp_config_path)
250
+ loaded_server_count = -1
251
+ minimal_server_count = 0
252
+ full_server_count = 0
253
+ if min_cfg is not None:
254
+ minimal_server_count = int(min_cfg.get("selected_count", 0))
255
+ full_server_count = int(min_cfg.get("full_count", 0))
256
+ if minimal_server_count > 0:
257
+ used_config_path = str(min_cfg["path"])
258
+
259
+ agent = A1(llm=llm_name, source=llm_source)
260
+ agent.add_mcp(config_path=used_config_path)
261
+ tools = agent.list_custom_tools() or []
262
+ if min_cfg is not None and minimal_server_count > 0:
263
+ loaded_server_count = minimal_server_count
264
+ elif min_cfg is not None:
265
+ loaded_server_count = full_server_count
266
+ if not tools and used_config_path != str(self.mcp_config_path):
267
+ agent = A1(llm=llm_name, source=llm_source)
268
+ agent.add_mcp(config_path=str(self.mcp_config_path))
269
+ tools = agent.list_custom_tools() or []
270
+ used_config_path = str(self.mcp_config_path)
271
+ loaded_server_count = full_server_count if full_server_count > 0 else -1
272
+
273
+ ctx = {
274
+ "ok": True,
275
+ "agent": agent,
276
+ "trace": {
277
+ "custom_tool_count": len(tools),
278
+ "mcp_config_used": used_config_path,
279
+ "loaded_server_count": loaded_server_count,
280
+ "minimal_config_selected_count": minimal_server_count,
281
+ "full_config_server_count": full_server_count,
282
+ "biomni_llm": llm_name,
283
+ "biomni_source": llm_source,
284
+ **import_info,
285
+ },
286
+ "error": "",
287
+ }
288
+ self._biomni_agent_cache = ctx
289
+ return ctx
290
+ except Exception as exc:
291
+ ctx = {
292
+ "ok": False,
293
+ "agent": None,
294
+ "trace": {"mcp_registration_ok": True},
295
+ "error": str(exc),
296
+ }
297
+ self._biomni_agent_cache = ctx
298
+ return ctx
299
+
300
+ @staticmethod
301
+ def _extract_json_like_block(text: str) -> dict[str, Any]:
302
+ s = (text or "").strip()
303
+ if not s:
304
+ return {}
305
+ try:
306
+ return json.loads(s) if s.startswith("{") else {}
307
+ except Exception:
308
+ pass
309
+ # naive fallback: find first {...}
310
+ start = s.find("{")
311
+ end = s.rfind("}")
312
+ if start >= 0 and end > start:
313
+ block = s[start : end + 1]
314
+ try:
315
+ return json.loads(block)
316
+ except Exception:
317
+ return {}
318
+ return {}
319
+
320
+ def _execute_hypothesis_with_biomni(self, h: dict[str, Any]) -> dict[str, Any]:
321
+ """
322
+ Execute hypothesis using Biomni directly (A1.go) with lightweight prompt.
323
+ Returns execution summary and artifact path.
324
+ """
325
+ text = f"{h.get('title', '')} {h.get('hypothesis', '')}".strip()
326
+ ctx = self._get_biomni_agent_context(text)
327
+ if not ctx.get("ok", False):
328
+ return {
329
+ "ok": False,
330
+ "error": f"biomni_agent_unavailable: {ctx.get('error', 'unknown')}",
331
+ "trace": ctx.get("trace", {}),
332
+ }
333
+
334
+ agent = ctx.get("agent")
335
+ data_sources = [str(x) for x in h.get("data_sources", [])][:5]
336
+ ops = [x for x in h.get("data_operations", []) if isinstance(x, dict)]
337
+ prompt = (
338
+ "You are validating a bioinformatics hypothesis in lightweight mode.\n"
339
+ "Use available MCP tools where useful and provide concise execution output.\n"
340
+ "Return JSON with keys: status, summary, key_metrics, recommendation.\n\n"
341
+ f"Domain: {h.get('domain', '')}\n"
342
+ f"Hypothesis title: {h.get('title', '')}\n"
343
+ f"Hypothesis: {h.get('hypothesis', '')}\n"
344
+ f"Expected improvement: {h.get('expected_improvement', '')}\n"
345
+ f"Theoretical basis: {h.get('theoretical_basis', '')}\n"
346
+ f"Data sources: {json.dumps(data_sources, ensure_ascii=True)}\n"
347
+ f"Requested operations: {json.dumps(ops, ensure_ascii=True)}\n"
348
+ "Please execute a minimal-cost validation and return machine-readable JSON."
349
+ )
350
+
351
+ try:
352
+ logs, final_message = agent.go(prompt)
353
+ parsed = self._extract_json_like_block(str(final_message))
354
+ status_raw = str(parsed.get("status", "")).lower()
355
+ mapped_status = "success" if status_raw in {"success", "ok", "passed"} else ("failed" if status_raw in {"failed", "error"} else "inconclusive")
356
+ summary = str(parsed.get("summary", "")).strip() or str(final_message)[:1000]
357
+ key_metrics = parsed.get("key_metrics", {}) if isinstance(parsed.get("key_metrics", {}), dict) else {}
358
+ recommendation = str(parsed.get("recommendation", "")).strip()
359
+
360
+ out_dir = self.project_root / "agent_system" / "results" / "biomni_exec"
361
+ out_dir.mkdir(parents=True, exist_ok=True)
362
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
363
+ out_path = out_dir / f"biomni_exec_{h.get('hypothesis_id', 'unknown')}_{ts}.json"
364
+ out_payload = {
365
+ "hypothesis_id": h.get("hypothesis_id", ""),
366
+ "domain": h.get("domain", ""),
367
+ "prompt": prompt,
368
+ "parsed": parsed,
369
+ "status": mapped_status,
370
+ "summary": summary,
371
+ "key_metrics": key_metrics,
372
+ "recommendation": recommendation,
373
+ "final_message": str(final_message),
374
+ "log_tail": logs[-20:] if isinstance(logs, list) else [],
375
+ "trace": ctx.get("trace", {}),
376
+ "saved_at": datetime.now(timezone.utc).isoformat(),
377
+ }
378
+ out_path.write_text(json.dumps(out_payload, indent=2, ensure_ascii=True), encoding="utf-8")
379
+ return {
380
+ "ok": True,
381
+ "status": mapped_status,
382
+ "summary": summary,
383
+ "key_metrics": key_metrics,
384
+ "recommendation": recommendation,
385
+ "artifact_file": str(out_path),
386
+ "trace": ctx.get("trace", {}),
387
+ }
388
+ except Exception as exc:
389
+ return {
390
+ "ok": False,
391
+ "error": f"biomni_execution_failed: {exc}",
392
+ "trace": ctx.get("trace", {}),
393
+ }
394
+
395
+ def _build_minimal_mcp_config(self, hypothesis_text: str) -> dict[str, Any] | None:
396
+ yaml_spec = importlib.util.find_spec("yaml")
397
+ if yaml_spec is None:
398
+ return None
399
+ import yaml # type: ignore
400
+
401
+ if not self.mcp_config_path.exists():
402
+ return None
403
+
404
+ try:
405
+ raw = self.mcp_config_path.read_text(encoding="utf-8")
406
+ doc = yaml.safe_load(raw) or {}
407
+ except Exception:
408
+ return None
409
+ servers = doc.get("mcp_servers", {})
410
+ if not isinstance(servers, dict) or not servers:
411
+ return None
412
+
413
+ full_count = len(servers)
414
+ max_servers = int(os.getenv("BIOCLAW_MIN_MCP_SERVERS", "40"))
415
+ max_servers = max(5, min(200, max_servers))
416
+
417
+ q = (hypothesis_text or "").lower()
418
+ tokens = [t for t in q.replace("-", " ").replace("_", " ").split() if len(t) >= 3][:30]
419
+
420
+ scored: list[tuple[int, str, dict[str, Any]]] = []
421
+ for name, cfg in servers.items():
422
+ if not isinstance(cfg, dict):
423
+ continue
424
+ cmd = " ".join([str(x).lower() for x in cfg.get("command", [])]) if isinstance(cfg.get("command"), list) else str(cfg.get("command", "")).lower()
425
+ desc = str(cfg.get("description", "")).lower()
426
+ hay = f"{name.lower()} {desc} {cmd}"
427
+ s = 0
428
+ for tok in tokens:
429
+ if tok in hay:
430
+ s += 2
431
+ if "single" in hay or "cell" in hay or "rna" in hay:
432
+ s += 1
433
+ scored.append((s, name, cfg))
434
+
435
+ scored.sort(key=lambda x: x[0], reverse=True)
436
+ chosen = [x for x in scored if x[0] > 0][:max_servers]
437
+ if len(chosen) < min(10, max_servers):
438
+ # Backfill with top entries to keep probe meaningful.
439
+ fallback_pool = [x for x in scored if x[1] not in {c[1] for c in chosen}]
440
+ need = min(10, max_servers) - len(chosen)
441
+ chosen.extend(fallback_pool[:need])
442
+ if not chosen:
443
+ chosen = scored[: min(10, max_servers)]
444
+ if not chosen:
445
+ return None
446
+
447
+ selected_servers = {name: cfg for _, name, cfg in chosen}
448
+ out_dir = self.project_root / "agent_system" / "results" / "e1_runtime" / "min_configs"
449
+ out_dir.mkdir(parents=True, exist_ok=True)
450
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
451
+ out_path = out_dir / f"mcp_min_{ts}.yaml"
452
+ out_path.write_text(
453
+ yaml.safe_dump({"mcp_servers": selected_servers}, sort_keys=False, allow_unicode=False),
454
+ encoding="utf-8",
455
+ )
456
+ return {
457
+ "path": out_path,
458
+ "selected_count": len(selected_servers),
459
+ "full_count": full_count,
460
+ }
461
+
462
+ def _persist_runtime_artifact(self, report_dict: dict[str, Any], hypothesis: dict[str, Any]) -> str:
463
+ runtime_dir = self.project_root / "agent_system" / "results" / "e1_runtime"
464
+ runtime_dir.mkdir(parents=True, exist_ok=True)
465
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
466
+ out_path = runtime_dir / f"{report_dict.get('level', 'Lx').lower()}_{report_dict.get('validation_id', 'unknown')}_{ts}.json"
467
+ payload = {
468
+ "validation": report_dict,
469
+ "hypothesis": {
470
+ "hypothesis_id": hypothesis.get("hypothesis_id", ""),
471
+ "domain": hypothesis.get("domain", ""),
472
+ "title": hypothesis.get("title", ""),
473
+ },
474
+ "saved_at": datetime.now(timezone.utc).isoformat(),
475
+ }
476
+ out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=True), encoding="utf-8")
477
+
478
+ summary_path = runtime_dir / "summary.json"
479
+ if summary_path.exists():
480
+ try:
481
+ summary = json.loads(summary_path.read_text(encoding="utf-8"))
482
+ except Exception:
483
+ summary = {}
484
+ else:
485
+ summary = {}
486
+ by_level = summary.setdefault("by_level", {})
487
+ lvl = report_dict.get("level", "UNKNOWN")
488
+ s = by_level.setdefault(lvl, {"count": 0, "success": 0, "inconclusive": 0, "failed": 0, "avg_score": 0.0})
489
+ old_count = int(s.get("count", 0))
490
+ old_avg = float(s.get("avg_score", 0.0))
491
+ new_score = float(report_dict.get("score", 0.0))
492
+ new_count = old_count + 1
493
+ s["count"] = new_count
494
+ s["avg_score"] = (old_avg * old_count + new_score) / max(1, new_count)
495
+ st = str(report_dict.get("status", "inconclusive"))
496
+ s[st] = int(s.get(st, 0)) + 1
497
+ summary["total_reports"] = int(summary.get("total_reports", 0)) + 1
498
+ summary["last_report_file"] = str(out_path)
499
+ summary["last_updated"] = datetime.now(timezone.utc).isoformat()
500
+ summary_path.write_text(json.dumps(summary, indent=2, ensure_ascii=True), encoding="utf-8")
501
+ return str(out_path)
502
+
503
+ def _index_data_files(self, data_root: Path, limit: int = 1000) -> list[Path]:
504
+ if not data_root.exists():
505
+ return []
506
+ files: list[Path] = []
507
+ for p in sorted(data_root.rglob("*")):
508
+ if p.is_file():
509
+ files.append(p)
510
+ if len(files) >= limit:
511
+ break
512
+ return files
513
+
514
+ def _download_online_example_data(self, domain: str, max_files: int = 2) -> list[Path]:
515
+ """
516
+ Best-effort fallback downloader when no usable local dataset is available.
517
+ Downloads small public tabular files so L3/L4 can still produce bound validation reports.
518
+ """
519
+ out_dir = self.project_root / "agent_system" / "results" / "downloaded_example_data"
520
+ out_dir.mkdir(parents=True, exist_ok=True)
521
+
522
+ domain_l = str(domain).lower()
523
+ single_cell_candidates = [
524
+ (
525
+ "pbmc3k_marker_genes.csv",
526
+ "https://raw.githubusercontent.com/scverse/scanpy-tutorials/main/pbmc3k/marker_genes.csv",
527
+ ),
528
+ (
529
+ "pbmc3k_obs_metadata.csv",
530
+ "https://raw.githubusercontent.com/scverse/scanpy-tutorials/main/pbmc3k/obs_metadata.csv",
531
+ ),
532
+ ]
533
+ generic_candidates = [
534
+ (
535
+ "iris.csv",
536
+ "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/iris.csv",
537
+ ),
538
+ (
539
+ "tips.csv",
540
+ "https://raw.githubusercontent.com/mwaskom/seaborn-data/master/tips.csv",
541
+ ),
542
+ ]
543
+ candidates = single_cell_candidates + generic_candidates if ("single" in domain_l or "cell" in domain_l) else generic_candidates + single_cell_candidates
544
+
545
+ downloaded: list[Path] = []
546
+ for filename, url in candidates:
547
+ target = out_dir / filename
548
+ if target.exists() and target.stat().st_size > 0:
549
+ downloaded.append(target)
550
+ if len(downloaded) >= max_files:
551
+ break
552
+ continue
553
+ try:
554
+ urllib.request.urlretrieve(url, str(target))
555
+ if target.exists() and target.stat().st_size > 0:
556
+ downloaded.append(target)
557
+ if len(downloaded) >= max_files:
558
+ break
559
+ except Exception:
560
+ continue
561
+ return downloaded
562
+
563
+ def _safe_read_tabular_preview(self, file_path: Path, sample_rows: int = 500) -> dict[str, Any]:
564
+ suffix = file_path.suffix.lower()
565
+ result: dict[str, Any] = {
566
+ "file": str(file_path),
567
+ "suffix": suffix,
568
+ "status": "skipped",
569
+ "rows_scanned": 0,
570
+ "columns": [],
571
+ "missing_cells": 0,
572
+ "total_cells": 0,
573
+ "numeric_columns": [],
574
+ }
575
+
576
+ if suffix in {".csv", ".tsv"}:
577
+ delim = "," if suffix == ".csv" else "\t"
578
+ with file_path.open("r", encoding="utf-8", errors="ignore", newline="") as fh:
579
+ reader = csv.DictReader(fh, delimiter=delim)
580
+ cols = reader.fieldnames or []
581
+ result["columns"] = cols
582
+ rows = 0
583
+ missing = 0
584
+ total = 0
585
+ numeric_candidates: dict[str, int] = {c: 0 for c in cols}
586
+ for row in reader:
587
+ rows += 1
588
+ for c in cols:
589
+ v = (row.get(c) or "").strip()
590
+ total += 1
591
+ if v == "":
592
+ missing += 1
593
+ else:
594
+ try:
595
+ float(v)
596
+ numeric_candidates[c] += 1
597
+ except Exception:
598
+ pass
599
+ if rows >= sample_rows:
600
+ break
601
+ result["rows_scanned"] = rows
602
+ result["missing_cells"] = missing
603
+ result["total_cells"] = total
604
+ result["numeric_columns"] = [c for c, n in numeric_candidates.items() if n > 0]
605
+ result["status"] = "loaded"
606
+ return result
607
+
608
+ if suffix == ".parquet":
609
+ pd_spec = importlib.util.find_spec("pandas")
610
+ if pd_spec is None:
611
+ result["status"] = "skipped"
612
+ result["error"] = "pandas_not_installed_for_parquet"
613
+ return result
614
+ import pandas as pd # type: ignore
615
+
616
+ df = pd.read_parquet(file_path)
617
+ if len(df) > sample_rows:
618
+ df = df.head(sample_rows)
619
+ result["rows_scanned"] = int(len(df))
620
+ cols = [str(c) for c in df.columns.tolist()]
621
+ result["columns"] = cols
622
+ total = int(df.shape[0] * df.shape[1]) if df.shape[1] > 0 else 0
623
+ missing = int(df.isna().sum().sum()) if total > 0 else 0
624
+ numeric_cols = [str(c) for c in df.select_dtypes(include=["number"]).columns.tolist()]
625
+ result["missing_cells"] = missing
626
+ result["total_cells"] = total
627
+ result["numeric_columns"] = numeric_cols
628
+ result["status"] = "loaded"
629
+ return result
630
+
631
+ if suffix == ".json":
632
+ with file_path.open("r", encoding="utf-8", errors="ignore") as fh:
633
+ payload = json.load(fh)
634
+ if isinstance(payload, list):
635
+ rows = min(sample_rows, len(payload))
636
+ cols = sorted({k for item in payload[:rows] if isinstance(item, dict) for k in item.keys()})
637
+ result["rows_scanned"] = rows
638
+ result["columns"] = cols
639
+ result["status"] = "loaded"
640
+ elif isinstance(payload, dict):
641
+ result["rows_scanned"] = 1
642
+ result["columns"] = sorted(payload.keys())
643
+ result["status"] = "loaded"
644
+ else:
645
+ result["status"] = "loaded"
646
+ return result
647
+
648
+ result["status"] = "skipped"
649
+ result["error"] = f"unsupported_suffix:{suffix or 'none'}"
650
+ return result
651
+
652
+ def _safe_read_tabular_full(self, file_path: Path, max_rows: int = 200000) -> dict[str, Any]:
653
+ """
654
+ L4-oriented data pass.
655
+ Attempts a fuller scan (or up to max_rows for safety) and includes basic numeric summaries.
656
+ """
657
+ suffix = file_path.suffix.lower()
658
+ result: dict[str, Any] = {
659
+ "file": str(file_path),
660
+ "suffix": suffix,
661
+ "status": "skipped",
662
+ "rows_scanned": 0,
663
+ "columns": [],
664
+ "missing_cells": 0,
665
+ "total_cells": 0,
666
+ "numeric_columns": [],
667
+ "truncated": False,
668
+ "numeric_summary": {},
669
+ }
670
+
671
+ if suffix in {".csv", ".tsv"}:
672
+ delim = "," if suffix == ".csv" else "\t"
673
+ with file_path.open("r", encoding="utf-8", errors="ignore", newline="") as fh:
674
+ reader = csv.DictReader(fh, delimiter=delim)
675
+ cols = reader.fieldnames or []
676
+ result["columns"] = cols
677
+ rows = 0
678
+ missing = 0
679
+ total = 0
680
+ numeric_samples: dict[str, list[float]] = {c: [] for c in cols}
681
+ for row in reader:
682
+ rows += 1
683
+ for c in cols:
684
+ v = (row.get(c) or "").strip()
685
+ total += 1
686
+ if v == "":
687
+ missing += 1
688
+ else:
689
+ try:
690
+ fv = float(v)
691
+ if len(numeric_samples[c]) < 5000:
692
+ numeric_samples[c].append(fv)
693
+ except Exception:
694
+ pass
695
+ if rows >= max_rows:
696
+ result["truncated"] = True
697
+ break
698
+ result["rows_scanned"] = rows
699
+ result["missing_cells"] = missing
700
+ result["total_cells"] = total
701
+ numeric_cols = [c for c, arr in numeric_samples.items() if arr]
702
+ result["numeric_columns"] = numeric_cols
703
+ summary = {}
704
+ for c in numeric_cols[:20]:
705
+ arr = numeric_samples[c]
706
+ summary[c] = {
707
+ "count": len(arr),
708
+ "mean": float(statistics.fmean(arr)),
709
+ "min": float(min(arr)),
710
+ "max": float(max(arr)),
711
+ }
712
+ result["numeric_summary"] = summary
713
+ result["status"] = "loaded"
714
+ return result
715
+
716
+ if suffix == ".parquet":
717
+ pd_spec = importlib.util.find_spec("pandas")
718
+ if pd_spec is None:
719
+ result["status"] = "skipped"
720
+ result["error"] = "pandas_not_installed_for_parquet"
721
+ return result
722
+ import pandas as pd # type: ignore
723
+
724
+ df = pd.read_parquet(file_path)
725
+ if len(df) > max_rows:
726
+ df = df.head(max_rows)
727
+ result["truncated"] = True
728
+ result["rows_scanned"] = int(len(df))
729
+ cols = [str(c) for c in df.columns.tolist()]
730
+ result["columns"] = cols
731
+ total = int(df.shape[0] * df.shape[1]) if df.shape[1] > 0 else 0
732
+ missing = int(df.isna().sum().sum()) if total > 0 else 0
733
+ numeric_cols = [str(c) for c in df.select_dtypes(include=["number"]).columns.tolist()]
734
+ result["missing_cells"] = missing
735
+ result["total_cells"] = total
736
+ result["numeric_columns"] = numeric_cols
737
+ summary = {}
738
+ for c in numeric_cols[:20]:
739
+ s = df[c].dropna()
740
+ if len(s) == 0:
741
+ continue
742
+ summary[str(c)] = {
743
+ "count": int(len(s)),
744
+ "mean": float(s.mean()),
745
+ "min": float(s.min()),
746
+ "max": float(s.max()),
747
+ }
748
+ result["numeric_summary"] = summary
749
+ result["status"] = "loaded"
750
+ return result
751
+
752
+ if suffix == ".json":
753
+ with file_path.open("r", encoding="utf-8", errors="ignore") as fh:
754
+ payload = json.load(fh)
755
+ if isinstance(payload, list):
756
+ rows = len(payload)
757
+ if rows > max_rows:
758
+ rows = max_rows
759
+ result["truncated"] = True
760
+ cols = sorted({k for item in payload[:rows] if isinstance(item, dict) for k in item.keys()})
761
+ result["rows_scanned"] = rows
762
+ result["columns"] = cols
763
+ result["status"] = "loaded"
764
+ elif isinstance(payload, dict):
765
+ result["rows_scanned"] = 1
766
+ result["columns"] = sorted(payload.keys())
767
+ result["status"] = "loaded"
768
+ else:
769
+ result["status"] = "loaded"
770
+ return result
771
+
772
+ result["status"] = "skipped"
773
+ result["error"] = f"unsupported_suffix:{suffix or 'none'}"
774
+ return result
775
+
776
+ @staticmethod
777
+ def _domain_relevance_score(domain: str, selected_files: list[Path], per_file_results: list[dict[str, Any]]) -> tuple[float, list[str]]:
778
+ domain_l = str(domain).lower()
779
+ if not selected_files:
780
+ return 0.0, ["no_selected_files"]
781
+ hints = []
782
+ if "single_cell" in domain_l or "single" in domain_l:
783
+ hints = ["single", "cell", "rna", "census", "marker", "celltype", "expression"]
784
+ else:
785
+ hints = [x for x in domain_l.split("_") if x]
786
+
787
+ matched = 0
788
+ explanations: list[str] = []
789
+ for p in selected_files:
790
+ p_low = str(p).lower()
791
+ if any(h in p_low for h in hints):
792
+ matched += 1
793
+ explanations.append(f"file_match:{p.name}")
794
+ for r in per_file_results:
795
+ cols = " ".join([str(c).lower() for c in r.get("columns", [])])
796
+ if any(h in cols for h in hints):
797
+ matched += 1
798
+ explanations.append(f"column_match:{Path(str(r.get('file', 'unknown'))).name}")
799
+ denom = max(1, len(selected_files) + len(per_file_results))
800
+ return min(1.0, matched / denom), explanations[:10]
801
+
802
+ @staticmethod
803
+ def _build_interpretability_summary(
804
+ *,
805
+ level: str,
806
+ loaded_count: int,
807
+ selected_count: int,
808
+ rows_scanned: int,
809
+ missing_rate: float,
810
+ numeric_cols_total: int,
811
+ relevance_score: float,
812
+ ) -> dict[str, Any]:
813
+ quality = "high" if missing_rate < 0.01 else ("medium" if missing_rate < 0.1 else "low")
814
+ coverage = loaded_count / max(1, selected_count)
815
+ coverage_label = "high" if coverage >= 0.8 else ("medium" if coverage >= 0.5 else "low")
816
+ relevance_label = "high" if relevance_score >= 0.6 else ("medium" if relevance_score >= 0.3 else "low")
817
+ return {
818
+ "level": level,
819
+ "data_quality": quality,
820
+ "coverage": coverage_label,
821
+ "domain_relevance": relevance_label,
822
+ "rows_scanned": rows_scanned,
823
+ "numeric_signal_strength": "high" if numeric_cols_total >= 10 else ("medium" if numeric_cols_total >= 3 else "low"),
824
+ "recommendations": [
825
+ "Prefer domain-relevant files if domain_relevance is low.",
826
+ "Increase file coverage when coverage is medium/low.",
827
+ "Escalate to L4 only after L3 relevance is acceptable.",
828
+ ],
829
+ }
830
+
831
+ @staticmethod
832
+ def _check_l2_dependencies() -> tuple[bool, list[str], str]:
833
+ required_modules = ["nest_asyncio", "mcp", "yaml"]
834
+ missing = [m for m in required_modules if importlib.util.find_spec(m) is None]
835
+ if not missing:
836
+ return True, [], ""
837
+
838
+ module_to_pip = {
839
+ "nest_asyncio": "nest_asyncio",
840
+ "mcp": "mcp",
841
+ "yaml": "PyYAML",
842
+ }
843
+ pip_pkgs = [module_to_pip[m] for m in missing]
844
+ install_cmd = f"{sys.executable} -m pip install " + " ".join(pip_pkgs)
845
+ evidence = (
846
+ "L2 dependency check failed. Missing modules: "
847
+ f"{', '.join(missing)}. "
848
+ f"Install with: {install_cmd}. "
849
+ f"(module->pip: {', '.join(f'{m}->{module_to_pip[m]}' for m in missing)})"
850
+ )
851
+ return False, missing, evidence
852
+
853
+ def _validate_l1(self, h: dict[str, Any]) -> tuple[str, float, dict[str, Any], str, str]:
854
+ text = f"{h.get('title', '')} {h.get('hypothesis', '')}".lower()
855
+ violations: list[str] = []
856
+ if "敲除" in text and "增殖加快" in text:
857
+ violations.append("possible essential-gene-growth contradiction")
858
+ if "不存在的工具" in text:
859
+ violations.append("depends on non-existing tool")
860
+ if "必须" in text and "不需要数据" in text:
861
+ violations.append("self-contradictory requirement")
862
+
863
+ if violations:
864
+ return (
865
+ "failed",
866
+ 0.1,
867
+ {"rule_violations": violations, "violation_count": len(violations)},
868
+ "biological_or_tooling_inconsistency",
869
+ "L1 rules detected contradictions.",
870
+ )
871
+ return (
872
+ "success",
873
+ 0.6,
874
+ {"rule_violations": [], "violation_count": 0},
875
+ "",
876
+ "L1 rules found no obvious contradiction.",
877
+ )
878
+
879
+ def _validate_l2(self, h: dict[str, Any]) -> tuple[str, float, dict[str, Any], str, str]:
880
+ registration = self._registration_state or self.ensure_mcp_registered(dry_run=False)
881
+ if not registration.get("ok", False):
882
+ return (
883
+ "inconclusive",
884
+ 0.3,
885
+ {"mcp_registration_ok": False},
886
+ "mcp_registration_failed",
887
+ "L2 could not ensure MCP registration.",
888
+ )
889
+
890
+ deps_ok, missing_modules, deps_evidence = self._check_l2_dependencies()
891
+ if not deps_ok:
892
+ return (
893
+ "inconclusive",
894
+ 0.2,
895
+ {
896
+ "mcp_registration_ok": True,
897
+ "l2_dependency_check_ok": False,
898
+ "missing_modules": missing_modules,
899
+ },
900
+ "biomni_dependency_missing",
901
+ deps_evidence,
902
+ )
903
+
904
+ try:
905
+ import_info = self._prepare_biomni_import()
906
+ from biomni.agent import A1
907
+ llm_name, llm_source = self._resolve_biomni_llm_source()
908
+
909
+ agent = A1(llm=llm_name, source=llm_source)
910
+ agent.add_mcp(config_path=str(self.mcp_config_path))
911
+ tools = agent.list_custom_tools() or []
912
+ if not tools:
913
+ return (
914
+ "inconclusive",
915
+ 0.35,
916
+ {
917
+ "mcp_registration_ok": True,
918
+ "custom_tool_count": 0,
919
+ "biomni_llm": llm_name,
920
+ "biomni_source": llm_source,
921
+ **import_info,
922
+ },
923
+ "no_mcp_tools_discovered",
924
+ "Biomni loaded config but no tools were discovered.",
925
+ )
926
+
927
+ text = f"{h.get('title', '')} {h.get('hypothesis', '')}".lower()
928
+ related = [t for t in tools if any(tok in t.lower() for tok in text.split()[:20])]
929
+ score = 0.5 + min(0.4, len(related) * 0.05)
930
+ return (
931
+ "success",
932
+ score,
933
+ {
934
+ "mcp_registration_ok": True,
935
+ "custom_tool_count": len(tools),
936
+ "related_tool_hits": len(related),
937
+ "biomni_llm": llm_name,
938
+ "biomni_source": llm_source,
939
+ **import_info,
940
+ },
941
+ "",
942
+ "L2 passed using Biomni MCP tool readiness check.",
943
+ )
944
+ except Exception as exc:
945
+ import_info = self._prepare_biomni_import()
946
+ return (
947
+ "inconclusive",
948
+ 0.25,
949
+ {"mcp_registration_ok": True, **import_info},
950
+ "biomni_probe_failed",
951
+ f"Biomni probe failed: {exc}",
952
+ )
953
+
954
+ def _validate_l3(self, h: dict[str, Any]) -> tuple[str, float, dict[str, Any], str, str]:
955
+ probe = self._quick_biomni_probe(f"{h.get('title', '')} {h.get('hypothesis', '')}")
956
+ if not probe.get("ok", False):
957
+ return (
958
+ "inconclusive",
959
+ 0.25,
960
+ {
961
+ "mcp_registration_ok": bool(probe.get("mcp_registration_ok", False)),
962
+ "biomni_probe_status": probe.get("status", ""),
963
+ "biomni_probe_duration_ms": probe.get("duration_ms", 0),
964
+ "biomni_call_trace": {
965
+ "registration_attempted": True,
966
+ "biomni_runtime_called": False,
967
+ "cache_hit": bool(probe.get("cache_hit", False)),
968
+ },
969
+ },
970
+ str(probe.get("status", "biomni_probe_failed")),
971
+ f"L3 aborted: Biomni quick probe failed ({probe.get('error', 'unknown_error')}).",
972
+ )
973
+
974
+ data_root = self._resolve_biomni_data_root()
975
+ data_files = self._index_data_files(data_root, limit=1500)
976
+ if not data_files:
977
+ return (
978
+ "inconclusive",
979
+ 0.3,
980
+ {"data_root": str(data_root), "indexed_files": 0},
981
+ "l3_data_unavailable",
982
+ "L3 could not locate biomni_data files.",
983
+ )
984
+
985
+ requested = [Path(str(x)) for x in h.get("data_sources", []) if str(x).strip()]
986
+ if requested:
987
+ selected = [p for p in requested if p.exists()][:5]
988
+ else:
989
+ selected = data_files[:5]
990
+ downloaded_examples: list[Path] = []
991
+ if not selected:
992
+ downloaded_examples = self._download_online_example_data(h.get("domain", ""), max_files=2)
993
+ selected = downloaded_examples[:]
994
+
995
+ operations = [x for x in h.get("data_operations", []) if isinstance(x, dict)]
996
+ if not operations:
997
+ operations = [
998
+ {"action": "load_table", "target": "all_selected"},
999
+ {"action": "sample_rows", "rows": 500},
1000
+ {"action": "profile_missingness"},
1001
+ {"action": "numeric_summary"},
1002
+ ]
1003
+
1004
+ per_file_results: list[dict[str, Any]] = []
1005
+ loaded_count = 0
1006
+ for p in selected:
1007
+ try:
1008
+ r = self._safe_read_tabular_preview(p, sample_rows=500)
1009
+ per_file_results.append(r)
1010
+ if r.get("status") == "loaded":
1011
+ loaded_count += 1
1012
+ except Exception as exc:
1013
+ per_file_results.append(
1014
+ {
1015
+ "file": str(p),
1016
+ "status": "failed",
1017
+ "error": str(exc),
1018
+ }
1019
+ )
1020
+
1021
+ op_names = [str(op.get("action", "")) for op in operations]
1022
+ rows_scanned = sum(int(r.get("rows_scanned", 0) or 0) for r in per_file_results)
1023
+ total_cells = sum(int(r.get("total_cells", 0) or 0) for r in per_file_results)
1024
+ missing_cells = sum(int(r.get("missing_cells", 0) or 0) for r in per_file_results)
1025
+ missing_rate = (missing_cells / total_cells) if total_cells > 0 else 0.0
1026
+ numeric_cols_total = sum(len(r.get("numeric_columns", []) or []) for r in per_file_results)
1027
+ relevance_score, relevance_evidence = self._domain_relevance_score(h.get("domain", ""), selected, per_file_results)
1028
+
1029
+ results_dir = self.project_root / "agent_system" / "results" / "l3_reports"
1030
+ results_dir.mkdir(parents=True, exist_ok=True)
1031
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1032
+ artifact_path = results_dir / f"l3_{h.get('hypothesis_id', 'unknown')}_{ts}.json"
1033
+ artifact_payload = {
1034
+ "hypothesis_id": h.get("hypothesis_id", ""),
1035
+ "domain": h.get("domain", ""),
1036
+ "data_root": str(data_root),
1037
+ "selected_files": [str(p) for p in selected],
1038
+ "operations": operations,
1039
+ "per_file_results": per_file_results,
1040
+ "rows_scanned": rows_scanned,
1041
+ "missing_rate": missing_rate,
1042
+ }
1043
+ artifact_path.write_text(json.dumps(artifact_payload, indent=2, ensure_ascii=True), encoding="utf-8")
1044
+
1045
+ if loaded_count == 0:
1046
+ return (
1047
+ "inconclusive",
1048
+ 0.35,
1049
+ {
1050
+ "data_root": str(data_root),
1051
+ "indexed_files": len(data_files),
1052
+ "selected_files": [str(p) for p in selected],
1053
+ "loaded_files": 0,
1054
+ "operations_requested": op_names,
1055
+ "l3_report_file": str(artifact_path),
1056
+ },
1057
+ "l3_no_loadable_data",
1058
+ "L3 selected files but none were loadable in lightweight mode.",
1059
+ )
1060
+
1061
+ score_components = {
1062
+ "base": 0.45,
1063
+ "loaded_files_component": 0.08 * loaded_count,
1064
+ "operation_component": 0.03 * min(8, len(op_names)),
1065
+ "domain_relevance_component": 0.08 * relevance_score,
1066
+ }
1067
+ score = min(0.92, sum(score_components.values()))
1068
+ status = "success" if loaded_count >= 1 else "inconclusive"
1069
+ biomni_exec = self._execute_hypothesis_with_biomni(h)
1070
+ if biomni_exec.get("ok", False):
1071
+ b_status = biomni_exec.get("status", "inconclusive")
1072
+ score = min(1.0, score + (0.08 if b_status == "success" else 0.0))
1073
+ if b_status == "failed":
1074
+ status = "failed"
1075
+ elif status != "failed" and b_status == "inconclusive":
1076
+ status = "inconclusive"
1077
+ else:
1078
+ score = max(0.0, score - 0.08)
1079
+ if status == "success":
1080
+ status = "inconclusive"
1081
+ evidence = (
1082
+ "L3 executed lightweight full-data checks with sampled previews; "
1083
+ f"loaded {loaded_count}/{len(selected)} files, scanned_rows={rows_scanned}, "
1084
+ f"missing_rate={missing_rate:.4f}, domain_relevance={relevance_score:.2f}. Report: {artifact_path}"
1085
+ )
1086
+ return (
1087
+ status,
1088
+ score,
1089
+ {
1090
+ "data_root": str(data_root),
1091
+ "indexed_files": len(data_files),
1092
+ "selected_files": [str(p) for p in selected],
1093
+ "loaded_files": loaded_count,
1094
+ "rows_scanned": rows_scanned,
1095
+ "missing_rate": missing_rate,
1096
+ "numeric_columns_total": numeric_cols_total,
1097
+ "operations_requested": op_names,
1098
+ "domain_relevance_score": relevance_score,
1099
+ "domain_relevance_evidence": relevance_evidence,
1100
+ "score_breakdown": score_components,
1101
+ "interpretability_summary": self._build_interpretability_summary(
1102
+ level="L3",
1103
+ loaded_count=loaded_count,
1104
+ selected_count=len(selected),
1105
+ rows_scanned=rows_scanned,
1106
+ missing_rate=missing_rate,
1107
+ numeric_cols_total=numeric_cols_total,
1108
+ relevance_score=relevance_score,
1109
+ ),
1110
+ "biomni_call_trace": {
1111
+ "registration_attempted": bool(self._registration_state),
1112
+ "biomni_runtime_called": True,
1113
+ "cache_hit": bool(probe.get("cache_hit", False)),
1114
+ "biomni_probe_duration_ms": probe.get("duration_ms", 0),
1115
+ "custom_tool_count": probe.get("custom_tool_count", 0),
1116
+ "related_tool_hits": probe.get("related_tool_hits", 0),
1117
+ "mcp_config_used": probe.get("mcp_config_used", str(self.mcp_config_path)),
1118
+ "loaded_server_count": probe.get("loaded_server_count", -1),
1119
+ "minimal_config_selected_count": probe.get("minimal_config_selected_count", 0),
1120
+ "full_config_server_count": probe.get("full_config_server_count", 0),
1121
+ "note": "L3 first performs a minimal Biomni runtime probe, then runs lightweight data checks.",
1122
+ },
1123
+ "downloaded_example_files": [str(p) for p in downloaded_examples],
1124
+ "l3_report_file": str(artifact_path),
1125
+ "biomni_execution": biomni_exec,
1126
+ },
1127
+ "" if biomni_exec.get("ok", False) else "biomni_execution_failed",
1128
+ evidence,
1129
+ )
1130
+
1131
+ def _validate_l4(self, h: dict[str, Any]) -> tuple[str, float, dict[str, Any], str, str]:
1132
+ probe = self._quick_biomni_probe(f"{h.get('title', '')} {h.get('hypothesis', '')}")
1133
+ if not probe.get("ok", False):
1134
+ return (
1135
+ "inconclusive",
1136
+ 0.3,
1137
+ {
1138
+ "mcp_registration_ok": bool(probe.get("mcp_registration_ok", False)),
1139
+ "biomni_probe_status": probe.get("status", ""),
1140
+ "biomni_probe_duration_ms": probe.get("duration_ms", 0),
1141
+ "biomni_call_trace": {
1142
+ "registration_attempted": True,
1143
+ "biomni_runtime_called": False,
1144
+ "cache_hit": bool(probe.get("cache_hit", False)),
1145
+ },
1146
+ },
1147
+ str(probe.get("status", "biomni_probe_failed")),
1148
+ f"L4 aborted: Biomni quick probe failed ({probe.get('error', 'unknown_error')}).",
1149
+ )
1150
+
1151
+ data_root = self._resolve_biomni_data_root()
1152
+ data_files = self._index_data_files(data_root, limit=3000)
1153
+ if not data_files:
1154
+ return (
1155
+ "inconclusive",
1156
+ 0.3,
1157
+ {"data_root": str(data_root), "indexed_files": 0},
1158
+ "l4_data_unavailable",
1159
+ "L4 could not locate biomni_data files.",
1160
+ )
1161
+
1162
+ requested = [Path(str(x)) for x in h.get("data_sources", []) if str(x).strip()]
1163
+ if requested:
1164
+ selected = [p for p in requested if p.exists()][:8]
1165
+ else:
1166
+ selected = data_files[:5]
1167
+ downloaded_examples: list[Path] = []
1168
+ if not selected:
1169
+ downloaded_examples = self._download_online_example_data(h.get("domain", ""), max_files=3)
1170
+ selected = downloaded_examples[:]
1171
+
1172
+ operations = [x for x in h.get("data_operations", []) if isinstance(x, dict)]
1173
+ if not operations:
1174
+ operations = [
1175
+ {"action": "load_table", "target": "all_selected"},
1176
+ {"action": "sample_rows", "rows": 500},
1177
+ {"action": "profile_missingness"},
1178
+ {"action": "numeric_summary"},
1179
+ ]
1180
+
1181
+ max_rows = int(os.getenv("BIOCLAW_L4_MAX_ROWS", "200000"))
1182
+ max_rows = max(1000, min(1000000, max_rows))
1183
+
1184
+ per_file_results: list[dict[str, Any]] = []
1185
+ loaded_count = 0
1186
+ truncated_count = 0
1187
+ for p in selected:
1188
+ try:
1189
+ r = self._safe_read_tabular_full(p, max_rows=max_rows)
1190
+ per_file_results.append(r)
1191
+ if r.get("status") == "loaded":
1192
+ loaded_count += 1
1193
+ if r.get("truncated"):
1194
+ truncated_count += 1
1195
+ except Exception as exc:
1196
+ per_file_results.append(
1197
+ {
1198
+ "file": str(p),
1199
+ "status": "failed",
1200
+ "error": str(exc),
1201
+ }
1202
+ )
1203
+
1204
+ rows_scanned = sum(int(r.get("rows_scanned", 0) or 0) for r in per_file_results)
1205
+ total_cells = sum(int(r.get("total_cells", 0) or 0) for r in per_file_results)
1206
+ missing_cells = sum(int(r.get("missing_cells", 0) or 0) for r in per_file_results)
1207
+ missing_rate = (missing_cells / total_cells) if total_cells > 0 else 0.0
1208
+ numeric_cols_total = sum(len(r.get("numeric_columns", []) or []) for r in per_file_results)
1209
+ relevance_score, relevance_evidence = self._domain_relevance_score(h.get("domain", ""), selected, per_file_results)
1210
+ op_names = [str(op.get("action", "")) for op in operations]
1211
+
1212
+ results_dir = self.project_root / "agent_system" / "results" / "l4_reports"
1213
+ results_dir.mkdir(parents=True, exist_ok=True)
1214
+ ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
1215
+ artifact_path = results_dir / f"l4_{h.get('hypothesis_id', 'unknown')}_{ts}.json"
1216
+ artifact_payload = {
1217
+ "hypothesis_id": h.get("hypothesis_id", ""),
1218
+ "domain": h.get("domain", ""),
1219
+ "data_root": str(data_root),
1220
+ "selected_files": [str(p) for p in selected],
1221
+ "operations": operations,
1222
+ "max_rows_per_file": max_rows,
1223
+ "per_file_results": per_file_results,
1224
+ "rows_scanned": rows_scanned,
1225
+ "missing_rate": missing_rate,
1226
+ "numeric_columns_total": numeric_cols_total,
1227
+ "truncated_file_count": truncated_count,
1228
+ }
1229
+ artifact_path.write_text(json.dumps(artifact_payload, indent=2, ensure_ascii=True), encoding="utf-8")
1230
+
1231
+ if loaded_count == 0:
1232
+ return (
1233
+ "inconclusive",
1234
+ 0.35,
1235
+ {
1236
+ "data_root": str(data_root),
1237
+ "indexed_files": len(data_files),
1238
+ "selected_files": [str(p) for p in selected],
1239
+ "loaded_files": 0,
1240
+ "l4_report_file": str(artifact_path),
1241
+ },
1242
+ "l4_no_loadable_data",
1243
+ "L4 selected files but none were loadable.",
1244
+ )
1245
+
1246
+ completeness = 1.0 - min(1.0, missing_rate * 10.0)
1247
+ coverage = min(1.0, loaded_count / max(1, len(selected)))
1248
+ richness = min(1.0, numeric_cols_total / 20.0)
1249
+ trunc_penalty = 0.08 if truncated_count > 0 else 0.0
1250
+ score_components = {
1251
+ "base": 0.45,
1252
+ "coverage_component": 0.25 * coverage,
1253
+ "completeness_component": 0.2 * completeness,
1254
+ "richness_component": 0.15 * richness,
1255
+ "domain_relevance_component": 0.1 * relevance_score,
1256
+ "truncation_penalty": -trunc_penalty,
1257
+ }
1258
+ score = max(0.0, min(1.0, sum(score_components.values())))
1259
+ status = "success" if (loaded_count >= max(1, min(2, len(selected))) and completeness >= 0.5) else "inconclusive"
1260
+ biomni_exec = self._execute_hypothesis_with_biomni(h)
1261
+ if biomni_exec.get("ok", False):
1262
+ b_status = biomni_exec.get("status", "inconclusive")
1263
+ score = min(1.0, score + (0.1 if b_status == "success" else 0.0))
1264
+ if b_status == "failed":
1265
+ status = "failed"
1266
+ elif status != "failed" and b_status == "inconclusive":
1267
+ status = "inconclusive"
1268
+ else:
1269
+ score = max(0.0, score - 0.1)
1270
+ if status == "success":
1271
+ status = "inconclusive"
1272
+ evidence = (
1273
+ "L4 executed extended data checks; "
1274
+ f"loaded {loaded_count}/{len(selected)} files, rows_scanned={rows_scanned}, "
1275
+ f"missing_rate={missing_rate:.4f}, numeric_columns_total={numeric_cols_total}, domain_relevance={relevance_score:.2f}, "
1276
+ f"truncated_files={truncated_count}. Report: {artifact_path}"
1277
+ )
1278
+ return (
1279
+ status,
1280
+ score,
1281
+ {
1282
+ "data_root": str(data_root),
1283
+ "indexed_files": len(data_files),
1284
+ "selected_files": [str(p) for p in selected],
1285
+ "loaded_files": loaded_count,
1286
+ "rows_scanned": rows_scanned,
1287
+ "missing_rate": missing_rate,
1288
+ "numeric_columns_total": numeric_cols_total,
1289
+ "truncated_file_count": truncated_count,
1290
+ "operations_requested": op_names,
1291
+ "domain_relevance_score": relevance_score,
1292
+ "domain_relevance_evidence": relevance_evidence,
1293
+ "score_breakdown": score_components,
1294
+ "interpretability_summary": self._build_interpretability_summary(
1295
+ level="L4",
1296
+ loaded_count=loaded_count,
1297
+ selected_count=len(selected),
1298
+ rows_scanned=rows_scanned,
1299
+ missing_rate=missing_rate,
1300
+ numeric_cols_total=numeric_cols_total,
1301
+ relevance_score=relevance_score,
1302
+ ),
1303
+ "biomni_call_trace": {
1304
+ "registration_attempted": bool(self._registration_state),
1305
+ "biomni_runtime_called": True,
1306
+ "cache_hit": bool(probe.get("cache_hit", False)),
1307
+ "biomni_probe_duration_ms": probe.get("duration_ms", 0),
1308
+ "custom_tool_count": probe.get("custom_tool_count", 0),
1309
+ "related_tool_hits": probe.get("related_tool_hits", 0),
1310
+ "mcp_config_used": probe.get("mcp_config_used", str(self.mcp_config_path)),
1311
+ "loaded_server_count": probe.get("loaded_server_count", -1),
1312
+ "minimal_config_selected_count": probe.get("minimal_config_selected_count", 0),
1313
+ "full_config_server_count": probe.get("full_config_server_count", 0),
1314
+ "note": "L4 first performs a minimal Biomni runtime probe, then runs extended data checks.",
1315
+ },
1316
+ "downloaded_example_files": [str(p) for p in downloaded_examples],
1317
+ "l4_report_file": str(artifact_path),
1318
+ "biomni_execution": biomni_exec,
1319
+ },
1320
+ "" if biomni_exec.get("ok", False) else "biomni_execution_failed",
1321
+ evidence,
1322
+ )
1323
+
1324
+ def validate_hypothesis(self, hypothesis: Hypothesis | dict[str, Any], level: str = "L1") -> dict[str, Any]:
1325
+ h = self._coerce_hypothesis(hypothesis)
1326
+ level = level.upper().strip()
1327
+ if level not in self.LEVEL_CONFIDENCE:
1328
+ level = "L1"
1329
+
1330
+ if level == "L1":
1331
+ status, score, metrics, failure_reason, evidence = self._validate_l1(h)
1332
+ elif level == "L2":
1333
+ status, score, metrics, failure_reason, evidence = self._validate_l2(h)
1334
+ elif level == "L3":
1335
+ status, score, metrics, failure_reason, evidence = self._validate_l3(h)
1336
+ elif level == "L4":
1337
+ status, score, metrics, failure_reason, evidence = self._validate_l4(h)
1338
+ else:
1339
+ status, score, metrics, failure_reason, evidence = (
1340
+ "inconclusive",
1341
+ 0.4,
1342
+ {"implemented": False},
1343
+ f"{level}_not_implemented_in_v1",
1344
+ f"{level} validation is scaffolded in first version.",
1345
+ )
1346
+
1347
+ report = ValidationReport.build(
1348
+ hypothesis_id=h.get("hypothesis_id", ""),
1349
+ domain=h.get("domain", ""),
1350
+ level=level,
1351
+ status=status,
1352
+ score=score,
1353
+ confidence=self.LEVEL_CONFIDENCE[level],
1354
+ key_metrics=metrics,
1355
+ failure_reason=failure_reason,
1356
+ evidence=evidence,
1357
+ )
1358
+ self.memory.save_validation_report(report)
1359
+ # Save an insight so T1 can leverage execution outcomes as experience.
1360
+ try:
1361
+ title = f"{h.get('domain', 'general')} {level} {status}"
1362
+ recommendation = report.evidence
1363
+ biomni_exec = report.key_metrics.get("biomni_execution", {})
1364
+ if isinstance(biomni_exec, dict) and biomni_exec.get("recommendation"):
1365
+ recommendation = str(biomni_exec.get("recommendation"))
1366
+ ins = Insight.build(
1367
+ title=title,
1368
+ hypothesis=str(h.get("hypothesis", ""))[:1000],
1369
+ recommendation=recommendation[:2000],
1370
+ confidence=float(report.confidence),
1371
+ evidence_run_ids=[report.validation_id],
1372
+ tags=[str(h.get("domain", "general")), f"validation-{level.lower()}", status],
1373
+ )
1374
+ self.memory.save_insight(ins)
1375
+ except Exception:
1376
+ pass
1377
+ self.memory.update_summary_statistics()
1378
+ report_dict = report.to_dict()
1379
+ report_dict["runtime_report_file"] = self._persist_runtime_artifact(report_dict, h)
1380
+ return report_dict
1381
+
1382
+ def validate_top_hypotheses(
1383
+ self,
1384
+ ranked_hypotheses: list[dict[str, Any]],
1385
+ top_m: int = 3,
1386
+ level: str = "L1",
1387
+ ) -> list[dict[str, Any]]:
1388
+ reports: list[dict[str, Any]] = []
1389
+ selected = ranked_hypotheses[: max(0, top_m)]
1390
+ total = len(selected)
1391
+ for idx, h in enumerate(selected, start=1):
1392
+ hid = h.get("hypothesis_id", f"idx_{idx}")
1393
+ print(f"[STAGE:VALIDATION] running {idx}/{total} level={level.upper()} hypothesis_id={hid}", flush=True)
1394
+ report = self.validate_hypothesis(h, level=level)
1395
+ print(
1396
+ f"[STAGE:VALIDATION] finished {idx}/{total} hypothesis_id={hid} status={report.get('status', 'unknown')} score={report.get('score', 0)}",
1397
+ flush=True,
1398
+ )
1399
+ reports.append(report)
1400
+ return reports
BioScientist/agent_system/engines/t1_consultant.py ADDED
@@ -0,0 +1,625 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from pathlib import Path
7
+ from typing import Any
8
+
9
+ from ..llm import (
10
+ ClaudeClientConfigError,
11
+ ClaudeCodeClient,
12
+ GeminiClientConfigError,
13
+ GeminiCodeClient,
14
+ KimiClientConfigError,
15
+ KimiCodeClient,
16
+ Qwen3ClientConfigError,
17
+ Qwen3LocalClient,
18
+ )
19
+ from ..schemas import Hypothesis, Insight, PipelineConfiguration
20
+ from ..shared_memory import SharedKnowledgeSpace
21
+
22
+
23
+ class BioinfoT1Consultant:
24
+ """Thought engine: reflect on reports and produce strategy/config guidance."""
25
+
26
+ def __init__(self, memory: SharedKnowledgeSpace):
27
+ self.memory = memory
28
+ self.backend = os.getenv("T1_MODEL_BACKEND", "claude").strip().lower()
29
+ self.last_generation_meta: dict[str, Any] = {
30
+ "requested_backend": self.backend,
31
+ "actual_backend": "none",
32
+ "used_fallback": False,
33
+ "error": "",
34
+ "history_size": 0,
35
+ "available_data_files": 0,
36
+ "candidates_returned": 0,
37
+ }
38
+ self.claude: ClaudeCodeClient | None = None
39
+ self.kimi: KimiCodeClient | None = None
40
+ self.qwen3: Qwen3LocalClient | None = None
41
+ self.gemini: GeminiCodeClient | None = None
42
+ if self.backend == "qwen3":
43
+ try:
44
+ self.qwen3 = Qwen3LocalClient()
45
+ except Qwen3ClientConfigError:
46
+ self.qwen3 = None
47
+ elif self.backend == "gemini":
48
+ try:
49
+ self.gemini = GeminiCodeClient()
50
+ except GeminiClientConfigError:
51
+ self.gemini = None
52
+ elif self.backend == "kimi":
53
+ try:
54
+ self.kimi = KimiCodeClient()
55
+ except KimiClientConfigError:
56
+ self.kimi = None
57
+ else:
58
+ try:
59
+ self.claude = ClaudeCodeClient()
60
+ except ClaudeClientConfigError:
61
+ self.claude = None
62
+
63
+ def review_reports(self) -> list[dict[str, Any]]:
64
+ reports = self.memory.list_reports()
65
+ if not reports:
66
+ return []
67
+
68
+ produced: list[dict[str, Any]] = []
69
+ for rep in reports[-20:]:
70
+ status = rep.get("status", "unknown")
71
+ task = rep.get("task", "generic")
72
+ if status == "failed":
73
+ insight = Insight.build(
74
+ title=f"{task}: failure pattern",
75
+ hypothesis="Parameter set or tool ordering may be unstable.",
76
+ recommendation="Use a conservative config and verify dependencies first.",
77
+ confidence=0.65,
78
+ evidence_run_ids=[rep.get("run_id", "unknown")],
79
+ tags=[task, "failure-mode"],
80
+ )
81
+ else:
82
+ insight = Insight.build(
83
+ title=f"{task}: successful path",
84
+ hypothesis="Current tool chain is reproducible for this task scope.",
85
+ recommendation="Promote this parameter set to default pipeline config.",
86
+ confidence=0.75,
87
+ evidence_run_ids=[rep.get("run_id", "unknown")],
88
+ tags=[task, "best-practice"],
89
+ )
90
+ self.memory.save_insight(insight)
91
+ produced.append(insight.to_dict())
92
+ return produced
93
+
94
+ def consult(self, user_goal: str, task_scope: str) -> dict[str, Any]:
95
+ insights = self.memory.list_insights()
96
+ matched = [
97
+ item
98
+ for item in insights
99
+ if task_scope.lower() in " ".join(item.get("tags", [])).lower()
100
+ or task_scope.lower() in item.get("title", "").lower()
101
+ ]
102
+ top = matched[-3:] if matched else insights[-3:]
103
+ recommendation = top[-1]["recommendation"] if top else "No prior insight found."
104
+
105
+ # 优先走可用 LLM 后端(Qwen3/Kimi/Claude);失败则回退规则推荐
106
+ llm_summary: dict[str, Any] | None = None
107
+ model_backend = "heuristic"
108
+ if self.qwen3 is not None or self.kimi is not None or self.gemini is not None or self.claude is not None:
109
+ try:
110
+ system_prompt = (
111
+ "You are a senior bioinformatics workflow consultant. "
112
+ "Return strict JSON with keys: primary_hypothesis, step_by_step_plan, recommendation, reasoning, risk_checks, next_actions. "
113
+ "step_by_step_plan, risk_checks and next_actions must be arrays of strings."
114
+ )
115
+ user_prompt = json.dumps(
116
+ {
117
+ "task_scope": task_scope,
118
+ "user_goal": user_goal,
119
+ "insights": top,
120
+ },
121
+ ensure_ascii=True,
122
+ )
123
+ if self.qwen3 is not None:
124
+ llm_summary = self.qwen3.complete_json(system_prompt=system_prompt, user_prompt=user_prompt)
125
+ model_backend = "qwen3"
126
+ elif self.kimi is not None:
127
+ text = self.kimi.complete(system_prompt=system_prompt, user_prompt=user_prompt)
128
+ llm_summary = json.loads(text)
129
+ model_backend = "kimi"
130
+ elif self.gemini is not None:
131
+ llm_summary = self.gemini.complete_json(system_prompt=system_prompt, user_prompt=user_prompt)
132
+ model_backend = "gemini"
133
+ elif self.claude is not None:
134
+ llm_summary = self.claude.complete_json(system_prompt=system_prompt, user_prompt=user_prompt)
135
+ model_backend = "claude"
136
+ recommendation = llm_summary.get("recommendation") or recommendation
137
+ except Exception:
138
+ llm_summary = None
139
+ model_backend = "heuristic"
140
+
141
+ return {
142
+ "task_scope": task_scope,
143
+ "user_goal": user_goal,
144
+ "insight_candidates": top,
145
+ "recommendation": recommendation,
146
+ "model_backend": model_backend if llm_summary is not None else "heuristic",
147
+ "llm_summary": llm_summary,
148
+ }
149
+
150
+ def emit_pipeline_config(
151
+ self,
152
+ task_scope: str,
153
+ strategy_name: str,
154
+ tools: list[str],
155
+ parameters: dict[str, Any],
156
+ rationale: str,
157
+ ) -> dict[str, Any]:
158
+ top_insights = self.memory.list_insights()[-3:]
159
+ cfg = PipelineConfiguration.build(
160
+ strategy_name=strategy_name,
161
+ task_scope=task_scope,
162
+ tools=tools,
163
+ parameters=parameters,
164
+ rationale=rationale,
165
+ source_insight_ids=[it.get("insight_id", "") for it in top_insights if it.get("insight_id")],
166
+ )
167
+ self.memory.save_pipeline_config(cfg)
168
+ return cfg.to_dict()
169
+
170
+ @staticmethod
171
+ def _tokenize(text: str) -> set[str]:
172
+ return {x for x in re.split(r"[^a-zA-Z0-9_]+", text.lower()) if len(x) > 2}
173
+
174
+ @staticmethod
175
+ def _supported_data_operations() -> set[str]:
176
+ return {"load_table", "sample_rows", "profile_missingness", "numeric_summary"}
177
+
178
+ @staticmethod
179
+ def _normalize_operations(ops: list[dict[str, Any]]) -> list[dict[str, Any]]:
180
+ supported = BioinfoT1Consultant._supported_data_operations()
181
+ normalized: list[dict[str, Any]] = []
182
+ for op in ops:
183
+ action = str(op.get("action", "")).strip()
184
+ if action not in supported:
185
+ continue
186
+ if action == "sample_rows":
187
+ rows = op.get("rows", 500)
188
+ try:
189
+ rows = int(rows)
190
+ except Exception:
191
+ rows = 500
192
+ rows = max(50, min(2000, rows))
193
+ normalized.append({"action": "sample_rows", "rows": rows})
194
+ elif action == "load_table":
195
+ normalized.append({"action": "load_table", "target": "all_selected"})
196
+ else:
197
+ normalized.append({"action": action})
198
+ if not normalized:
199
+ normalized = [
200
+ {"action": "load_table", "target": "all_selected"},
201
+ {"action": "sample_rows", "rows": 500},
202
+ {"action": "profile_missingness"},
203
+ {"action": "numeric_summary"},
204
+ ]
205
+ return normalized
206
+
207
+ def _domain_keyword_hints(self, domain: str, user_query: str) -> set[str]:
208
+ tokens = self._tokenize(f"{domain} {user_query}")
209
+ if "single_cell" in domain or "single" in tokens or "cell" in tokens:
210
+ tokens |= {
211
+ "single",
212
+ "cell",
213
+ "census",
214
+ "marker",
215
+ "celltype",
216
+ "rna",
217
+ "gene",
218
+ "normalization",
219
+ "expression",
220
+ }
221
+ return tokens
222
+
223
+ @staticmethod
224
+ def _resolve_biomni_data_root() -> Path:
225
+ env = os.getenv("BIOCLAW_BIOMNI_DATA_ROOT", "").strip()
226
+ if env:
227
+ p = Path(env).expanduser().resolve()
228
+ if p.exists():
229
+ return p
230
+ candidates = [
231
+ Path(__file__).resolve().parents[1] / "toolbase" / "data" / "biomni_data",
232
+ Path("/225040511/project/BioScientist/agent_system/toolbase/data/biomni_data"),
233
+ ]
234
+ for c in candidates:
235
+ if c.exists():
236
+ return c
237
+ return candidates[0]
238
+
239
+ def _list_available_data_files(self, limit: int = 400) -> list[str]:
240
+ root = self._resolve_biomni_data_root()
241
+ if not root.exists():
242
+ return []
243
+ files: list[str] = []
244
+ for p in sorted(root.rglob("*")):
245
+ if p.is_file():
246
+ files.append(str(p))
247
+ if len(files) >= limit:
248
+ break
249
+ return files
250
+
251
+ def _suggest_data_plan(
252
+ self,
253
+ *,
254
+ domain: str,
255
+ user_query: str,
256
+ hypothesis_text: str,
257
+ available_files: list[str],
258
+ ) -> tuple[list[str], list[dict[str, Any]]]:
259
+ text = f"{domain} {user_query} {hypothesis_text}".lower()
260
+ keywords = self._tokenize(text) | self._domain_keyword_hints(domain, user_query)
261
+
262
+ scored: list[tuple[int, str]] = []
263
+ for fp in available_files:
264
+ p = Path(fp)
265
+ name = p.name.lower()
266
+ parent = str(p.parent).lower()
267
+ score = 0
268
+ for kw in keywords:
269
+ if kw in name:
270
+ score += 1
271
+ if kw in parent:
272
+ score += 1
273
+ if any(k in name for k in ("single", "cell", "census", "marker", "gtex", "gene", "rna", "expression")):
274
+ score += 2
275
+ if "benchmark" in parent:
276
+ score -= 1
277
+ if score > 0:
278
+ scored.append((score, fp))
279
+ scored.sort(key=lambda x: x[0], reverse=True)
280
+ selected = [fp for _, fp in scored[:5]]
281
+ if not selected:
282
+ selected = available_files[:3]
283
+
284
+ ops = [
285
+ {"action": "load_table", "target": "all_selected"},
286
+ {"action": "sample_rows", "rows": 500},
287
+ {"action": "profile_missingness"},
288
+ {"action": "numeric_summary"},
289
+ ]
290
+ return selected, ops
291
+
292
+ @staticmethod
293
+ def _inject_data_grounding_text(base_hypothesis: str, selected_sources: list[str], selected_ops: list[dict[str, Any]]) -> str:
294
+ clean = base_hypothesis.strip()
295
+ source_names = [Path(x).name for x in selected_sources[:2]]
296
+ op_names = [str(x.get("action", "")) for x in selected_ops]
297
+ if not source_names:
298
+ return clean
299
+ grounding = f" Data-backed plan: use {', '.join(source_names)} with ops {', '.join(op_names)}."
300
+ if "data-backed plan" in clean.lower():
301
+ return clean
302
+ return (clean + grounding).strip()
303
+
304
+ @staticmethod
305
+ def _hypothesis_prompt_template_en(
306
+ *,
307
+ domain: str,
308
+ user_query: str,
309
+ tools_list_text: str,
310
+ success_patterns_text: str,
311
+ failure_patterns_text: str,
312
+ ) -> str:
313
+ return (
314
+ f"# Role\n"
315
+ f"You are an AI scientist specializing in the {domain} domain. "
316
+ f"Your task is to propose concrete, testable, and quantifiable analytical hypotheses.\n\n"
317
+ f"# Available Tooling Ecosystem (Injected from T0/T1)\n"
318
+ f"{tools_list_text}\n\n"
319
+ f"# Historical Success Patterns (Injected from memory)\n"
320
+ f"{success_patterns_text}\n\n"
321
+ f"# Historical Failure Patterns (Injected from memory)\n"
322
+ f"{failure_patterns_text}\n\n"
323
+ f"# Current User Request\n"
324
+ f"{user_query}\n\n"
325
+ f"# Hypothesis Generation Specification\n\n"
326
+ f"## Each hypothesis must include exactly these 5 parts:\n\n"
327
+ f"### 1. Core Hypothesis (one sentence)\n"
328
+ f"State the idea to be validated in one clear sentence.\n\n"
329
+ f"### 2. Concrete Method (2-3 sentences)\n"
330
+ f"Explain how to execute it, including:\n"
331
+ f"- Which tools/functions are used\n"
332
+ f"- Key parameters\n"
333
+ f"- How it differs from the baseline method\n\n"
334
+ f"### 3. Expected Effect (quantified)\n"
335
+ f"Must include explicit numerical targets, e.g.:\n"
336
+ f"- Clustering metrics: ARI / NMI improves by X%\n"
337
+ f"- Batch effects: kBET decreases by X%\n"
338
+ f"- Differential genes: +X significant genes\n"
339
+ f"- Runtime: reduced by X%\n\n"
340
+ f"### 4. Validation Plan\n"
341
+ f"Specify:\n"
342
+ f"- Which dataset(s) to use (must be from the available data list)\n"
343
+ f"- Which baseline to compare against\n"
344
+ f"- Success threshold (e.g., ARI > 0.6)\n\n"
345
+ f"### 5. Theoretical Rationale (biology/statistics)\n"
346
+ f"Explain why the hypothesis may hold, grounded in biological principles or statistical intuition.\n\n"
347
+ f"## Output format example\n\n"
348
+ f"[HYPOTHESIS-001]\n"
349
+ f"**Core Hypothesis**: ...\n"
350
+ f"**Concrete Method**: ...\n"
351
+ f"**Expected Effect**: ...\n"
352
+ f"**Validation Plan**: ...\n"
353
+ f"**Theoretical Rationale**: ...\n"
354
+ )
355
+
356
+ def generate_hypotheses(
357
+ self,
358
+ user_query: str,
359
+ domain: str,
360
+ n: int = 10,
361
+ top_k: int = 5,
362
+ ) -> list[dict[str, Any]]:
363
+ history = self.memory.get_hypothesis_context(domain=domain, top_k=top_k)
364
+ failed_patterns = []
365
+ success_examples = []
366
+ historical_reflection_pool: list[str] = []
367
+ error_avoidance_pool: list[str] = []
368
+ for item in history:
369
+ h = item.get("hypothesis", {})
370
+ if h.get("historical_reflection"):
371
+ historical_reflection_pool.extend([str(x) for x in h.get("historical_reflection", []) if str(x).strip()])
372
+ if h.get("error_avoidance"):
373
+ error_avoidance_pool.extend([str(x) for x in h.get("error_avoidance", []) if str(x).strip()])
374
+ for vr in item.get("validation_reports", []):
375
+ if vr.get("status") == "failed" and vr.get("failure_reason"):
376
+ failed_patterns.append(vr["failure_reason"])
377
+ error_avoidance_pool.append(f"Avoid failure mode: {vr['failure_reason']}")
378
+ if vr.get("status") == "success":
379
+ success_examples.append(
380
+ {
381
+ "title": h.get("title", ""),
382
+ "hypothesis": h.get("hypothesis", ""),
383
+ "score": vr.get("score", 0.0),
384
+ }
385
+ )
386
+ historical_reflection_pool.append(
387
+ f"Success case: {h.get('title', '')} (score={vr.get('score', 0.0)})"
388
+ )
389
+
390
+ for ins in self.memory.list_insights()[-40:]:
391
+ tags = [str(x).lower() for x in ins.get("tags", [])]
392
+ if domain.lower() not in " ".join(tags + [ins.get("title", "").lower()]):
393
+ continue
394
+ title = str(ins.get("title", "")).strip()
395
+ reco = str(ins.get("recommendation", "")).strip()
396
+ if title or reco:
397
+ historical_reflection_pool.append(f"Insight: {title} -> {reco}")
398
+ if "failed" in " ".join(tags) or "inconclusive" in " ".join(tags):
399
+ if reco:
400
+ error_avoidance_pool.append(f"Risk to avoid: {reco}")
401
+
402
+ generated: list[dict[str, Any]] = []
403
+ llm_data: dict[str, Any] | None = None
404
+ available_files = self._list_available_data_files(limit=500)
405
+ llm_backend = "none"
406
+ llm_error = ""
407
+
408
+ if self.qwen3 is not None or self.kimi is not None or self.gemini is not None or self.claude is not None:
409
+ try:
410
+ tools_list_text = "No explicit tool list injected; use available MCP/Biomni tools relevant to the domain."
411
+ success_patterns_text = json.dumps(success_examples[-10:], ensure_ascii=True)
412
+ failure_patterns_text = json.dumps(failed_patterns[-10:], ensure_ascii=True)
413
+ generation_spec = self._hypothesis_prompt_template_en(
414
+ domain=domain,
415
+ user_query=user_query,
416
+ tools_list_text=tools_list_text,
417
+ success_patterns_text=success_patterns_text,
418
+ failure_patterns_text=failure_patterns_text,
419
+ )
420
+ system_prompt = (
421
+ "You are a bioinformatics hypothesis generator.\n"
422
+ "Follow the provided Hypothesis Generation Specification strictly.\n"
423
+ "Return strict JSON only with key `hypotheses`.\n"
424
+ "Each item must include fields:\n"
425
+ "- title\n"
426
+ "- hypothesis (include core hypothesis + concise method)\n"
427
+ "- expected_improvement (must be quantified)\n"
428
+ "- validation_plan\n"
429
+ "- theoretical_basis\n"
430
+ "- historical_reflection (array of 2-5 concise points grounded in prior cases)\n"
431
+ "- error_avoidance (array of 2-5 concrete mistakes to avoid)\n"
432
+ "- reasoning_chain (array of 3-7 steps explaining how prior evidence leads to this hypothesis)\n"
433
+ "- tags\n"
434
+ "- data_sources (must come from available_data_files)\n"
435
+ "- data_operations (only from load_table/sample_rows/profile_missingness/numeric_summary)\n"
436
+ "Do not repeat failed patterns. Prefer variants inspired by high-score prior ideas.\n"
437
+ "Every hypothesis must explicitly reflect on historical cases and produce an actionable reasoning chain."
438
+ )
439
+ user_payload = {
440
+ "generation_spec_en": generation_spec,
441
+ "domain": domain,
442
+ "user_query": user_query,
443
+ "generate_n": n,
444
+ "failed_patterns_to_avoid": failed_patterns[-10:],
445
+ "success_examples_to_learn_from": success_examples[-10:],
446
+ "historical_reflection_candidates": historical_reflection_pool[-20:],
447
+ "error_avoidance_candidates": error_avoidance_pool[-20:],
448
+ "available_data_files": available_files[:200],
449
+ }
450
+ user_prompt = json.dumps(user_payload, ensure_ascii=True)
451
+ if self.qwen3 is not None:
452
+ llm_backend = "qwen3"
453
+ llm_data = self.qwen3.complete_json(system_prompt=system_prompt, user_prompt=user_prompt)
454
+ elif self.kimi is not None:
455
+ llm_backend = "kimi"
456
+ text = self.kimi.complete(system_prompt=system_prompt, user_prompt=user_prompt)
457
+ llm_data = json.loads(text)
458
+ elif self.gemini is not None:
459
+ llm_backend = "gemini"
460
+ llm_data = self.gemini.complete_json(system_prompt=system_prompt, user_prompt=user_prompt)
461
+ elif self.claude is not None:
462
+ llm_backend = "claude"
463
+ llm_data = self.claude.complete_json(system_prompt=system_prompt, user_prompt=user_prompt)
464
+ except Exception as exc:
465
+ llm_data = None
466
+ llm_error = str(exc)
467
+
468
+ candidates = (llm_data or {}).get("hypotheses", []) if isinstance(llm_data, dict) else []
469
+ used_fallback = not bool(candidates)
470
+ if not candidates:
471
+ for i in range(max(1, n)):
472
+ suggested_sources, suggested_ops = self._suggest_data_plan(
473
+ domain=domain,
474
+ user_query=user_query,
475
+ hypothesis_text=user_query,
476
+ available_files=available_files,
477
+ )
478
+ candidates.append(
479
+ {
480
+ "title": f"{domain} hypothesis {i + 1}",
481
+ "hypothesis": self._inject_data_grounding_text(
482
+ f"Apply a normalization variant for: {user_query}.",
483
+ suggested_sources,
484
+ suggested_ops,
485
+ ),
486
+ "expected_improvement": "Improve normalization stability, reduce batch effects, and keep biological signal.",
487
+ "validation_plan": "Use selected data sources, compare against standard normalization baseline, and require metric improvement over baseline.",
488
+ "theoretical_basis": "Selected available datasets are used for immediate lightweight validation before expensive full runs.",
489
+ "historical_reflection": [
490
+ f"Referenced {min(len(success_examples), top_k)} successful historical cases in the same domain.",
491
+ "Observed prior failures and kept this hypothesis conservative in scope.",
492
+ ],
493
+ "error_avoidance": (
494
+ [f"Avoid: {x}" for x in failed_patterns[-3:]]
495
+ if failed_patterns
496
+ else ["Avoid unsupported tools and unverifiable claims."]
497
+ ),
498
+ "reasoning_chain": [
499
+ "Review historical validation outcomes in the same domain.",
500
+ "Extract stable successful patterns and recurring failure reasons.",
501
+ "Select currently available data files for immediate verification.",
502
+ "Propose a minimally risky, testable variant with quantifiable expectations.",
503
+ "Bind operations so E1 can execute immediately and report metrics.",
504
+ ],
505
+ "tags": [domain, "generated-v1"],
506
+ "data_sources": suggested_sources,
507
+ "data_operations": suggested_ops,
508
+ }
509
+ )
510
+ if not llm_error and llm_backend != "none":
511
+ llm_error = "model_returned_empty_or_non_json_hypotheses"
512
+ if llm_backend == "none":
513
+ llm_error = "no_llm_backend_available"
514
+
515
+ source_example_ids = [it.get("hypothesis", {}).get("hypothesis_id", "") for it in history]
516
+ known_paths = set(available_files)
517
+ for c in candidates[: max(1, n)]:
518
+ selected_sources = [str(x) for x in c.get("data_sources", []) if str(x).strip() and str(x) in known_paths]
519
+ selected_ops = [x for x in c.get("data_operations", []) if isinstance(x, dict)]
520
+ if not selected_sources or not selected_ops:
521
+ suggested_sources, suggested_ops = self._suggest_data_plan(
522
+ domain=domain,
523
+ user_query=user_query,
524
+ hypothesis_text=str(c.get("hypothesis", "")),
525
+ available_files=available_files,
526
+ )
527
+ if not selected_sources:
528
+ selected_sources = suggested_sources
529
+ if not selected_ops:
530
+ selected_ops = suggested_ops
531
+ selected_ops = self._normalize_operations(selected_ops)
532
+ grounded_hypothesis = self._inject_data_grounding_text(
533
+ str(c.get("hypothesis", "")),
534
+ selected_sources,
535
+ selected_ops,
536
+ )
537
+ hyp = Hypothesis.build(
538
+ domain=domain,
539
+ user_query=user_query,
540
+ title=str(c.get("title", "Untitled hypothesis")).strip(),
541
+ hypothesis=grounded_hypothesis,
542
+ expected_improvement=str(c.get("expected_improvement", "")).strip(),
543
+ theoretical_basis=(
544
+ f"{str(c.get('theoretical_basis', '')).strip()} "
545
+ f"Validation plan: {str(c.get('validation_plan', '')).strip()}"
546
+ ).strip(),
547
+ tags=[str(x) for x in c.get("tags", []) if str(x).strip()],
548
+ source_examples=[x for x in source_example_ids if x],
549
+ data_sources=selected_sources,
550
+ data_operations=selected_ops,
551
+ historical_reflection=[
552
+ str(x).strip()
553
+ for x in c.get("historical_reflection", [])
554
+ if str(x).strip()
555
+ ][:6],
556
+ error_avoidance=[
557
+ str(x).strip()
558
+ for x in c.get("error_avoidance", [])
559
+ if str(x).strip()
560
+ ][:6],
561
+ reasoning_chain=[
562
+ str(x).strip()
563
+ for x in c.get("reasoning_chain", [])
564
+ if str(x).strip()
565
+ ][:8],
566
+ )
567
+ self.memory.save_hypothesis(hyp)
568
+ generated.append(hyp.to_dict())
569
+
570
+ self.last_generation_meta = {
571
+ "requested_backend": self.backend,
572
+ "actual_backend": llm_backend,
573
+ "used_fallback": used_fallback,
574
+ "error": llm_error[:1000],
575
+ "history_size": len(history),
576
+ "available_data_files": len(available_files),
577
+ "candidates_returned": len(candidates),
578
+ "generated_count": len(generated),
579
+ }
580
+ return generated
581
+
582
+ def rank_hypotheses_by_success_proxy(
583
+ self,
584
+ hypotheses: list[dict[str, Any]],
585
+ domain: str,
586
+ ) -> list[dict[str, Any]]:
587
+ stats = self.memory.get_summary_statistics()
588
+ tag_summary = stats.get("tag_summary", {})
589
+ history = self.memory.get_hypothesis_context(domain=domain, top_k=20)
590
+
591
+ successful_texts = []
592
+ for item in history:
593
+ reports = item.get("validation_reports", [])
594
+ if any(r.get("status") == "success" for r in reports):
595
+ h = item.get("hypothesis", {})
596
+ successful_texts.append(f"{h.get('title', '')} {h.get('hypothesis', '')}")
597
+
598
+ successful_tokens = [self._tokenize(x) for x in successful_texts if x.strip()]
599
+
600
+ ranked = []
601
+ for h in hypotheses:
602
+ tags = h.get("tags", []) or []
603
+ if not tags:
604
+ tags = [domain]
605
+ tag_scores = []
606
+ for t in tags:
607
+ s = tag_summary.get(t, {})
608
+ tag_scores.append(float(s.get("success_rate", 0.5)))
609
+ tag_score = sum(tag_scores) / len(tag_scores) if tag_scores else 0.5
610
+
611
+ h_tokens = self._tokenize(f"{h.get('title', '')} {h.get('hypothesis', '')}")
612
+ sim_score = 0.0
613
+ if successful_tokens and h_tokens:
614
+ overlaps = []
615
+ for st in successful_tokens:
616
+ inter = len(h_tokens & st)
617
+ union = len(h_tokens | st) or 1
618
+ overlaps.append(inter / union)
619
+ sim_score = max(overlaps) if overlaps else 0.0
620
+
621
+ predicted_success = max(0.0, min(1.0, 0.7 * tag_score + 0.3 * sim_score))
622
+ ranked.append({**h, "predicted_success": predicted_success})
623
+
624
+ ranked.sort(key=lambda x: x.get("predicted_success", 0.0), reverse=True)
625
+ return ranked
BioScientist/agent_system/engines/test_gemini.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import google.generativeai as genai
2
+ import os
3
+
4
+ # 替换为你的 Gemini API Key
5
+ api_key = os.environ.get("GEMINI_API_KEY")
6
+ print("api_key:"+api_key)
7
+ os.environ["GEMINI_API_KEY"] = api_key
8
+ genai.configure(api_key=os.environ["GEMINI_API_KEY"])
9
+
10
+ def list_gemini_models():
11
+ print("Listing available Gemini models:")
12
+ print("-" * 50)
13
+
14
+ # 遍历所有模型
15
+ for model in genai.list_models():
16
+ # 过滤掉仅支持旧版或特定功能的模型(可选)
17
+ if 'generateContent' in model.supported_generation_methods:
18
+ print(f"Model Name: {model.name}")
19
+ print(f"Description: {model.description}")
20
+ print(f"Input Token Limit: {model.input_token_limit}")
21
+ print(f"Output Token Limit: {model.output_token_limit}")
22
+ print("-" * 50)
23
+
24
+ if __name__ == "__main__":
25
+ list_gemini_models()
BioScientist/agent_system/engines/v1_executor.py ADDED
@@ -0,0 +1,1348 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import importlib.util
4
+ from pathlib import Path
5
+ from typing import Any
6
+ import json
7
+ import os
8
+ import re
9
+ import shlex
10
+ import shutil
11
+ import subprocess
12
+ import traceback
13
+ import requests
14
+
15
+ from ..mcp_router import MCPToolRouter
16
+ from ..schemas import ExperimentReport, FailureMode
17
+ from ..shared_memory import SharedKnowledgeSpace
18
+
19
+
20
+ class BioinfoV1Executor:
21
+ """Execution engine: plan task -> call MCP servers -> publish structured report."""
22
+
23
+ def __init__(
24
+ self,
25
+ memory: SharedKnowledgeSpace,
26
+ default_results_root: str | Path,
27
+ project_root: str | Path,
28
+ execution_backend: str = "docker",
29
+ ):
30
+ self.memory = memory
31
+ self.default_results_root = Path(default_results_root)
32
+ self.default_results_root.mkdir(parents=True, exist_ok=True)
33
+ self.project_root = Path(project_root)
34
+ self.router = MCPToolRouter(project_root=project_root)
35
+ self.execution_backend = execution_backend
36
+
37
+ @staticmethod
38
+ def _append_log(log_file: Path, message: str) -> None:
39
+ with log_file.open("a", encoding="utf-8") as lf:
40
+ lf.write(message.rstrip() + "\n")
41
+
42
+ @staticmethod
43
+ def _jsonable(value: Any) -> Any:
44
+ if isinstance(value, Path):
45
+ return str(value)
46
+ if isinstance(value, dict):
47
+ return {str(k): BioinfoV1Executor._jsonable(v) for k, v in value.items()}
48
+ if isinstance(value, list):
49
+ return [BioinfoV1Executor._jsonable(v) for v in value]
50
+ if isinstance(value, tuple):
51
+ return [BioinfoV1Executor._jsonable(v) for v in value]
52
+ return value
53
+
54
+ @staticmethod
55
+ def _read_log_tail(log_file: Path, max_lines: int = 200) -> str:
56
+ try:
57
+ lines = log_file.read_text(encoding="utf-8", errors="replace").splitlines()
58
+ return "\n".join(lines[-max_lines:])
59
+ except Exception:
60
+ return ""
61
+
62
+ @staticmethod
63
+ def _pick_trimmed_pair(trim_dir: Path) -> tuple[Path | None, Path | None]:
64
+ r1_patterns = [
65
+ "*_val_1.fq.gz",
66
+ "*_val_1.fq",
67
+ "*_R1_val_1.fq.gz",
68
+ "*_1_val_1.fq.gz",
69
+ "*_fastp_R1.fastq",
70
+ "*_fastp_R1.fastq.gz",
71
+ ]
72
+ r2_patterns = [
73
+ "*_val_2.fq.gz",
74
+ "*_val_2.fq",
75
+ "*_R2_val_2.fq.gz",
76
+ "*_2_val_2.fq.gz",
77
+ "*_fastp_R2.fastq",
78
+ "*_fastp_R2.fastq.gz",
79
+ ]
80
+ r1_candidates: list[Path] = []
81
+ r2_candidates: list[Path] = []
82
+ for pattern in r1_patterns:
83
+ r1_candidates.extend(sorted(trim_dir.glob(pattern)))
84
+ for pattern in r2_patterns:
85
+ r2_candidates.extend(sorted(trim_dir.glob(pattern)))
86
+ return (r1_candidates[-1] if r1_candidates else None, r2_candidates[-1] if r2_candidates else None)
87
+
88
+ def _plan_task(
89
+ self,
90
+ task: str,
91
+ task_scope: str,
92
+ pipeline_config: dict[str, Any] | None,
93
+ ) -> list[dict[str, Any]]:
94
+ """
95
+ Plan execution in a Biomni-like style:
96
+ 1) understand scope
97
+ 2) choose candidate tools
98
+ 3) execute step-by-step with artifacts passed forward
99
+ """
100
+ cfg_tools = (pipeline_config or {}).get("tools") or []
101
+
102
+ if task_scope == "first_pipeline":
103
+ task_lower = task.lower()
104
+ wants_alignment = any(
105
+ kw in task_lower
106
+ for kw in ("align", "alignment", "map", "mapping", "variant", "snp", "bam")
107
+ )
108
+ benchmark_repro_mode = any(
109
+ kw in task_lower
110
+ for kw in ("code repository", "original open-source code", "reproduce paper", "reproducibility")
111
+ )
112
+ base_plan = [
113
+ {
114
+ "name": "qc_raw",
115
+ "description": "Quality control on raw reads",
116
+ "candidates": cfg_tools or ["fastqc", "fastp"],
117
+ },
118
+ {
119
+ "name": "trim",
120
+ "description": "Adapter/quality trimming",
121
+ "candidates": cfg_tools or ["trim_galore", "cutadapt", "trimmomatic", "fastp"],
122
+ },
123
+ {
124
+ "name": "align",
125
+ "description": "Read alignment to reference index",
126
+ "candidates": cfg_tools or ["bowtie2", "bwa", "hisat2", "star", "minimap2"],
127
+ },
128
+ {
129
+ "name": "qc_trimmed",
130
+ "description": "Quality control after trimming",
131
+ "candidates": cfg_tools or ["fastqc", "qualimap"],
132
+ },
133
+ {
134
+ "name": "aggregate",
135
+ "description": "Aggregate reports",
136
+ "candidates": cfg_tools or ["multiqc"],
137
+ },
138
+ ]
139
+ if not wants_alignment:
140
+ base_plan = [s for s in base_plan if s["name"] != "align"]
141
+
142
+ if benchmark_repro_mode:
143
+ base_plan.extend(
144
+ [
145
+ {
146
+ "name": "source_clone",
147
+ "description": "Clone original paper source code repository",
148
+ "local_executor": "source_clone",
149
+ "candidates": [],
150
+ },
151
+ {
152
+ "name": "source_execute",
153
+ "description": "Run reproducibility-oriented source code execution attempts",
154
+ "local_executor": "source_execute",
155
+ "candidates": [],
156
+ },
157
+ {
158
+ "name": "summarize_repro",
159
+ "description": "Summarize reproducibility with deviations and evidence",
160
+ "local_executor": "summarize_repro",
161
+ "candidates": [],
162
+ },
163
+ ]
164
+ )
165
+ return base_plan
166
+
167
+ # generic mode: one tool per configured entry
168
+ if cfg_tools:
169
+ return [
170
+ {
171
+ "name": f"step_{i+1}",
172
+ "description": f"Configured execution step for {tool}",
173
+ "candidates": [tool],
174
+ }
175
+ for i, tool in enumerate(cfg_tools)
176
+ ]
177
+
178
+ # fallback
179
+ return [
180
+ {
181
+ "name": "generic_step",
182
+ "description": f"Generic execution for task: {task}",
183
+ "candidates": ["fastqc"],
184
+ }
185
+ ]
186
+
187
+ def _route_or_generate(self, tool_name: str, log_file: Path):
188
+ if self.router.has_tool(tool_name):
189
+ return self.router.resolve(tool_name)
190
+
191
+ self._append_log(log_file, f"[planner] MCP tool missing: {tool_name}, invoking converter...")
192
+ self._try_generate_mcp_server(tool_name, log_file)
193
+ self.router.refresh()
194
+ if self.router.has_tool(tool_name):
195
+ return self.router.resolve(tool_name)
196
+ return None
197
+
198
+ def _try_generate_mcp_server(self, tool_name: str, log_file: Path) -> None:
199
+ converter_path = self.project_root / "src" / "bioinfomcp_converter.py"
200
+ if not converter_path.exists():
201
+ self._append_log(log_file, f"[converter] converter not found: {converter_path}")
202
+ return
203
+
204
+ try:
205
+ spec = importlib.util.spec_from_file_location("bioinfomcp_converter", str(converter_path))
206
+ if spec is None or spec.loader is None:
207
+ raise RuntimeError("Failed to load converter module spec.")
208
+ module = importlib.util.module_from_spec(spec)
209
+ spec.loader.exec_module(module)
210
+ converter_cls = getattr(module, "BioinfoMCP", None)
211
+ if converter_cls is None:
212
+ raise RuntimeError("BioinfoMCP class not found in converter.")
213
+
214
+ converter = converter_cls(model="openai")
215
+ ok, error_msg, code = converter.autogenerate_mcp_tool(
216
+ tool_name=tool_name,
217
+ manual="--help",
218
+ run_help_command=True,
219
+ )
220
+ if not ok or not code:
221
+ raise RuntimeError(f"Converter failed: {error_msg}")
222
+
223
+ mcp_dir = self.project_root / "mcp-servers" / f"mcp_{tool_name}" / "app"
224
+ mcp_dir.mkdir(parents=True, exist_ok=True)
225
+ server_file = mcp_dir / f"{tool_name}_server.py"
226
+ server_file.write_text(self._wrap_generated_mcp_code(code), encoding="utf-8")
227
+ self._append_log(log_file, f"[converter] generated MCP server: {server_file}")
228
+ except Exception as exc: # pragma: no cover - defensive path
229
+ self._append_log(log_file, f"[converter] generation failed for {tool_name}: {exc}")
230
+
231
+ @staticmethod
232
+ def _wrap_generated_mcp_code(code: str) -> str:
233
+ if "FastMCP" in code and "mcp = FastMCP()" in code:
234
+ if "if __name__ == '__main__':" in code or "if __name__ == \"__main__\":" in code:
235
+ return code
236
+ return f"{code}\n\nif __name__ == '__main__':\n mcp.run()\n"
237
+
238
+ return (
239
+ "from fastmcp import FastMCP\n"
240
+ "mcp = FastMCP()\n\n"
241
+ f"{code}\n\n"
242
+ "if __name__ == '__main__':\n"
243
+ " mcp.run()\n"
244
+ )
245
+
246
+ @staticmethod
247
+ def _load_tool_callable(server_script: Path, function_name: str):
248
+ mod_name = f"mcp_module_{server_script.stem}_{abs(hash(str(server_script)))}"
249
+ spec = importlib.util.spec_from_file_location(mod_name, str(server_script))
250
+ if spec is None or spec.loader is None:
251
+ raise RuntimeError(f"Cannot load module for {server_script}")
252
+ module = importlib.util.module_from_spec(spec)
253
+ spec.loader.exec_module(module)
254
+ func = getattr(module, function_name, None)
255
+ if func is None:
256
+ raise RuntimeError(f"Function '{function_name}' not found in {server_script}")
257
+ return func
258
+
259
+ def _invoke_mcp_tool(
260
+ self,
261
+ route,
262
+ kwargs: dict[str, Any],
263
+ log_file: Path,
264
+ ) -> dict[str, Any]:
265
+ self._append_log(
266
+ log_file,
267
+ f"[toolcall] {route.tool_name}.{route.function_name} script={route.server_script} kwargs={json.dumps({k: str(v) for k, v in kwargs.items()}, ensure_ascii=True)}",
268
+ )
269
+ if self.execution_backend == "docker":
270
+ return self._invoke_mcp_tool_docker(route, kwargs=kwargs, log_file=log_file)
271
+
272
+ try:
273
+ tool_fn = self._load_tool_callable(route.server_script, route.function_name)
274
+ result = tool_fn(**kwargs)
275
+ if not isinstance(result, dict):
276
+ result = {"raw_result": result}
277
+ ok = "error" not in result
278
+ self._append_log(log_file, f"[toolcall] status={'ok' if ok else 'error'}")
279
+ return {"ok": ok, "result": result}
280
+ except FileNotFoundError as exc:
281
+ tb = traceback.format_exc()
282
+ self._append_log(log_file, f"[toolcall] FileNotFoundError: {exc}\n{tb}")
283
+ return {"ok": False, "result": {"error": f"FileNotFoundError: {exc}", "traceback": tb}}
284
+ except Exception as exc:
285
+ tb = traceback.format_exc()
286
+ self._append_log(log_file, f"[toolcall] exception: {exc}\n{tb}")
287
+ return {"ok": False, "result": {"error": str(exc), "traceback": tb}}
288
+
289
+ def _invoke_mcp_tool_docker(self, route, kwargs: dict[str, Any], log_file: Path) -> dict[str, Any]:
290
+ if shutil.which("docker") is None:
291
+ return {
292
+ "ok": False,
293
+ "result": {"error": "docker_not_found", "hint": "Install Docker or switch execution_backend=python"},
294
+ }
295
+
296
+ run_dir = log_file.parent
297
+ payload_file = run_dir / f"_mcp_payload_{route.function_name}.json"
298
+ runner_file = run_dir / "_mcp_docker_runner.py"
299
+ payload = {
300
+ "server_script": str(route.server_script),
301
+ "function_name": route.function_name,
302
+ "kwargs": self._jsonable(kwargs),
303
+ }
304
+ payload_file.write_text(json.dumps(payload, ensure_ascii=True), encoding="utf-8")
305
+ runner_file.write_text(self._docker_runner_script(), encoding="utf-8")
306
+
307
+ mount_root = self.project_root.parent
308
+ docker_cmd = [
309
+ "docker",
310
+ "run",
311
+ "--rm",
312
+ "-v",
313
+ f"{mount_root}:{mount_root}",
314
+ "-w",
315
+ str(mount_root),
316
+ route.image_name,
317
+ "python",
318
+ str(runner_file),
319
+ str(payload_file),
320
+ ]
321
+ self._append_log(log_file, f"[toolcall-docker] cmd={' '.join(docker_cmd)}")
322
+
323
+ completed = subprocess.run(docker_cmd, capture_output=True, text=True)
324
+ stdout = completed.stdout or ""
325
+ stderr = completed.stderr or ""
326
+ if completed.returncode != 0:
327
+ self._append_log(log_file, f"[toolcall-docker] failed rc={completed.returncode}\n{stderr}")
328
+ return {
329
+ "ok": False,
330
+ "result": {
331
+ "error": f"docker_run_failed rc={completed.returncode}",
332
+ "stdout": stdout,
333
+ "stderr": stderr,
334
+ "image": route.image_name,
335
+ "hint": (
336
+ f"Ensure image '{route.image_name}' exists/builds, "
337
+ f"or run with execution_backend=python."
338
+ ),
339
+ },
340
+ }
341
+
342
+ try:
343
+ result = json.loads(stdout.strip() or "{}")
344
+ if not isinstance(result, dict):
345
+ result = {"raw_result": result}
346
+ except Exception:
347
+ result = {"raw_stdout": stdout, "raw_stderr": stderr}
348
+
349
+ ok = "error" not in result
350
+ return {"ok": ok, "result": result}
351
+
352
+ @staticmethod
353
+ def _docker_runner_script() -> str:
354
+ return """from __future__ import annotations
355
+ import importlib.util
356
+ import inspect
357
+ import json
358
+ import sys
359
+ from pathlib import Path
360
+ from typing import Any, get_args, get_origin, Union
361
+
362
+
363
+ def to_jsonable(v: Any):
364
+ if isinstance(v, Path):
365
+ return str(v)
366
+ if isinstance(v, dict):
367
+ return {str(k): to_jsonable(val) for k, val in v.items()}
368
+ if isinstance(v, list):
369
+ return [to_jsonable(x) for x in v]
370
+ if isinstance(v, tuple):
371
+ return [to_jsonable(x) for x in v]
372
+ return v
373
+
374
+
375
+ def is_path_type(tp: Any) -> bool:
376
+ return tp is Path
377
+
378
+
379
+ def convert_value(value: Any, ann: Any) -> Any:
380
+ if ann is inspect._empty:
381
+ return value
382
+ origin = get_origin(ann)
383
+ if origin in (list, tuple):
384
+ args = get_args(ann)
385
+ inner = args[0] if args else Any
386
+ if isinstance(value, list):
387
+ return [convert_value(v, inner) for v in value]
388
+ return value
389
+ if origin is Union:
390
+ for a in get_args(ann):
391
+ if a is type(None):
392
+ continue
393
+ try:
394
+ return convert_value(value, a)
395
+ except Exception:
396
+ pass
397
+ return value
398
+ if is_path_type(ann):
399
+ return Path(value) if value is not None else value
400
+ return value
401
+
402
+
403
+ def main():
404
+ payload_path = Path(sys.argv[1])
405
+ payload = json.loads(payload_path.read_text(encoding="utf-8"))
406
+ server_script = payload["server_script"]
407
+ function_name = payload["function_name"]
408
+ kwargs = payload.get("kwargs", {})
409
+
410
+ spec = importlib.util.spec_from_file_location("mcp_runtime_mod", server_script)
411
+ if spec is None or spec.loader is None:
412
+ print(json.dumps({"error": f"cannot_load_module:{server_script}"}))
413
+ sys.exit(0)
414
+ mod = importlib.util.module_from_spec(spec)
415
+ spec.loader.exec_module(mod)
416
+ fn = getattr(mod, function_name, None)
417
+ if fn is None:
418
+ print(json.dumps({"error": f"function_not_found:{function_name}"}))
419
+ sys.exit(0)
420
+
421
+ sig = inspect.signature(fn)
422
+ call_kwargs = {}
423
+ for k, v in kwargs.items():
424
+ if k in sig.parameters:
425
+ call_kwargs[k] = convert_value(v, sig.parameters[k].annotation)
426
+ else:
427
+ call_kwargs[k] = v
428
+
429
+ try:
430
+ result = fn(**call_kwargs)
431
+ print(json.dumps(to_jsonable(result), ensure_ascii=True))
432
+ except Exception as exc:
433
+ import traceback
434
+ print(json.dumps({"error": str(exc), "traceback": traceback.format_exc()}, ensure_ascii=True))
435
+
436
+
437
+ if __name__ == "__main__":
438
+ main()
439
+ """
440
+
441
+ def _build_step_kwargs(
442
+ self,
443
+ step_name: str,
444
+ route_function: str,
445
+ context: dict[str, Any],
446
+ ) -> dict[str, Any]:
447
+ run_dir: Path = context["run_dir"]
448
+ r1: Path = context["r1"]
449
+ r2: Path = context["r2"]
450
+ threads: int = context["threads"]
451
+ quality_cutoff: int = context["quality_cutoff"]
452
+ index_base = context["index_base"]
453
+ artifacts = context["artifacts"]
454
+
455
+ if step_name == "qc_raw":
456
+ outdir = run_dir / "01_fastqc_raw"
457
+ outdir.mkdir(parents=True, exist_ok=True)
458
+ return {"input_files": [r1, r2], "outdir": outdir, "threads": threads}
459
+
460
+ if step_name == "trim":
461
+ outdir = run_dir / "02_trim"
462
+ outdir.mkdir(parents=True, exist_ok=True)
463
+ if route_function == "fastp":
464
+ out1 = outdir / f"{r1.stem}_fastp_R1.fastq"
465
+ out2 = outdir / f"{r2.stem}_fastp_R2.fastq"
466
+ json_report = str(outdir / "fastp.json")
467
+ html_report = str(outdir / "fastp.html")
468
+ return {
469
+ "in1": r1,
470
+ "in2": r2,
471
+ "out1": out1,
472
+ "out2": out2,
473
+ "qualified_quality_phred": quality_cutoff,
474
+ "cut_mean_quality": quality_cutoff,
475
+ "cut_right": True,
476
+ "thread": threads,
477
+ "json": json_report,
478
+ "html": html_report,
479
+ "report_title": "fastp report (BioClawMCP)",
480
+ }
481
+
482
+ return {
483
+ "input_files": [r1, r2],
484
+ "paired": True,
485
+ "quality": quality_cutoff,
486
+ "output_dir": outdir,
487
+ "cores": threads,
488
+ }
489
+
490
+ if step_name == "align":
491
+ align_dir = run_dir / "03_align"
492
+ align_dir.mkdir(parents=True, exist_ok=True)
493
+ sam_out = align_dir / "alignment.sam"
494
+ artifacts["sam"] = sam_out
495
+ trimmed_r1 = artifacts.get("trimmed_r1", r1)
496
+ trimmed_r2 = artifacts.get("trimmed_r2", r2)
497
+ if route_function == "bowtie2_align":
498
+ return {
499
+ "index_base": str(index_base),
500
+ "mate1_files": str(trimmed_r1),
501
+ "mate2_files": str(trimmed_r2),
502
+ "sam_output": sam_out,
503
+ "threads": threads,
504
+ }
505
+ # generic aligner fall back shape
506
+ return {
507
+ "input_files": [trimmed_r1, trimmed_r2],
508
+ "reference_index_base": str(index_base),
509
+ "output_dir": align_dir,
510
+ "threads": threads,
511
+ }
512
+
513
+ if step_name == "qc_trimmed":
514
+ outdir = run_dir / "04_fastqc_trimmed"
515
+ outdir.mkdir(parents=True, exist_ok=True)
516
+ trimmed_r1 = artifacts.get("trimmed_r1", r1)
517
+ trimmed_r2 = artifacts.get("trimmed_r2", r2)
518
+ return {"input_files": [trimmed_r1, trimmed_r2], "outdir": outdir, "threads": threads}
519
+
520
+ if step_name == "aggregate":
521
+ outdir = run_dir / "05_multiqc"
522
+ outdir.mkdir(parents=True, exist_ok=True)
523
+ return {
524
+ "analysis_directory": run_dir,
525
+ "outdir": outdir,
526
+ "filename": "multiqc_report.html",
527
+ "force": True,
528
+ }
529
+
530
+ # generic fallback for configured custom step
531
+ return {
532
+ "analysis_directory": run_dir,
533
+ "outdir": run_dir / f"{step_name}_output",
534
+ }
535
+
536
+ @staticmethod
537
+ def _extract_code_repo(task: str, input_manifest: dict[str, Any]) -> str:
538
+ manifest_repo = str(input_manifest.get("code_repo") or "").strip()
539
+ if manifest_repo:
540
+ return manifest_repo
541
+ m = re.search(r"Code repo:\s*(https?://\S+)", task, re.I)
542
+ if m:
543
+ return m.group(1).rstrip(").,;")
544
+ return ""
545
+
546
+ def _run_local_command(self, cmd: list[str], log_file: Path, cwd: Path | None = None, timeout_s: int = 900) -> dict[str, Any]:
547
+ try:
548
+ self._append_log(log_file, f"[local-cmd] {' '.join(cmd)} cwd={cwd or self.project_root}")
549
+ completed = subprocess.run(
550
+ cmd,
551
+ cwd=str(cwd) if cwd else None,
552
+ capture_output=True,
553
+ text=True,
554
+ timeout=timeout_s,
555
+ )
556
+ return {
557
+ "command_executed": " ".join(cmd),
558
+ "return_code": completed.returncode,
559
+ "stdout": completed.stdout or "",
560
+ "stderr": completed.stderr or "",
561
+ }
562
+ except Exception as exc:
563
+ tb = traceback.format_exc()
564
+ return {
565
+ "command_executed": " ".join(cmd),
566
+ "return_code": -1,
567
+ "error": str(exc),
568
+ "traceback": tb,
569
+ }
570
+
571
+ @staticmethod
572
+ def _extract_readme_run_commands(source_dir: Path, max_commands: int = 6) -> list[list[str]]:
573
+ candidates: list[list[str]] = []
574
+ readme_files = [
575
+ source_dir / "README.md",
576
+ source_dir / "readme.md",
577
+ source_dir / "README.rst",
578
+ ]
579
+ text = ""
580
+ for fp in readme_files:
581
+ if fp.exists():
582
+ try:
583
+ text = fp.read_text(encoding="utf-8", errors="replace")
584
+ break
585
+ except Exception:
586
+ continue
587
+ if not text:
588
+ return candidates
589
+
590
+ # extract fenced code blocks first
591
+ blocks = re.findall(r"```(?:bash|sh|shell)?\n([\s\S]*?)```", text, re.I)
592
+ lines: list[str] = []
593
+ for b in blocks:
594
+ lines.extend(b.splitlines())
595
+ if not lines:
596
+ lines = text.splitlines()
597
+
598
+ allowed_prefix = (
599
+ "python ",
600
+ "python3 ",
601
+ "bash ",
602
+ "sh ",
603
+ "./",
604
+ "Rscript ",
605
+ "R -e ",
606
+ "R --vanilla ",
607
+ "R ",
608
+ "snakemake",
609
+ "nextflow run",
610
+ )
611
+ forbidden = ("sudo ", "rm -rf", "docker system", "shutdown", "reboot")
612
+ for raw in lines:
613
+ line = raw.strip()
614
+ if line.startswith("$ "):
615
+ line = line[2:].strip()
616
+ if not line or line.startswith("#"):
617
+ continue
618
+ if any(bad in line for bad in forbidden):
619
+ continue
620
+ if line.startswith(allowed_prefix):
621
+ try:
622
+ cmd = shlex.split(line)
623
+ except Exception:
624
+ continue
625
+ if cmd:
626
+ candidates.append(cmd)
627
+ if len(candidates) >= max_commands:
628
+ break
629
+ return candidates
630
+
631
+ @staticmethod
632
+ def _is_safe_run_command(cmd: list[str]) -> bool:
633
+ if not cmd:
634
+ return False
635
+ joined = " ".join(cmd).lower()
636
+ forbidden = [
637
+ "rm -rf",
638
+ "sudo ",
639
+ "shutdown",
640
+ "reboot",
641
+ ":(){:|:&};:",
642
+ "mkfs",
643
+ "dd if=",
644
+ "curl ",
645
+ "wget ",
646
+ "scp ",
647
+ "ssh ",
648
+ "docker system",
649
+ ]
650
+ if any(x in joined for x in forbidden):
651
+ return False
652
+ allowed_heads = {"python", "python3", "bash", "sh", "rscript", "r", "snakemake", "nextflow", "make"}
653
+ head = cmd[0].lower()
654
+ return head in allowed_heads or head.startswith("./")
655
+
656
+ def _plan_source_commands_with_gemini(
657
+ self,
658
+ *,
659
+ task: str,
660
+ source_dir: Path,
661
+ input_manifest: dict[str, Any],
662
+ log_file: Path,
663
+ max_commands: int = 5,
664
+ ) -> list[list[str]]:
665
+ api_key = os.getenv("GEMINI_API_KEY", "").strip()
666
+ print("api_key:"+api_key)
667
+ if not api_key:
668
+ self._append_log(log_file, "[gemini] planner skipped: GEMINI_API_KEY missing")
669
+ return []
670
+
671
+ readme_text = ""
672
+ for fp in [source_dir / "README.md", source_dir / "readme.md", source_dir / "README.rst"]:
673
+ if fp.exists():
674
+ readme_text = fp.read_text(encoding="utf-8", errors="replace")
675
+ break
676
+ if len(readme_text) > 12000:
677
+ readme_text = readme_text[:12000]
678
+ print("readme_text:"+readme_text)
679
+ endpoint = (
680
+ "https://generativelanguage.googleapis.com/v1beta/models/"
681
+ f"gemini-2.5-flash-lite:generateContent?key={api_key}"
682
+ )
683
+ planner_prompt = (
684
+ "You are a bioinformatics reproducibility execution planner.\n"
685
+ "Given repository README and task context, propose up to 5 LOCAL runnable commands.\n"
686
+ "Output JSON with key `commands`, where each command is either:\n"
687
+ '1) ["python","script.py","--arg"] or 2) "python script.py --arg"\n'
688
+ "Rules:\n"
689
+ "- Only local run commands: python/python3/bash/sh/Rscript/R/snakemake/nextflow/make\n"
690
+ "- No network download commands (curl/wget/git clone), no sudo, no destructive commands.\n"
691
+ "- Prefer commands that execute original method in the repository.\n"
692
+ "- Return JSON only.\n"
693
+ )
694
+ payload = {
695
+ "contents": [
696
+ {
697
+ "role": "user",
698
+ "parts": [
699
+ {
700
+ "text": planner_prompt
701
+ + "\n"
702
+ + json.dumps(
703
+ {
704
+ "task": task,
705
+ "repo_path": str(source_dir),
706
+ "data_type": input_manifest.get("data_type", ""),
707
+ "repro_steps": input_manifest.get("repro_steps", []),
708
+ "readme_excerpt": readme_text,
709
+ },
710
+ ensure_ascii=True,
711
+ )
712
+ }
713
+ ],
714
+ }
715
+ ],
716
+ "generationConfig": {"temperature": 0.1},
717
+ }
718
+ print("payload:"+json.dumps(payload, ensure_ascii=False))
719
+ try:
720
+ resp = requests.post(endpoint, json=payload, timeout=60)
721
+ if resp.status_code >= 400:
722
+ self._append_log(
723
+ log_file,
724
+ f"[gemini] planner failed status={resp.status_code} body={(resp.text or '')[:500]}",
725
+ )
726
+ return self._extract_readme_run_commands(source_dir, max_commands=max_commands)
727
+ data = resp.json()
728
+ text = ""
729
+ for part in (data.get("candidates", [{}])[0].get("content", {}).get("parts", []) or []):
730
+ if "text" in part:
731
+ text += part["text"]
732
+ obj = {}
733
+ try:
734
+ obj = json.loads(text) if text.strip() else {}
735
+ except Exception:
736
+ m = re.search(r"```json\s*([\s\S]*?)```", text, re.I)
737
+ if m:
738
+ obj = json.loads(m.group(1))
739
+ else:
740
+ m = re.search(r"(\{[\s\S]*\})", text)
741
+ if m:
742
+ obj = json.loads(m.group(1))
743
+ commands = obj.get("commands", [])
744
+ # Tolerate model output as a plain list
745
+ print("obj:"+json.dumps(obj, ensure_ascii=False))
746
+ if not commands and isinstance(obj, list):
747
+ commands = obj
748
+ # Tolerate inline text commands
749
+ if not commands and text.strip():
750
+ candidate_lines = [ln.strip() for ln in text.splitlines() if ln.strip()]
751
+ commands = [
752
+ ln.lstrip("- ").strip()
753
+ for ln in candidate_lines
754
+ if ln.lower().startswith(("python ", "python3 ", "bash ", "sh ", "rscript ", "r ", "snakemake", "nextflow", "make "))
755
+ ]
756
+ safe_cmds: list[list[str]] = []
757
+ for item in commands[:max_commands]:
758
+ if isinstance(item, str):
759
+ cmd = shlex.split(item)
760
+ elif isinstance(item, list):
761
+ cmd = [str(x) for x in item]
762
+ else:
763
+ continue
764
+ if self._is_safe_run_command(cmd):
765
+ safe_cmds.append(cmd)
766
+ if not safe_cmds:
767
+ self._append_log(log_file, f"[gemini] empty command plan; raw={(text or '')[:500]}")
768
+ return self._extract_readme_run_commands(source_dir, max_commands=max_commands)
769
+ self._append_log(log_file, f"[gemini] planned_commands={json.dumps(safe_cmds, ensure_ascii=True)}")
770
+ return safe_cmds
771
+ except Exception as exc:
772
+ self._append_log(log_file, f"[gemini] planner exception: {exc}")
773
+ return self._extract_readme_run_commands(source_dir, max_commands=max_commands)
774
+
775
+ def _execute_local_step(
776
+ self,
777
+ step_name: str,
778
+ *,
779
+ task: str,
780
+ input_manifest: dict[str, Any],
781
+ context: dict[str, Any],
782
+ log_file: Path,
783
+ ) -> dict[str, Any]:
784
+ run_dir: Path = context["run_dir"]
785
+ artifacts: dict[str, Any] = context["artifacts"]
786
+ code_repo = self._extract_code_repo(task=task, input_manifest=input_manifest)
787
+ source_dir = run_dir / "06_source_code"
788
+ artifacts["source_repo"] = code_repo
789
+ artifacts["source_dir"] = source_dir
790
+
791
+ if step_name == "source_clone":
792
+ if not code_repo or "unknown_repo" in code_repo:
793
+ return {"ok": False, "skipped": True, "result": {"warning": "source_repo_missing"}}
794
+ source_dir.parent.mkdir(parents=True, exist_ok=True)
795
+ if source_dir.exists():
796
+ result = self._run_local_command(["git", "-C", str(source_dir), "pull"], log_file=log_file)
797
+ else:
798
+ result = self._run_local_command(["git", "clone", "--depth", "1", code_repo, str(source_dir)], log_file=log_file)
799
+ ok = result.get("return_code", 1) == 0
800
+ return {"ok": ok, "result": result}
801
+
802
+ if step_name == "source_execute":
803
+ if not source_dir.exists():
804
+ return {"ok": False, "skipped": True, "result": {"warning": "source_dir_not_ready"}}
805
+ attempts: list[dict[str, Any]] = []
806
+ run_attempt_success = False
807
+ has_r = shutil.which("R") is not None
808
+ # Attempt 1: install dependencies if common file exists
809
+ req = source_dir / "requirements.txt"
810
+ if req.exists():
811
+ setup = self._run_local_command(["python", "-m", "pip", "install", "-r", str(req)], cwd=source_dir, log_file=log_file)
812
+ setup["attempt_type"] = "setup"
813
+ attempts.append(setup)
814
+ r_renv = source_dir / "renv.lock"
815
+ r_desc = source_dir / "DESCRIPTION"
816
+ if r_renv.exists():
817
+ if has_r:
818
+ setup = self._run_local_command(
819
+ [
820
+ "R",
821
+ "--vanilla",
822
+ "-e",
823
+ "if (!requireNamespace('renv', quietly=TRUE)) install.packages('renv', repos='https://cloud.r-project.org'); renv::restore(prompt=FALSE)",
824
+ ],
825
+ cwd=source_dir,
826
+ log_file=log_file,
827
+ timeout_s=1800,
828
+ )
829
+ setup["attempt_type"] = "setup"
830
+ attempts.append(setup)
831
+ else:
832
+ attempts.append(
833
+ {
834
+ "attempt_type": "setup",
835
+ "return_code": -1,
836
+ "warning": "R_not_found_for_renv_restore",
837
+ "command_executed": "R --vanilla -e <renv::restore>",
838
+ }
839
+ )
840
+ elif r_desc.exists() and has_r:
841
+ setup = self._run_local_command(
842
+ [
843
+ "R",
844
+ "--vanilla",
845
+ "-e",
846
+ "if (!requireNamespace('remotes', quietly=TRUE)) install.packages('remotes', repos='https://cloud.r-project.org'); remotes::install_local('.', upgrade='never')",
847
+ ],
848
+ cwd=source_dir,
849
+ log_file=log_file,
850
+ timeout_s=1800,
851
+ )
852
+ setup["attempt_type"] = "setup"
853
+ attempts.append(setup)
854
+ # Attempt 2: common reproducibility entry scripts
855
+ candidate_scripts = ["reproduce.sh", "run.sh", "scripts/reproduce.sh"]
856
+ executed = False
857
+ for rel in candidate_scripts:
858
+ fp = source_dir / rel
859
+ if fp.exists():
860
+ run = self._run_local_command(["bash", str(fp)], cwd=source_dir, log_file=log_file)
861
+ run["attempt_type"] = "run"
862
+ attempts.append(run)
863
+ run_attempt_success = run_attempt_success or (run.get("return_code", 1) == 0)
864
+ executed = True
865
+ break
866
+ # Attempt 3: Gemini agent plans and drives repo-specific runnable commands
867
+ gemini_cmds: list[list[str]] = []
868
+ if not executed:
869
+ gemini_cmds = self._plan_source_commands_with_gemini(
870
+ task=task,
871
+ source_dir=source_dir,
872
+ input_manifest=input_manifest,
873
+ log_file=log_file,
874
+ )
875
+ print(gemini_cmds)
876
+ for cmd in gemini_cmds:
877
+ run = self._run_local_command(cmd, cwd=source_dir, log_file=log_file)
878
+ run["attempt_type"] = "run_gemini"
879
+ attempts.append(run)
880
+ if run.get("return_code", 1) == 0:
881
+ run_attempt_success = True
882
+ executed = True
883
+ break
884
+
885
+ if run_attempt_success:
886
+ return {"ok": True, "result": {"attempts": attempts, "gemini_planned_commands": gemini_cmds}}
887
+ warning = "source_execution_not_reproduced"
888
+ if not gemini_cmds and not executed:
889
+ warning = "gemini_no_executable_command"
890
+ return {
891
+ "ok": False,
892
+ "skipped": True,
893
+ "result": {
894
+ "warning": warning,
895
+ "gemini_planned_commands": gemini_cmds,
896
+ "attempts": attempts,
897
+ },
898
+ }
899
+
900
+ if step_name == "summarize_repro":
901
+ summary = {
902
+ "source_repo": code_repo or "unknown_repo",
903
+ "data_sources": input_manifest.get("data_sources", []),
904
+ "repro_steps": input_manifest.get("repro_steps", []),
905
+ "notes": (
906
+ "Summary generated from benchmark prompt execution. "
907
+ "Review source execution attempts and MCP preprocessing outputs for final conclusion."
908
+ ),
909
+ }
910
+ return {"ok": True, "result": summary}
911
+
912
+ return {"ok": False, "result": {"error": f"unsupported_local_step:{step_name}"}}
913
+
914
+ @staticmethod
915
+ def _bowtie2_index_exists(index_base: str | Path) -> bool:
916
+ base = Path(index_base)
917
+ bt2_suffixes = [".1.bt2", ".2.bt2", ".3.bt2", ".4.bt2", ".rev.1.bt2", ".rev.2.bt2"]
918
+ bt2l_suffixes = [s.replace(".bt2", ".bt2l") for s in bt2_suffixes]
919
+ return any((base.parent / (base.name + s)).exists() for s in bt2_suffixes) or any(
920
+ (base.parent / (base.name + s)).exists() for s in bt2l_suffixes
921
+ )
922
+
923
+ def execute_task(
924
+ self,
925
+ task: str,
926
+ input_manifest: dict[str, Any],
927
+ task_scope: str,
928
+ pipeline_config: dict[str, Any] | None = None,
929
+ ) -> dict[str, Any]:
930
+ report = ExperimentReport.new(
931
+ task=task,
932
+ input_manifest=input_manifest,
933
+ pipeline_config_id=(pipeline_config or {}).get("config_id"),
934
+ )
935
+ run_dir = self.default_results_root / report.run_id
936
+ run_dir.mkdir(parents=True, exist_ok=True)
937
+ log_path = run_dir / "pipeline.log"
938
+ log_path.write_text("", encoding="utf-8")
939
+
940
+ plan = self._plan_task(task=task, task_scope=task_scope, pipeline_config=pipeline_config)
941
+ route_snapshot: dict[str, Any] = {}
942
+ self._append_log(log_path, f"[planner] generated_plan={json.dumps(plan, ensure_ascii=True)}")
943
+
944
+ try:
945
+ params = (pipeline_config or {}).get("parameters", {})
946
+ threads = int(params.get("threads", 4))
947
+ quality_cutoff = int(params.get("quality_cutoff", 20))
948
+ index_base = input_manifest.get("reference_index_base") or params.get("reference_index_base")
949
+ r1 = Path(input_manifest["r1"]).resolve()
950
+ r2 = Path(input_manifest["r2"]).resolve()
951
+
952
+ if task_scope != "first_pipeline":
953
+ self._append_log(
954
+ log_path,
955
+ "[planner] non-first_pipeline scope detected; running generic configured steps plan",
956
+ )
957
+ if not r1.exists() or not r2.exists():
958
+ raise FileNotFoundError("Input FASTQ files not found for r1/r2.")
959
+
960
+ index_ready = bool(index_base) and self._bowtie2_index_exists(index_base)
961
+ if not index_ready:
962
+ before = len(plan)
963
+ plan = [s for s in plan if s.get("name") != "align"]
964
+ self._append_log(
965
+ log_path,
966
+ "[planner] align_step_skipped reason=missing_or_invalid_reference_index",
967
+ )
968
+ self._append_log(log_path, f"[planner] plan_rewritten from_steps={before} to_steps={len(plan)}")
969
+
970
+ step_results: dict[str, Any] = {}
971
+ artifacts: dict[str, Any] = {}
972
+ warnings: list[str] = []
973
+ skippable_steps = {"aggregate"}
974
+ context = {
975
+ "run_dir": run_dir,
976
+ "r1": r1,
977
+ "r2": r2,
978
+ "threads": threads,
979
+ "quality_cutoff": quality_cutoff,
980
+ "index_base": index_base,
981
+ "artifacts": artifacts,
982
+ }
983
+
984
+ for step in plan:
985
+ step_name = step["name"]
986
+ if step.get("local_executor"):
987
+ local_result = self._execute_local_step(
988
+ step_name=step_name,
989
+ task=task,
990
+ input_manifest=input_manifest,
991
+ context=context,
992
+ log_file=log_path,
993
+ )
994
+ step_results[step_name] = local_result
995
+ if not local_result.get("ok"):
996
+ warn = f"skip_or_fail_local_step:{step_name}"
997
+ warnings.append(warn)
998
+ self._append_log(log_path, f"[planner] {warn}")
999
+ continue
1000
+ candidates = step["candidates"]
1001
+ chosen_route = None
1002
+ for candidate in candidates:
1003
+ chosen_route = self._route_or_generate(candidate, log_file=log_path)
1004
+ if chosen_route is not None:
1005
+ if self.router.binary_available(chosen_route.function_name):
1006
+ break
1007
+ self._append_log(
1008
+ log_path,
1009
+ f"[planner] skip_candidate={candidate} reason=binary_unavailable binary={chosen_route.binary_name}",
1010
+ )
1011
+ chosen_route = None
1012
+ if chosen_route is None:
1013
+ if step_name in skippable_steps:
1014
+ warn = f"skip_step:{step_name} reason=no_available_mcp_route"
1015
+ warnings.append(warn)
1016
+ self._append_log(log_path, f"[planner] {warn}")
1017
+ step_results[step_name] = {
1018
+ "ok": False,
1019
+ "skipped": True,
1020
+ "result": {"warning": warn},
1021
+ }
1022
+ continue
1023
+ raise RuntimeError(f"no_mcp_route_found_for_step:{step_name}")
1024
+
1025
+ route_snapshot[step_name] = {
1026
+ "requested_candidates": candidates,
1027
+ "selected_tool": chosen_route.tool_name,
1028
+ "selected_function": chosen_route.function_name,
1029
+ "binary": chosen_route.binary_name,
1030
+ "binary_available": self.router.binary_available(chosen_route.function_name),
1031
+ "server_script": str(chosen_route.server_script),
1032
+ "server_exists": chosen_route.server_script.exists(),
1033
+ }
1034
+
1035
+ kwargs = self._build_step_kwargs(
1036
+ step_name=step_name,
1037
+ route_function=chosen_route.function_name,
1038
+ context=context,
1039
+ )
1040
+ result = self._invoke_mcp_tool(chosen_route, kwargs=kwargs, log_file=log_path)
1041
+ step_results[step_name] = result
1042
+ if not result["ok"]:
1043
+ if step_name in skippable_steps:
1044
+ warn = f"skip_step:{step_name} reason=tool_execution_failed"
1045
+ warnings.append(warn)
1046
+ self._append_log(log_path, f"[planner] {warn}")
1047
+ step_results[step_name] = {
1048
+ "ok": False,
1049
+ "skipped": True,
1050
+ "result": {
1051
+ **self._jsonable(result["result"]),
1052
+ "warning": warn,
1053
+ },
1054
+ }
1055
+ continue
1056
+ raise RuntimeError(f"{step_name}_failed")
1057
+
1058
+ # capture key artifacts
1059
+ output_files = result["result"].get("output_files") or []
1060
+ if step_name == "trim":
1061
+ trim_dir = run_dir / "02_trim"
1062
+ # best-effort: prefer explicit output_files when available
1063
+ if output_files:
1064
+ r1_candidates = [Path(p) for p in output_files if ("_val_1" in p or "_fastp_R1" in p)]
1065
+ r2_candidates = [Path(p) for p in output_files if ("_val_2" in p or "_fastp_R2" in p)]
1066
+ if r1_candidates and r2_candidates:
1067
+ artifacts["trimmed_r1"] = r1_candidates[-1]
1068
+ artifacts["trimmed_r2"] = r2_candidates[-1]
1069
+
1070
+ if "trimmed_r1" not in artifacts or "trimmed_r2" not in artifacts:
1071
+ trimmed_r1, trimmed_r2 = self._pick_trimmed_pair(trim_dir)
1072
+ if trimmed_r1 and trimmed_r2:
1073
+ artifacts["trimmed_r1"] = trimmed_r1
1074
+ artifacts["trimmed_r2"] = trimmed_r2
1075
+ if "trimmed_r1" not in artifacts or "trimmed_r2" not in artifacts:
1076
+ raise RuntimeError("trimmed_files_not_found")
1077
+
1078
+ multiqc_report = run_dir / "05_multiqc" / "multiqc_report.html"
1079
+ if (step_results.get("aggregate") or {}).get("ok") and multiqc_report.exists():
1080
+ artifacts["multiqc_report"] = multiqc_report
1081
+
1082
+ self._collect_fastqc_artifacts(run_dir=run_dir, artifacts=artifacts)
1083
+
1084
+ summary = {
1085
+ "status": "completed_with_warnings" if warnings else "completed",
1086
+ "task_scope": task_scope,
1087
+ "task": task,
1088
+ "plan": self._jsonable(plan),
1089
+ "steps": self._jsonable({k: {"ok": v["ok"], "result": v["result"]} for k, v in step_results.items()}),
1090
+ "warnings": warnings,
1091
+ "artifacts": {
1092
+ "trimmed_r1": str(artifacts.get("trimmed_r1", "")),
1093
+ "trimmed_r2": str(artifacts.get("trimmed_r2", "")),
1094
+ "sam": str(artifacts.get("sam", "")),
1095
+ "multiqc_report": str(artifacts.get("multiqc_report", multiqc_report)),
1096
+ "fastqc_raw_html": self._jsonable(artifacts.get("fastqc_raw_html", [])),
1097
+ "fastqc_trimmed_html": self._jsonable(artifacts.get("fastqc_trimmed_html", [])),
1098
+ },
1099
+ "router": self._jsonable(route_snapshot),
1100
+ }
1101
+ (run_dir / "summary.json").write_text(
1102
+ json.dumps(summary, indent=2, ensure_ascii=True),
1103
+ encoding="utf-8",
1104
+ )
1105
+
1106
+ analysis_report_path = self._write_analysis_report(
1107
+ run_dir=run_dir,
1108
+ task=task,
1109
+ task_scope=task_scope,
1110
+ status=summary["status"],
1111
+ step_results=step_results,
1112
+ warnings=warnings,
1113
+ artifacts=artifacts,
1114
+ )
1115
+
1116
+ report.status = "completed_with_warnings" if warnings else "completed"
1117
+ report.execution_log = str(log_path)
1118
+ report.output_artifacts = [str(run_dir / "summary.json"), str(analysis_report_path)]
1119
+ if artifacts.get("multiqc_report"):
1120
+ report.output_artifacts.append(str(artifacts["multiqc_report"]))
1121
+ report.metrics = {
1122
+ "steps": len(plan),
1123
+ "has_pipeline_config": bool(pipeline_config),
1124
+ "task_scope": task_scope,
1125
+ "plan": self._jsonable(plan),
1126
+ "router": self._jsonable(route_snapshot),
1127
+ "step_results": self._jsonable(step_results),
1128
+ "warnings": warnings,
1129
+ "log_tail": self._read_log_tail(log_path),
1130
+ }
1131
+ if warnings:
1132
+ report.notes = (
1133
+ "Executed with warnings via MCP routing plan. "
1134
+ f"Warnings: {'; '.join(warnings)}"
1135
+ )
1136
+ else:
1137
+ report.notes = "Executed via MCP routing plan with dynamic tool selection and converter fallback."
1138
+ except Exception as exc: # pragma: no cover - defensive path
1139
+ report.status = "failed"
1140
+ report.execution_log = str(log_path)
1141
+ report.failures.append(
1142
+ FailureMode(
1143
+ step_name="executor",
1144
+ error_type=type(exc).__name__,
1145
+ error_message=str(exc),
1146
+ hint="Inspect pipeline.log and parameters.",
1147
+ )
1148
+ )
1149
+ self._append_log(log_path, f"[V1] status=failed error={exc}")
1150
+ failure_summary = {
1151
+ "status": "failed",
1152
+ "task_scope": task_scope,
1153
+ "task": task,
1154
+ "plan": self._jsonable(plan),
1155
+ "router": self._jsonable(route_snapshot),
1156
+ "partial_steps": self._jsonable(step_results),
1157
+ "artifacts": self._jsonable(artifacts),
1158
+ "error": {"type": type(exc).__name__, "message": str(exc)},
1159
+ "log_tail": self._read_log_tail(log_path),
1160
+ }
1161
+ (run_dir / "summary.json").write_text(
1162
+ json.dumps(failure_summary, indent=2, ensure_ascii=True),
1163
+ encoding="utf-8",
1164
+ )
1165
+ report.output_artifacts = [str(run_dir / "summary.json")]
1166
+ report.metrics = {
1167
+ "task_scope": task_scope,
1168
+ "router": self._jsonable(route_snapshot),
1169
+ "plan": self._jsonable(plan),
1170
+ "partial_steps": self._jsonable(step_results),
1171
+ "log_tail": self._read_log_tail(log_path),
1172
+ }
1173
+
1174
+ self.memory.save_report(report)
1175
+ return report.to_dict()
1176
+
1177
+ def execute_autopilot(
1178
+ self,
1179
+ user_goal: str,
1180
+ data_dir: str | Path,
1181
+ task_scope: str = "first_pipeline",
1182
+ pipeline_config: dict[str, Any] | None = None,
1183
+ manifest_overrides: dict[str, Any] | None = None,
1184
+ ) -> dict[str, Any]:
1185
+ r1, r2 = self._discover_paired_fastq(Path(data_dir))
1186
+ manifest: dict[str, Any] = {"r1": str(r1), "r2": str(r2)}
1187
+ if manifest_overrides:
1188
+ manifest.update(manifest_overrides)
1189
+ # reference index is optional; execute_task 会根据有效性决定是否执行 align
1190
+ if pipeline_config and pipeline_config.get("parameters", {}).get("reference_index_base"):
1191
+ manifest["reference_index_base"] = pipeline_config["parameters"]["reference_index_base"]
1192
+ return self.execute_task(
1193
+ task=user_goal,
1194
+ input_manifest=manifest,
1195
+ task_scope=task_scope,
1196
+ pipeline_config=pipeline_config,
1197
+ )
1198
+
1199
+ @staticmethod
1200
+ def _discover_paired_fastq(data_dir: Path) -> tuple[Path, Path]:
1201
+ if not data_dir.exists():
1202
+ raise FileNotFoundError(f"data_dir not found: {data_dir}")
1203
+
1204
+ files = sorted(
1205
+ [
1206
+ p for p in data_dir.iterdir()
1207
+ if p.is_file() and p.name.lower().endswith((".fastq", ".fq", ".fastq.gz", ".fq.gz"))
1208
+ ]
1209
+ )
1210
+ if len(files) < 2:
1211
+ raise ValueError("Need at least two FASTQ files in data_dir.")
1212
+
1213
+ # 优先找 R1/R2 命名对
1214
+ for f in files:
1215
+ name = f.name
1216
+ r2_name = (
1217
+ name.replace("_R1", "_R2")
1218
+ .replace(".R1.", ".R2.")
1219
+ .replace("_1.", "_2.")
1220
+ )
1221
+ for g in files:
1222
+ if g.name == r2_name:
1223
+ return f, g
1224
+
1225
+ # fallback: 前两个 FASTQ
1226
+ return files[0], files[1]
1227
+
1228
+ @staticmethod
1229
+ def _collect_fastqc_artifacts(run_dir: Path, artifacts: dict[str, Any]) -> None:
1230
+ raw_dir = run_dir / "01_fastqc_raw"
1231
+ trim_dir = run_dir / "04_fastqc_trimmed"
1232
+ if raw_dir.exists():
1233
+ artifacts["fastqc_raw_html"] = [str(p) for p in sorted(raw_dir.glob("*_fastqc.html"))]
1234
+ if trim_dir.exists():
1235
+ artifacts["fastqc_trimmed_html"] = [str(p) for p in sorted(trim_dir.glob("*_fastqc.html"))]
1236
+
1237
+ @staticmethod
1238
+ def _extract_fastp_metrics(step_results: dict[str, Any]) -> dict[str, Any]:
1239
+ trim = step_results.get("trim", {})
1240
+ result = trim.get("result", {}) if isinstance(trim, dict) else {}
1241
+ output_files = result.get("output_files") or []
1242
+ json_fp = None
1243
+ for fp in output_files:
1244
+ if str(fp).endswith(".json") and "fastp" in str(fp):
1245
+ json_fp = Path(fp)
1246
+ break
1247
+ if not json_fp or not json_fp.exists():
1248
+ return {}
1249
+ try:
1250
+ payload = json.loads(json_fp.read_text(encoding="utf-8"))
1251
+ summary = payload.get("summary", {})
1252
+ before = summary.get("before_filtering", {})
1253
+ after = summary.get("after_filtering", {})
1254
+ filtering = summary.get("filtering_result", {})
1255
+ return {
1256
+ "before_total_reads": before.get("total_reads"),
1257
+ "after_total_reads": after.get("total_reads"),
1258
+ "q30_rate_after": after.get("q30_rate"),
1259
+ "passed_filter_reads": filtering.get("passed_filter_reads"),
1260
+ "low_quality_reads": filtering.get("low_quality_reads"),
1261
+ }
1262
+ except Exception:
1263
+ return {}
1264
+
1265
+ def _write_analysis_report(
1266
+ self,
1267
+ run_dir: Path,
1268
+ task: str,
1269
+ task_scope: str,
1270
+ status: str,
1271
+ step_results: dict[str, Any],
1272
+ warnings: list[str],
1273
+ artifacts: dict[str, Any],
1274
+ ) -> Path:
1275
+ metrics = self._extract_fastp_metrics(step_results)
1276
+ lines: list[str] = []
1277
+ lines.append("# BioClawMCP Analysis Report")
1278
+ lines.append("")
1279
+ lines.append(f"- Task: {task}")
1280
+ lines.append(f"- Task Scope: {task_scope}")
1281
+ lines.append(f"- Status: {status}")
1282
+ lines.append("")
1283
+ if warnings:
1284
+ lines.append("## Warnings")
1285
+ for w in warnings:
1286
+ lines.append(f"- {w}")
1287
+ lines.append("")
1288
+
1289
+ lines.append("## Pipeline Execution Summary")
1290
+ lines.append("| Step | Status |")
1291
+ lines.append("|---|---|")
1292
+ for step_name, step_payload in step_results.items():
1293
+ ok = step_payload.get("ok", False)
1294
+ skipped = step_payload.get("skipped", False)
1295
+ status_text = "skipped" if skipped else ("ok" if ok else "failed")
1296
+ lines.append(f"| {step_name} | {status_text} |")
1297
+ lines.append("")
1298
+
1299
+ if metrics:
1300
+ lines.append("## fastp Key Metrics")
1301
+ lines.append(f"- Before total reads: {metrics.get('before_total_reads')}")
1302
+ lines.append(f"- After total reads: {metrics.get('after_total_reads')}")
1303
+ lines.append(f"- Q30 rate after: {metrics.get('q30_rate_after')}")
1304
+ lines.append(f"- Passed filter reads: {metrics.get('passed_filter_reads')}")
1305
+ lines.append(f"- Low quality reads: {metrics.get('low_quality_reads')}")
1306
+ lines.append("")
1307
+
1308
+ lines.append("## Output Artifacts")
1309
+ if artifacts.get("trimmed_r1"):
1310
+ lines.append(f"- Trimmed R1: {artifacts.get('trimmed_r1')}")
1311
+ if artifacts.get("trimmed_r2"):
1312
+ lines.append(f"- Trimmed R2: {artifacts.get('trimmed_r2')}")
1313
+ if artifacts.get("sam"):
1314
+ lines.append(f"- Alignment SAM: {artifacts.get('sam')}")
1315
+ if artifacts.get("multiqc_report"):
1316
+ lines.append(f"- MultiQC report: {artifacts.get('multiqc_report')}")
1317
+ for fp in artifacts.get("fastqc_raw_html", []):
1318
+ lines.append(f"- FastQC raw HTML: {fp}")
1319
+ for fp in artifacts.get("fastqc_trimmed_html", []):
1320
+ lines.append(f"- FastQC trimmed HTML: {fp}")
1321
+ lines.append("")
1322
+
1323
+ lines.append("## Per-step Command & Logs")
1324
+ for step_name, step_payload in step_results.items():
1325
+ lines.append(f"### {step_name}")
1326
+ result = step_payload.get("result", {}) if isinstance(step_payload, dict) else {}
1327
+ cmd = result.get("command_executed", "")
1328
+ if cmd:
1329
+ lines.append("```bash")
1330
+ lines.append(cmd)
1331
+ lines.append("```")
1332
+ stderr = result.get("stderr", "")
1333
+ stdout = result.get("stdout", "")
1334
+ if stderr:
1335
+ lines.append("stderr:")
1336
+ lines.append("```text")
1337
+ lines.append(str(stderr)[:6000])
1338
+ lines.append("```")
1339
+ if stdout:
1340
+ lines.append("stdout:")
1341
+ lines.append("```text")
1342
+ lines.append(str(stdout)[:6000])
1343
+ lines.append("```")
1344
+ lines.append("")
1345
+
1346
+ report_path = run_dir / "analysis_report.md"
1347
+ report_path.write_text("\n".join(lines), encoding="utf-8")
1348
+ return report_path
BioScientist/agent_system/engines/v1_executor_backup/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .version import __version__
2
+
3
+ __all__ = ["__version__"]
BioScientist/agent_system/engines/v1_executor_backup/agent/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from biomni.agent.a1 import A1 # noqa: F401
BioScientist/agent_system/engines/v1_executor_backup/agent/__pycache__/__init__.cpython-311.pyc ADDED
Binary file (215 Bytes). View file
 
BioScientist/agent_system/engines/v1_executor_backup/agent/__pycache__/__init__.cpython-313.pyc ADDED
Binary file (192 Bytes). View file
 
BioScientist/agent_system/engines/v1_executor_backup/agent/a1.py ADDED
The diff for this file is too large to render. See raw diff
 
BioScientist/agent_system/engines/v1_executor_backup/agent/env_collection.py ADDED
@@ -0,0 +1,313 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ from typing import Any
5
+
6
+ from langchain_core.prompts import ChatPromptTemplate
7
+ from langchain_text_splitters import RecursiveCharacterTextSplitter
8
+
9
+ from biomni.agent.base_agent import base_agent
10
+
11
+
12
+ class PaperTaskExtractor(base_agent):
13
+ """Agent that extracts generalizable tasks/experiments from academic papers.
14
+ It processes papers in chunks and identifies common research tasks that could be shared across papers.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ llm="claude-3-7-sonnet-20250219",
20
+ cheap_llm=None,
21
+ tools=None,
22
+ chunk_size=4000,
23
+ chunk_overlap=400,
24
+ ):
25
+ """Initialize the PaperTaskExtractor agent.
26
+
27
+ Args:
28
+ llm (str): The LLM model to use
29
+ cheap_llm (str, optional): A cheaper LLM for simpler tasks
30
+ tools (list, optional): Any tools to use (not needed for this agent)
31
+ chunk_size (int): Size of text chunks for processing
32
+ chunk_overlap (int): Overlap between chunks
33
+
34
+ """
35
+ super().__init__(llm, cheap_llm, tools)
36
+ self.chunk_size = chunk_size
37
+ self.chunk_overlap = chunk_overlap
38
+ self.log = []
39
+ self.configure()
40
+
41
+ def configure(self):
42
+ """Configure the agent with appropriate prompts."""
43
+ # Prompt for analyzing paper chunks
44
+ self.chunk_analysis_prompt = """You are a research methodology expert specializing in identifying computational tasks and data analysis procedures in academic papers.
45
+
46
+ Your job is to analyze chunks of academic papers and identify ONLY the most common, generalizable computational tasks that are widely used across biomedical research and can be implemented with Python or Linux code.
47
+
48
+ STRICT GUIDELINES:
49
+ 1. ONLY extract tasks that are extremely common and standard in computational biomedical research
50
+ 2. Each task MUST have clear, well-defined inputs and outputs
51
+ 3. Tasks MUST be generalizable across many different papers and research questions
52
+ 4. Be VERY selective - only include tasks that appear in hundreds of papers
53
+ 5. If a task is specific to this paper, unclear, or not widely used, DO NOT include it
54
+ 6. Focus on computational tasks that can be implemented with Python or Linux code
55
+ 7. Each task should be something that could be implemented as a function with clear inputs/outputs
56
+ 8. Also identify commonly used databases and software packages mentioned in the text
57
+ 9. Tasks MUST be CONCRETE and SPECIFIC - include exact methodological details
58
+ 10. Avoid vague task names like "Statistical Analysis" - instead use specific protocol names like "Two-way ANOVA with Tukey's Post-hoc Test using SciPy"
59
+ 11. DO NOT include wet lab procedures that cannot be implemented computationally
60
+ 12. ONLY include tasks that could be automated with code
61
+
62
+ For the following chunk of text from an academic paper, provide:
63
+ 1. A list of ONLY the most common, generalizable COMPUTATIONAL tasks identified (be extremely selective)
64
+ 2. For each task, clearly define:
65
+ - Task name: A SPECIFIC and CONCRETE name with methodological details (e.g., "RNA-seq Differential Expression Analysis with DESeq2" instead of just "Gene Expression Analysis")
66
+ - Input: What SPECIFIC data or parameters the task requires
67
+ - Output: What SPECIFIC data or results the task produces
68
+ - Code implementation: How this task could be implemented with Python or Linux code, including key libraries/packages
69
+ - Frequency: How common this computational task is in biomedical research
70
+ - Standard methods: The established computational techniques used to perform this task
71
+ - Example: A brief description of how THIS specific paper uses this task (with specific details from the paper)
72
+ 3. A list of commonly used databases mentioned in the text (if any)
73
+ 4. A list of commonly used software packages/tools mentioned in the text (if any)
74
+
75
+ PAPER CHUNK:
76
+ {chunk_text}
77
+
78
+ Remember, it's better to return NO tasks than to include tasks that aren't extremely common, generalizable, and implementable with code. Quality over quantity is essential. Tasks MUST be CONCRETE with SPECIFIC methodological details and MUST be implementable with Python or Linux code.
79
+ """
80
+
81
+ # Prompt for consolidating tasks
82
+ self.consolidation_prompt = """You are a research methodology expert. Your task is to consolidate lists of computational research tasks extracted from different chunks of an academic paper.
83
+
84
+ BE EXTREMELY SELECTIVE. Only include tasks that are:
85
+ 1. Fundamental to computational biomedical research
86
+ 2. Used in hundreds of papers across different subfields
87
+ 3. Have clear, well-defined inputs and outputs
88
+ 4. Represent standard computational approaches
89
+ 5. Could be implemented as a function with specific inputs and outputs
90
+ 6. Are CONCRETE and SPECIFIC with exact methodological details
91
+ 7. Can be implemented with Python or Linux code
92
+ 8. Are computational in nature, not wet lab procedures
93
+
94
+ REMOVE any tasks that:
95
+ - Are specific to a particular paper or dataset
96
+ - Lack clear inputs or outputs
97
+ - Are not widely used across biomedical research
98
+ - Are vague or poorly defined
99
+ - Represent niche or specialized techniques
100
+ - Have generic names without specific methodological details
101
+ - Cannot be implemented with code
102
+ - Require physical lab equipment or manual intervention
103
+
104
+ The output should be a JSON object with three main keys:
105
+ 1. "tasks": A list of task objects, where each task has:
106
+ - "task_name": A SPECIFIC and CONCRETE name with methodological details (e.g., "Single-cell RNA-seq Clustering with Seurat" instead of just "Cell Clustering")
107
+ - "description": A clear description of what the computational task does
108
+ - "inputs": SPECIFIC data types or parameters the task requires
109
+ - "outputs": SPECIFIC data types or results the task produces
110
+ - "code_implementation": How this task could be implemented with Python or Linux code, including key libraries/packages and a brief pseudocode example
111
+ - "frequency": How common this computational task is in biomedical research
112
+ - "standard_methods": The established computational techniques used to perform this task
113
+ - "example": A specific example from THIS paper showing how the task was used (with concrete details)
114
+
115
+ 2. "databases": A list of database objects, where each database has:
116
+ - "name": The name of the database
117
+ - "description": A brief description of what the database contains
118
+ - "url": The URL of the database (if mentioned)
119
+ - "usage": How the database is commonly used in computational biomedical research
120
+ - "example": How this specific paper uses the database
121
+
122
+ 3. "software": A list of software package objects, where each package has:
123
+ - "name": The name of the software package
124
+ - "description": A brief description of what the software does
125
+ - "url": The URL or reference to the software (if mentioned)
126
+ - "usage": How the software is commonly used in computational biomedical research
127
+ - "example": How this specific paper uses the software
128
+
129
+ EXTRACTED INFORMATION FROM PAPER CHUNKS:
130
+ {task_lists}
131
+
132
+ Be ruthless in filtering - it's better to return a few truly common computational tasks than many that aren't universal or implementable with code.
133
+ Respond with only a valid JSON object containing the three lists described above.
134
+ """
135
+
136
+ def process_paper(self, paper_text: str) -> dict[str, list[dict[str, Any]]]:
137
+ """Process a paper and extract generalizable tasks/experiments, databases, and software.
138
+
139
+ Args:
140
+ paper_text (str): The full text of the paper
141
+
142
+ Returns:
143
+ Dict[str, List[Dict[str, Any]]]: A dictionary with tasks, databases, and software
144
+
145
+ """
146
+ # Split the paper into chunks
147
+ text_splitter = RecursiveCharacterTextSplitter(
148
+ chunk_size=self.chunk_size,
149
+ chunk_overlap=self.chunk_overlap,
150
+ length_function=len,
151
+ separators=["\n\n", "\n", ". ", " ", ""],
152
+ )
153
+ chunks = text_splitter.split_text(paper_text)
154
+
155
+ # Process each chunk to extract tasks
156
+ chunk_results = []
157
+ for i, chunk in enumerate(chunks):
158
+ print(f"Processing chunk {i + 1}/{len(chunks)}...")
159
+ chunk_tasks = self._process_chunk(chunk)
160
+ chunk_results.append(chunk_tasks)
161
+
162
+ # Consolidate tasks from all chunks
163
+ consolidated_results = self._consolidate_tasks(chunk_results)
164
+ return consolidated_results
165
+
166
+ def _process_chunk(self, chunk_text: str) -> str:
167
+ """Process a single chunk of the paper to extract tasks.
168
+
169
+ Args:
170
+ chunk_text (str): The chunk text to process
171
+
172
+ Returns:
173
+ str: Extracted tasks from this chunk
174
+
175
+ """
176
+ prompt = self.chunk_analysis_prompt.format(chunk_text=chunk_text)
177
+ message = self.llm.invoke(prompt)
178
+ return message.content
179
+
180
+ def _consolidate_tasks(self, chunk_results: list[str]) -> dict[str, list[dict[str, Any]]]:
181
+ """Consolidate tasks, databases, and software extracted from different chunks into a unified structure.
182
+
183
+ Args:
184
+ chunk_results (List[str]): Results from each chunk
185
+
186
+ Returns:
187
+ Dict[str, List[Dict[str, Any]]]: Consolidated information with tasks, databases, and software
188
+
189
+ """
190
+ # Combine all chunk results
191
+ all_tasks = "\n\n===== CHUNK SEPARATOR =====\n\n".join(chunk_results)
192
+
193
+ # Use the consolidation prompt to merge and organize tasks
194
+ prompt = self.consolidation_prompt.format(task_lists=all_tasks)
195
+ response = self.llm.invoke(prompt)
196
+
197
+ # Extract the JSON from the response
198
+ try:
199
+ # Try to parse the entire response as JSON
200
+ result = json.loads(response.content)
201
+ except json.JSONDecodeError:
202
+ # If that fails, try to extract JSON from the text
203
+ try:
204
+ # Look for JSON-like content between triple backticks
205
+ json_match = re.search(r"```(?:json)?\s*([\s\S]*?)\s*```", response.content)
206
+ if json_match:
207
+ result = json.loads(json_match.group(1))
208
+ else:
209
+ # Fallback: just return the text response
210
+ return {
211
+ "tasks": [
212
+ {
213
+ "error": "Could not parse JSON",
214
+ "raw_response": response.content,
215
+ }
216
+ ],
217
+ "databases": [],
218
+ "software": [],
219
+ }
220
+ except Exception:
221
+ return {
222
+ "tasks": [
223
+ {
224
+ "error": "Could not parse JSON",
225
+ "raw_response": response.content,
226
+ }
227
+ ],
228
+ "databases": [],
229
+ "software": [],
230
+ }
231
+
232
+ # Ensure the result has the expected structure
233
+ if not isinstance(result, dict):
234
+ result = {
235
+ "tasks": result if isinstance(result, list) else [],
236
+ "databases": [],
237
+ "software": [],
238
+ }
239
+
240
+ # Ensure all required keys exist
241
+ for key in ["tasks", "databases", "software"]:
242
+ if key not in result:
243
+ result[key] = []
244
+
245
+ return result
246
+
247
+ def go(self, paper_text: str):
248
+ """Process a paper and return the extracted tasks, databases, and software.
249
+
250
+ Args:
251
+ paper_text (str): The full text of the paper
252
+
253
+ Returns:
254
+ tuple: (log, results) where log is a list of processing steps and results is the final result
255
+
256
+ """
257
+ self.log = []
258
+ self.log.append(
259
+ (
260
+ "user",
261
+ "Extract only the most common and generalizable biomedical research tasks, databases, and software from this paper",
262
+ )
263
+ )
264
+
265
+ results = self.process_paper(paper_text)
266
+
267
+ result_str = json.dumps(results, indent=2)
268
+ self.log.append(("assistant", result_str))
269
+
270
+ return self.log, results
271
+
272
+ def save_results(self, results: dict[str, list[dict[str, Any]]], output_path: str):
273
+ """Save the extracted tasks, databases, and software to a JSON file.
274
+
275
+ Args:
276
+ results (Dict[str, List[Dict[str, Any]]]): The extracted tasks, databases, and software
277
+ output_path (str): Path to save the results
278
+
279
+ """
280
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
281
+ with open(output_path, "w") as f:
282
+ json.dump(results, f, indent=2)
283
+ print(f"Results saved to {output_path}")
284
+
285
+ def result_formatting(self, output_class, task_intention):
286
+ """Format the results according to a specific output class.
287
+
288
+ Args:
289
+ output_class: The class to format the output as
290
+ task_intention: Description of the task
291
+
292
+ Returns:
293
+ The formatted result
294
+
295
+ """
296
+ format_check_prompt = ChatPromptTemplate.from_messages(
297
+ [
298
+ (
299
+ "system",
300
+ (
301
+ "You are evaluateGPT, tasked with extract and parse the task output based on the history of an agent. "
302
+ "Review the entire history of messages provided. "
303
+ "Here is the task output requirement: \n"
304
+ f"'{task_intention.replace('{', '{{').replace('}', '}}')}'.\n"
305
+ ),
306
+ ),
307
+ ("placeholder", "{messages}"),
308
+ ]
309
+ )
310
+
311
+ checker_llm = format_check_prompt | self.llm.with_structured_output(output_class)
312
+ result = checker_llm.invoke({"messages": [("user", str(self.log))]}).dict()
313
+ return result
BioScientist/agent_system/engines/v1_executor_backup/agent/function_generator.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+
3
+ from biomni.llm import get_llm
4
+
5
+
6
+ class base_agent:
7
+ def __init__(self, llm="claude-3-haiku-20240307", cheap_llm=None, tools=None, temperature=0.7):
8
+ self.tools = tools
9
+ self.llm = get_llm(llm, temperature)
10
+ if cheap_llm is None:
11
+ self.cheap_llm = llm
12
+ else:
13
+ self.cheap_llm = cheap_llm
14
+
15
+ def configure(self):
16
+ pass
17
+
18
+ def go(self, input):
19
+ pass
20
+
21
+
22
+ class FunctionGenerator(base_agent):
23
+ """Agent that generates executable Python code scripts given a task description."""
24
+
25
+ def __init__(self, llm="claude-3-7-sonnet-20250219", cheap_llm=None, temperature=0.7):
26
+ """Initialize the PaperTaskExtractor agent.
27
+
28
+ Args:
29
+ llm (str): The LLM model to use
30
+ cheap_llm (str, optional): A cheaper LLM for simpler tasks
31
+ """
32
+ super().__init__(llm, cheap_llm, temperature)
33
+ self.log = []
34
+ self.configure()
35
+
36
+ def configure(self):
37
+ """Configure the agent with appropriate prompts."""
38
+ # Prompt for Python code generation
39
+ self.system_prompt = """You are a senior Python engineer. Generate robust, idiomatic Python code that solves the user's task. Requirements:
40
+ 1. Output ONLY Python code, ideally inside a single triple-backtick code block.
41
+ 2. Include minimal inline comments and a small docstring.
42
+ 3. Add a `main()` and an `if __name__ == '__main__':` guard when appropriate.
43
+ 4. Avoid external dependencies unless necessary; if used, show `pip` installs in comments.
44
+ 5. Do not include prose before or after the code.
45
+ 6. When applicable, prioritize the use of codes on public repositories, such as HuggingFace or Github
46
+
47
+ Generate Python codes for the following task:
48
+ {task}
49
+ """
50
+
51
+ def _generate_code(self, task_description: str) -> str:
52
+ """Generate codes given a task description.
53
+ Args:
54
+ task_description (str): task descriptions (possibly generated from previous steps)
55
+
56
+ Returns:
57
+ str: generated code string
58
+
59
+ """
60
+ prompt = self.system_prompt.format(task=task_description)
61
+ message = self.llm.invoke(prompt)
62
+ return message.content
63
+
64
+ def _generate_script_filename(self, task_description: str, max_words: int = 6) -> str:
65
+ """
66
+ Generate a safe, meaningful Python script filename from a task description.
67
+ Poised for update: may ask the agent to suggest meaningful names.
68
+
69
+ Parameters:
70
+ -----------
71
+ task_description (str): task descriptions (possibly generated from previous steps)
72
+
73
+ max_words : int
74
+ Maximum number of words to include in the filename.
75
+
76
+ Returns:
77
+ --------
78
+ str
79
+ A lowercase, hyphen-free, safe filename ending in '.py'.
80
+ """
81
+ # Lowercase and remove non-alphanumeric (allow spaces for splitting)
82
+ cleaned = re.sub(r"[^a-zA-Z0-9\s]", "", task_description.lower())
83
+
84
+ # Tokenize and select top words
85
+ words = cleaned.split()
86
+ selected_words = words[:max_words] if words else ["script"]
87
+
88
+ # Join with underscores
89
+ base_name = "_".join(selected_words)
90
+ return f"{base_name}.py"
91
+
92
+ def go(self, task_description: str):
93
+ """Implement the inherited function to get the tasks done.
94
+
95
+ Args:
96
+ task_description (str): task descriptions (possibly generated from previous steps)
97
+
98
+ Returns:
99
+ tuple: (script_filename, results) where script_filename is a generated name for script file and results is the generated codes
100
+
101
+ """
102
+ self.log = []
103
+ self.log.append(
104
+ (
105
+ "user",
106
+ "Generate Python codes given a task description",
107
+ )
108
+ )
109
+
110
+ script_filename = self._generate_script_filename(task_description)
111
+ results = self._generate_code(task_description)
112
+ return script_filename, self._extract_code_block(results)
113
+
114
+ def _extract_code_block(self, s: str) -> str:
115
+ """
116
+ Extract the first fenced code block (``` or ```python) from s.
117
+ """
118
+ m = re.search(r"```(?:python)?\s*(.+?)\s*```", s, flags=re.DOTALL | re.IGNORECASE)
119
+ return m.group(1).strip() if m else None
BioScientist/agent_system/engines/v1_executor_backup/agent/qa_llm.py ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from langchain_core.prompts import ChatPromptTemplate
2
+
3
+ from biomni.llm import get_llm
4
+
5
+
6
+ class qa_llm:
7
+ def __init__(self, path="./data", llm="claude-3-haiku-20240307", lab_bench_reproduce=False):
8
+ self.path = path
9
+ self.llm = get_llm(llm)
10
+
11
+ if lab_bench_reproduce:
12
+ self.prompt_modifier = """
13
+ The following is a multiple choice question about biology.
14
+ Please answer by responding with the letter of the correct answer.
15
+
16
+ Think step by step. \n
17
+ """
18
+ else:
19
+ self.prompt_modifier = ""
20
+ self.log = []
21
+
22
+ def configure(self):
23
+ pass
24
+
25
+ def go(self, input):
26
+ self.log = []
27
+ self.log.append(("user", input))
28
+ message = self.llm.invoke(self.prompt_modifier + input)
29
+ self.log.append(("assistant", message.content))
30
+ return [message.content], message.content
31
+
32
+ def result_formatting(self, output_class, task_intention):
33
+ self.format_check_prompt = ChatPromptTemplate.from_messages(
34
+ [
35
+ (
36
+ "system",
37
+ (
38
+ "You are evaluateGPT, tasked with extract and parse the task output based on the history of an agent. "
39
+ "Review the entire history of messages provided. "
40
+ "Here is the task output requirement: \n"
41
+ f"'{task_intention.replace('{', '{{').replace('}', '}}')}'.\n"
42
+ ),
43
+ ),
44
+ ("placeholder", "{messages}"),
45
+ ]
46
+ )
47
+
48
+ checker_llm = self.format_check_prompt | self.llm.with_structured_output(output_class)
49
+ result = checker_llm.invoke({"messages": [("user", str(self.log))]}).dict()
50
+ return result
BioScientist/agent_system/engines/v1_executor_backup/agent/react.py ADDED
@@ -0,0 +1,465 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import glob
2
+ import inspect
3
+ import json
4
+ import os
5
+ import signal
6
+ from collections.abc import Sequence
7
+ from functools import wraps
8
+ from multiprocessing import Process, Queue
9
+ from typing import Annotated, TypedDict
10
+
11
+ from langchain_core.messages import BaseMessage, SystemMessage, ToolMessage
12
+ from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
13
+ from langchain_core.runnables import RunnableConfig
14
+ from langgraph.graph import END, StateGraph
15
+ from langgraph.graph.message import add_messages
16
+
17
+ from biomni.config import default_config
18
+ from biomni.env_desc import data_lake_dict, library_content_dict
19
+ from biomni.llm import get_llm
20
+ from biomni.model.retriever import ToolRetriever
21
+ from biomni.tool.tool_registry import ToolRegistry
22
+ from biomni.utils import (
23
+ api_schema_to_langchain_tool,
24
+ function_to_api_schema,
25
+ pretty_print,
26
+ read_module2api,
27
+ )
28
+
29
+
30
+ # Define the AgentState TypedDict for our custom implementation
31
+ class AgentState(TypedDict):
32
+ """The state of the agent."""
33
+
34
+ # add_messages is a reducer that combines message sequences
35
+ messages: Annotated[Sequence[BaseMessage], add_messages]
36
+
37
+
38
+ class react:
39
+ def __init__(
40
+ self,
41
+ path: str | None = None,
42
+ llm: str | None = None,
43
+ use_tool_retriever: bool | None = None,
44
+ timeout_seconds: int | None = None,
45
+ ):
46
+ # Use default_config values for unspecified parameters
47
+ if path is None:
48
+ path = default_config.path
49
+ if llm is None:
50
+ llm = default_config.llm
51
+ if use_tool_retriever is None:
52
+ use_tool_retriever = default_config.use_tool_retriever
53
+ if timeout_seconds is None:
54
+ timeout_seconds = default_config.timeout_seconds
55
+
56
+ self.path = path
57
+ if not os.path.exists(path):
58
+ os.makedirs(path)
59
+ print(f"Created directory: {path}")
60
+ ### TODO: Download the data
61
+ else:
62
+ print(f"Data directory already exists: {path}, loading...")
63
+
64
+ module2api = read_module2api()
65
+
66
+ self.llm = get_llm(llm, config=default_config)
67
+ tools = []
68
+ for module, api_list in module2api.items():
69
+ print("Registering tools from module:", module)
70
+ tools += [api_schema_to_langchain_tool(api, mode="custom_tool", module_name=module) for api in api_list]
71
+ self.tools = tools
72
+ self.module2api = module2api
73
+ self.use_tool_retriever = use_tool_retriever
74
+
75
+ # Store dictionaries for data lake and library content
76
+ self.data_lake_dict = data_lake_dict
77
+ self.library_content_dict = library_content_dict
78
+
79
+ if self.use_tool_retriever:
80
+ self.tool_registry = ToolRegistry(module2api)
81
+ self.retriever = ToolRetriever()
82
+
83
+ self.timeout_seconds = timeout_seconds # 10 minutes default timeout
84
+
85
+ # When wrapping tools with timeout
86
+ self.tools = self._add_timeout_to_tools(self.tools)
87
+ self.prompt = ""
88
+ self.system_prompt = ""
89
+
90
+ def _add_timeout_to_tools(self, tools):
91
+ """Apply timeout wrapper to all tool functions using multiprocessing."""
92
+
93
+ def create_timed_func(original_func, timeout):
94
+ """Factory function that creates a unique timed function for each tool."""
95
+ tool_name = getattr(original_func, "__name__", "unknown")
96
+ # print(f"Applying timeout wrapper to tool: {tool_name}")
97
+
98
+ def process_func(func, args, kwargs, result_queue):
99
+ """Function to run in a separate process."""
100
+ try:
101
+ result = func(*args, **kwargs)
102
+ result_queue.put(("success", result))
103
+ except Exception as e:
104
+ result_queue.put(("error", str(e)))
105
+
106
+ @wraps(original_func)
107
+ def timed_func(*args, **kwargs):
108
+ # print(f"Executing tool with timeout: {tool_name}")
109
+ result_queue = Queue()
110
+
111
+ # Start a separate process
112
+ proc = Process(
113
+ target=process_func,
114
+ args=(original_func, args, kwargs, result_queue),
115
+ )
116
+ proc.start()
117
+
118
+ # Wait for the specified timeout
119
+ proc.join(timeout)
120
+
121
+ # Check if the process is still running after timeout
122
+ if proc.is_alive():
123
+ print(f"TIMEOUT: Tool {tool_name} execution timed out after {timeout} seconds")
124
+ # Force terminate the process
125
+ proc.terminate()
126
+ proc.join(1) # Give it a second to terminate
127
+
128
+ # If it's still not dead, kill it with more force
129
+ if proc.is_alive():
130
+ os.kill(proc.pid, signal.SIGKILL)
131
+
132
+ return f"ERROR: Tool execution timed out after {timeout} seconds. Please try with simpler inputs or break your task into smaller steps."
133
+
134
+ # Get the result from the queue
135
+ if not result_queue.empty():
136
+ status, result = result_queue.get()
137
+ if status == "success":
138
+ return result
139
+ else:
140
+ return f"Error in tool execution: {result}"
141
+
142
+ return "Error: Tool execution completed but no result was returned"
143
+
144
+ return timed_func
145
+
146
+ wrapped_tools = []
147
+ for tool in tools:
148
+ wrapped_tool = tool
149
+ wrapped_tool.func = create_timed_func(tool.func, self.timeout_seconds)
150
+ wrapped_tools.append(wrapped_tool)
151
+
152
+ return wrapped_tools
153
+
154
+ def add_tool(self, api):
155
+ function_code = inspect.getsource(api)
156
+ schema = function_to_api_schema(function_code, self.llm)
157
+ new_tool = api_schema_to_langchain_tool(schema, mode="custom_tool", module_name=api.__module__)
158
+
159
+ # Create a single wrapped tool using the existing _add_timeout_to_tools method
160
+ wrapped_tools = self._add_timeout_to_tools([new_tool])
161
+
162
+ # Get the wrapped tool and add it to our tools list
163
+ if wrapped_tools:
164
+ self.tools.append(wrapped_tools[0])
165
+
166
+ def configure(
167
+ self,
168
+ plan=False,
169
+ reflect=False,
170
+ data_lake=False,
171
+ react_code_search=False,
172
+ library_access=False,
173
+ ):
174
+ data_lake_path = self.path + "/data_lake"
175
+ data_lake_content = glob.glob(data_lake_path + "/*")
176
+ data_lake_items = [x.split("/")[-1] for x in data_lake_content]
177
+
178
+ if react_code_search:
179
+ tools = [i for i in self.tools if i.name in ["run_python_repl", "search_google"]]
180
+
181
+ prompt_modifier = """
182
+ You are a helpful biomedical assistant assigned with the task of problem-solving.
183
+
184
+ You have access to two tools:
185
+ 1) run_python_repl: to write and run python code
186
+ 2) search_google: to search google for information
187
+
188
+ You can use them to solve the problem.
189
+ """
190
+ else:
191
+ tools = self.tools
192
+ if (not plan) and (not reflect):
193
+ prompt_modifier = """You are a helpful biologist and expert geneticist.
194
+ """
195
+ elif plan and (not reflect):
196
+ prompt_modifier = """You are a helpful biologist and expert geneticist.
197
+ Given the question from the user,
198
+ - First, come up with a high level plan based on your understanding of the problem and available tools and record it in the Research Plan and Status. You can revise the plan later.
199
+ - Research Plan and Status should well organized and succinctly keep track of 1) high level plan (can be revised), 2) what steps have been done and what steps are in progress, 3) short results and conclusions of each step after it has been performed. Do not perform action in research plan.
200
+ - Research Plan and Status must only include progress that has been made by previous steps. It should not include results not directly confirmed by the previous observation.
201
+ - Follow the plan and try to achieve the goal as straightforwardly as possible. Use tools as necessary.
202
+ """
203
+ elif (not plan) and reflect:
204
+ prompt_modifier = """You are a helpful biologist and expert geneticist.
205
+ In each round after the tool is used, conduct "reflection" step: reflect on the current state of the problem and the results of the last round. What does the observation mean? If there is an error, what caused the error and how to debug?
206
+ """
207
+ else:
208
+ prompt_modifier = """You are a helpful biologist and expert geneticist.
209
+ Given the question from the user,
210
+ - First, come up with a high level plan based on your understanding of the problem and available tools and record it in the Research Plan and Status. You can revise the plan later.
211
+ - Research Plan and Status should well organized and succinctly keep track of 1) high level plan (can be revised), 2) what steps have been done and what steps are in progress, 3) short results and conclusions of each step after it has been performed. Do not perform action in research plan.
212
+ - Research Plan and Status must only include progress that has been made by previous steps. It should not include results not directly confirmed by the previous observation.
213
+ - Follow the plan and try to achieve the goal as straightforwardly as possible. Use tools as necessary.
214
+ In each round after the tool is used, conduct "reflection" step: reflect on the current state of the problem and the results of the last round. What does the observation mean? If there is an error, what caused the error and how to debug?
215
+ You have access to write_python_code and run_python_repl tool to write and run your own code if tools fail, or if the given tools are not enough. Please always make sure to write code when dealing with substantial data, including finding the length of long sequences or elements at different positions.
216
+ """
217
+
218
+ if data_lake:
219
+ # Format data lake items with descriptions
220
+ data_lake_formatted = []
221
+ for item in data_lake_items:
222
+ description = data_lake_dict.get(item, f"Data lake item: {item}")
223
+ data_lake_formatted.append(f"{item}: {description}")
224
+
225
+ prompt_modifier += """
226
+ You can also access a biological data lake at the following path: {data_lake_path}. You can use the run_python_repl tool to write code to understand the data, process and utilize it for the task.
227
+ Here is the list of datasets with their descriptions:
228
+ ----
229
+ {data_lake_formatted}
230
+ ----
231
+ """.format(
232
+ data_lake_path=data_lake_path,
233
+ data_lake_formatted="\n".join(data_lake_formatted),
234
+ )
235
+
236
+ if library_access:
237
+ # Format library content with descriptions
238
+ library_formatted = []
239
+ for lib_name, lib_desc in library_content_dict.items():
240
+ library_formatted.append(f"{lib_name}: {lib_desc}")
241
+
242
+ prompt_modifier += """
243
+ You also have access to a list of software packages that can be used to perform various tasks.
244
+ You can use the run_python_repl tool to write code to access and utilize the library for the task.
245
+ Don't forget the import statement.
246
+ Here is the list of available libraries with their descriptions:
247
+ ----
248
+ {library_formatted}
249
+ ----
250
+ """.format(library_formatted="\n".join(library_formatted))
251
+
252
+ print("=" * 25 + "System Prompt" + "=" * 25)
253
+ print(prompt_modifier)
254
+ self.system_prompt = prompt_modifier
255
+ self.prompt = ChatPromptTemplate.from_messages(
256
+ [
257
+ ("system", prompt_modifier),
258
+ MessagesPlaceholder(variable_name="messages"),
259
+ ]
260
+ )
261
+
262
+ # Store the tools for later use
263
+ self.active_tools = tools
264
+
265
+ # Create a custom implementation of the ReAct agent using LangGraph
266
+ self.app = self._create_custom_react_agent(self.llm, tools, self.prompt)
267
+
268
+ def _create_custom_react_agent(self, llm, tools, prompt):
269
+ """Create a custom ReAct agent using LangGraph."""
270
+ # Create a dictionary mapping tool names to tool objects for easy lookup
271
+ tools_by_name = {tool.name: tool for tool in tools}
272
+
273
+ # Bind the tools to the language model
274
+ llm_with_tools = llm.bind_tools(tools)
275
+
276
+ # Define the node that calls the model
277
+ def call_model(state: AgentState, config: RunnableConfig = None):
278
+ """Node that calls the language model to get the next action."""
279
+ system_message = SystemMessage(content=self.system_prompt)
280
+ messages = [system_message] + state["messages"]
281
+ response = llm_with_tools.invoke(messages, config=config)
282
+ return {"messages": [response]}
283
+
284
+ # Define the node that executes tools
285
+ def tool_node(state: AgentState):
286
+ """Node that executes tools based on the LLM's decisions."""
287
+ outputs = []
288
+ for tool_call in state["messages"][-1].tool_calls:
289
+ try:
290
+ tool_result = tools_by_name[tool_call["name"]].invoke(tool_call["args"])
291
+ outputs.append(
292
+ ToolMessage(
293
+ content=json.dumps(tool_result),
294
+ name=tool_call["name"],
295
+ tool_call_id=tool_call["id"],
296
+ )
297
+ )
298
+ except Exception as e:
299
+ # Handle any errors that occur during tool execution
300
+ outputs.append(
301
+ ToolMessage(
302
+ content=json.dumps({"error": str(e)}),
303
+ name=tool_call["name"],
304
+ tool_call_id=tool_call["id"],
305
+ )
306
+ )
307
+ return {"messages": outputs}
308
+
309
+ # Define the conditional edge that determines whether to continue or not
310
+ def should_continue(state: AgentState):
311
+ """Determine if we should continue running the graph or finish."""
312
+ messages = state["messages"]
313
+ last_message = messages[-1]
314
+ # If there is no tool call, then we finish
315
+ if not hasattr(last_message, "tool_calls") or not last_message.tool_calls:
316
+ return "end"
317
+ # Otherwise if there is, we continue
318
+ else:
319
+ return "continue"
320
+
321
+ # Define a new graph
322
+ workflow = StateGraph(AgentState)
323
+
324
+ # Define the two nodes we will cycle between
325
+ workflow.add_node("agent", call_model)
326
+ workflow.add_node("tools", tool_node)
327
+
328
+ # Set the entrypoint as `agent`
329
+ workflow.set_entry_point("agent")
330
+
331
+ # Add conditional edges
332
+ workflow.add_conditional_edges(
333
+ "agent",
334
+ should_continue,
335
+ {
336
+ "continue": "tools",
337
+ "end": END,
338
+ },
339
+ )
340
+
341
+ # Add edge from tools back to agent
342
+ workflow.add_edge("tools", "agent")
343
+
344
+ # Compile the graph
345
+ return workflow.compile()
346
+
347
+ def go(self, prompt):
348
+ """Execute the agent with the given prompt.
349
+
350
+ Args:
351
+ prompt: The user's query
352
+
353
+ """
354
+ if self.use_tool_retriever:
355
+ # Gather all available tools from the registry
356
+ all_tools = self.tool_registry.tools if hasattr(self, "tool_registry") else []
357
+
358
+ # Get data lake items with descriptions
359
+ data_lake_path = self.path + "/data_lake"
360
+ data_lake_content = glob.glob(data_lake_path + "/*")
361
+ data_lake_items = [x.split("/")[-1] for x in data_lake_content]
362
+
363
+ # Create data lake descriptions for retrieval
364
+ data_lake_descriptions = []
365
+ for item in data_lake_items:
366
+ description = self.data_lake_dict.get(item, f"Data lake item: {item}")
367
+ data_lake_descriptions.append({"name": item, "description": description})
368
+
369
+ # Libraries with descriptions
370
+ library_descriptions = []
371
+ for lib_name, lib_desc in self.library_content_dict.items():
372
+ library_descriptions.append({"name": lib_name, "description": lib_desc})
373
+
374
+ # Prepare resources for retrieval
375
+ resources = {
376
+ "tools": all_tools,
377
+ "data_lake": data_lake_descriptions,
378
+ "libraries": library_descriptions,
379
+ }
380
+
381
+ # Use prompt-based retrieval with the agent's LLM
382
+ selected_resources = self.retriever.prompt_based_retrieval(prompt, resources, llm=self.llm)
383
+ print("Using prompt-based retrieval with the agent's LLM")
384
+
385
+ # If we're using prompt or embedding based retrieval, print the selected resources
386
+ print("\nSelected tools:")
387
+ for tool in selected_resources["tools"]:
388
+ if isinstance(tool, dict):
389
+ print(f"- {tool.get('name', 'Unknown')}: {tool.get('description', '')}")
390
+ else:
391
+ print(f"- {getattr(tool, 'name', str(tool))}: {getattr(tool, 'description', '')}")
392
+
393
+ print("\nSelected data lake items:")
394
+ for item in selected_resources["data_lake"]:
395
+ if isinstance(item, dict):
396
+ name = item.get("name", "Unknown")
397
+ description = self.data_lake_dict.get(name, f"Data lake item: {name}")
398
+ print(f"- {name}: {description}")
399
+ elif isinstance(item, str) and ": " in item:
400
+ # If the item already has a description, print it as is
401
+ print(f"- {item}")
402
+ else:
403
+ description = self.data_lake_dict.get(item, f"Data lake item: {item}")
404
+ print(f"- {item}: {description}")
405
+
406
+ print("\nSelected libraries:")
407
+ for lib in selected_resources["libraries"]:
408
+ if isinstance(lib, dict):
409
+ print(f"- {lib.get('name', 'Unknown')}: {lib.get('description', '')}")
410
+ else:
411
+ print(f"- {lib}")
412
+
413
+ # Convert selected tools to langchain tool objects
414
+ tool_names = [
415
+ tool["name"] if isinstance(tool, dict) else getattr(tool, "name", str(tool))
416
+ for tool in selected_resources["tools"]
417
+ ]
418
+ retrieved_list_of_tools = []
419
+
420
+ # Get the tool objects by name
421
+ for tool_name in tool_names:
422
+ # Find the tool in the original tools list
423
+ matching_tools = [t for t in self.tools if getattr(t, "name", None) == tool_name]
424
+ if matching_tools:
425
+ retrieved_list_of_tools.append(matching_tools[0])
426
+
427
+ # Add back coding tool if not already included
428
+ if len([i for i in retrieved_list_of_tools if i.name == "run_python_repl"]) == 0:
429
+ retrieved_list_of_tools = retrieved_list_of_tools + [
430
+ i for i in self.tools if i.name == "run_python_repl"
431
+ ]
432
+
433
+ print("Retrieved tools: \n" + "\n".join([l.name + ": " + l.description for l in retrieved_list_of_tools]))
434
+ # Recreate the custom agent with the retrieved tools
435
+ self.app = self._create_custom_react_agent(self.llm, retrieved_list_of_tools, self.prompt)
436
+
437
+ # Default behavior (no tool retriever or retrieval_method is 'none')
438
+ config = {"recursion_limit": 50}
439
+ inputs = {"messages": [("user", prompt)]}
440
+ self.log = []
441
+ for s in self.app.stream(inputs, stream_mode="values", config=config):
442
+ message = s["messages"][-1]
443
+ out = pretty_print(message)
444
+ self.log.append(out)
445
+ return self.log, s["messages"][-1].content
446
+
447
+ def result_formatting(self, output_class, task_intention):
448
+ self.format_check_prompt = ChatPromptTemplate.from_messages(
449
+ [
450
+ (
451
+ "system",
452
+ (
453
+ "You are evaluateGPT, tasked with extract and parse the task output based on the history of an agent. "
454
+ "Review the entire history of messages provided. "
455
+ "Here is the task output requirement: \n"
456
+ f"'{task_intention.replace('{', '{{').replace('}', '}}')}'.\n"
457
+ ),
458
+ ),
459
+ ("placeholder", "{messages}"),
460
+ ]
461
+ )
462
+
463
+ checker_llm = self.format_check_prompt | self.llm.with_structured_output(output_class)
464
+ result = checker_llm.invoke({"messages": [("user", str(self.log))]}).dict()
465
+ return result
BioScientist/agent_system/engines/v1_executor_backup/config.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Biomni Configuration Management
3
+
4
+ Simple configuration class for centralizing common settings.
5
+ Maintains full backward compatibility with existing code.
6
+ """
7
+
8
+ import os
9
+ from dataclasses import dataclass
10
+
11
+
12
+ @dataclass
13
+ class BiomniConfig:
14
+ """Central configuration for Biomni agent.
15
+
16
+ All settings are optional and have sensible defaults.
17
+ API keys are still read from environment variables to maintain
18
+ compatibility with existing .env file structure.
19
+
20
+ Usage:
21
+ # Create config with defaults
22
+ config = BiomniConfig()
23
+
24
+ # Override specific settings
25
+ config = BiomniConfig(llm="gpt-4", timeout_seconds=1200)
26
+
27
+ # Modify after creation
28
+ config.path = "./custom_data"
29
+ """
30
+
31
+ # Data and execution settings
32
+ path: str = "./data"
33
+ timeout_seconds: int = 600
34
+
35
+ # LLM settings (API keys still from environment)
36
+ llm: str = "claude-sonnet-4-6"
37
+ temperature: float = 0.7
38
+
39
+ # Tool settings
40
+ use_tool_retriever: bool = True
41
+
42
+ # Data licensing settings
43
+ commercial_mode: bool = False # If True, excludes non-commercial datasets
44
+
45
+ # Custom model settings (for custom LLM serving)
46
+ base_url: str | None = None
47
+ api_key: str | None = None # Only for custom models, not provider API keys
48
+
49
+ # LLM source (auto-detected if None)
50
+ source: str | None = None
51
+
52
+ # Third-party integrations
53
+ protocols_io_access_token: str | None = None
54
+
55
+ def __post_init__(self):
56
+ """Load any environment variable overrides if they exist."""
57
+ # Check for environment variable overrides (optional)
58
+ # Support both old and new names for backwards compatibility
59
+ if os.getenv("BIOMNI_PATH") or os.getenv("BIOMNI_DATA_PATH"):
60
+ self.path = os.getenv("BIOMNI_PATH") or os.getenv("BIOMNI_DATA_PATH")
61
+ if os.getenv("BIOMNI_TIMEOUT_SECONDS"):
62
+ self.timeout_seconds = int(os.getenv("BIOMNI_TIMEOUT_SECONDS"))
63
+ if os.getenv("BIOMNI_LLM") or os.getenv("BIOMNI_LLM_MODEL"):
64
+ self.llm = os.getenv("BIOMNI_LLM") or os.getenv("BIOMNI_LLM_MODEL")
65
+ if os.getenv("BIOMNI_USE_TOOL_RETRIEVER"):
66
+ self.use_tool_retriever = os.getenv("BIOMNI_USE_TOOL_RETRIEVER").lower() == "true"
67
+ if os.getenv("BIOMNI_COMMERCIAL_MODE"):
68
+ self.commercial_mode = os.getenv("BIOMNI_COMMERCIAL_MODE").lower() == "true"
69
+ if os.getenv("BIOMNI_TEMPERATURE"):
70
+ self.temperature = float(os.getenv("BIOMNI_TEMPERATURE"))
71
+ if os.getenv("BIOMNI_CUSTOM_BASE_URL"):
72
+ self.base_url = os.getenv("BIOMNI_CUSTOM_BASE_URL")
73
+ if os.getenv("BIOMNI_CUSTOM_API_KEY"):
74
+ self.api_key = os.getenv("BIOMNI_CUSTOM_API_KEY")
75
+ if os.getenv("BIOMNI_SOURCE"):
76
+ self.source = os.getenv("BIOMNI_SOURCE")
77
+
78
+ # Protocols.io access token (prefer specific env vars)
79
+ env_token = os.getenv("PROTOCOLS_IO_ACCESS_TOKEN") or os.getenv("BIOMNI_PROTOCOLS_IO_ACCESS_TOKEN")
80
+ if env_token:
81
+ self.protocols_io_access_token = env_token
82
+
83
+ def to_dict(self) -> dict:
84
+ """Convert config to dictionary for easy access."""
85
+ return {
86
+ "path": self.path,
87
+ "timeout_seconds": self.timeout_seconds,
88
+ "llm": self.llm,
89
+ "temperature": self.temperature,
90
+ "use_tool_retriever": self.use_tool_retriever,
91
+ "commercial_mode": self.commercial_mode,
92
+ "base_url": self.base_url,
93
+ "api_key": self.api_key,
94
+ "source": self.source,
95
+ }
96
+
97
+
98
+ # Global default config instance (optional, for convenience)
99
+ default_config = BiomniConfig()
BioScientist/agent_system/engines/v1_executor_backup/env_desc.py ADDED
@@ -0,0 +1,221 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data lake dictionary with detailed descriptions
2
+ data_lake_dict = {
3
+ "affinity_capture-ms.parquet": "Protein-protein interactions detected via affinity capture and mass spectrometry.",
4
+ "affinity_capture-rna.parquet": "Protein-RNA interactions detected by affinity capture.",
5
+ "BindingDB_All_202409.tsv": "Measured binding affinities between proteins and small molecules for drug discovery.",
6
+ "broad_repurposing_hub_molecule_with_smiles.parquet": "Molecules from Broad Institute's Drug Repurposing Hub with SMILES annotations.",
7
+ "broad_repurposing_hub_phase_moa_target_info.parquet": "Drug phases, mechanisms of action, and target information from Broad Institute.",
8
+ "co-fractionation.parquet": "Protein-protein interactions from co-fractionation experiments.",
9
+ "czi_census_datasets_v4.parquet": "Datasets from the Chan Zuckerberg Initiative's Cell Census.",
10
+ "DepMap_CRISPRGeneDependency.csv": "Gene dependency probability estimates for cancer cell lines, including all DepMap models.",
11
+ "DepMap_CRISPRGeneEffect.csv": "Genome-wide CRISPR gene effect estimates for cancer cell lines, including all DepMap models.",
12
+ "DepMap_Model.csv": "Metadata describing all cancer models/cell lines which are referenced by a dataset contained within the DepMap portal.",
13
+ "DepMap_OmicsExpressionProteinCodingGenesTPMLogp1.csv": "Gene expression in TPMs for cancer cell lines, including all DepMap models.",
14
+ "ddinter_alimentary_tract_metabolism.csv": "Drug-drug interactions for alimentary tract and metabolism drugs from DDInter 2.0 database.",
15
+ "ddinter_antineoplastic.csv": "Drug-drug interactions for antineoplastic and immunomodulating agents from DDInter 2.0 database.",
16
+ "ddinter_antiparasitic.csv": "Drug-drug interactions for antiparasitic products from DDInter 2.0 database.",
17
+ "ddinter_blood_organs.csv": "Drug-drug interactions for blood and blood forming organs drugs from DDInter 2.0 database.",
18
+ "ddinter_dermatological.csv": "Drug-drug interactions for dermatological drugs from DDInter 2.0 database.",
19
+ "ddinter_hormonal.csv": "Drug-drug interactions for systemic hormonal preparations from DDInter 2.0 database.",
20
+ "ddinter_respiratory.csv": "Drug-drug interactions for respiratory system drugs from DDInter 2.0 database.",
21
+ "ddinter_various.csv": "Drug-drug interactions for various drugs from DDInter 2.0 database.",
22
+ "DisGeNET.parquet": "Gene-disease associations from multiple sources.",
23
+ "dosage_growth_defect.parquet": "Gene dosage changes affecting growth.",
24
+ "enamine_cloud_library_smiles.pkl": "Compounds from Enamine REAL library with SMILES annotations.",
25
+ "evebio_assay_table.csv": "Assay metadata with one row per assay from EveBio pharmome mapping.",
26
+ "evebio_bundle_table.csv": "Target subfamily bundles used for screening-to-profiling progression.",
27
+ "evebio_compound_table.csv": "Compound metadata with common identifiers from EveBio screening.",
28
+ "evebio_control_table.csv": "Control datapoints for all screening and profiling plates.",
29
+ "evebio_detailed_result_table.csv": "Expanded results on evebio_summary_result_table with curve fit parameters and phase categories.",
30
+ "evebio_observed_points_table.csv": "Raw observed datapoints from all screening and profiling experiments.",
31
+ "evebio_summary_result_table.csv": "Succinct summary of results for each assay-compound combination.",
32
+ "evebio_target_table.csv": "Target metadata with common identifiers from EveBio screening.",
33
+ "genebass_missense_LC_filtered.pkl": "Filtered missense variants from GeneBass.",
34
+ "genebass_pLoF_filtered.pkl": "Predicted loss-of-function variants from GeneBass.",
35
+ "genebass_synonymous_filtered.pkl": "Filtered synonymous variants from GeneBass.",
36
+ "gene_info.parquet": "Comprehensive gene information.",
37
+ "genetic_interaction.parquet": "Genetic interactions between genes.",
38
+ "go-plus.json": "Gene ontology data for functional gene annotations.",
39
+ "gtex_tissue_gene_tpm.parquet": "Gene expression (TPM) across human tissues from GTEx.",
40
+ "gwas_catalog.pkl": "Genome-wide association studies (GWAS) results.",
41
+ "hp.obo": "Official HPO release in obographs format",
42
+ "kg.csv": "Precision medicine knowledge graph with 17,080 diseases and 4+ million relationships across biological scales.",
43
+ "marker_celltype.parquet": "Cell type marker genes for identification.",
44
+ "McPAS-TCR.parquet": "T-cell receptor sequences and specificity data from McPAS database.",
45
+ "miRDB_v6.0_results.parquet": "Predicted microRNA targets from miRDB.",
46
+ "miRTarBase_microRNA_target_interaction.parquet": "Experimentally validated microRNA-target interactions from miRTarBase.",
47
+ "miRTarBase_microRNA_target_interaction_pubmed_abtract.txt": "PubMed abstracts for microRNA-target interactions in miRTarBase.",
48
+ "miRTarBase_MicroRNA_Target_Sites.parquet": "Binding sites of microRNAs on target genes from miRTarBase.",
49
+ "mousemine_m1_positional_geneset.parquet": "Positional gene sets from MouseMine.",
50
+ "mousemine_m2_curated_geneset.parquet": "Curated gene sets from MouseMine.",
51
+ "mousemine_m3_regulatory_target_geneset.parquet": "Regulatory target gene sets from MouseMine.",
52
+ "mousemine_m5_ontology_geneset.parquet": "Ontology-based gene sets from MouseMine.",
53
+ "mousemine_m8_celltype_signature_geneset.parquet": "Cell type signature gene sets from MouseMine.",
54
+ "mousemine_mh_hallmark_geneset.parquet": "Hallmark gene sets from MouseMine.",
55
+ "msigdb_human_c1_positional_geneset.parquet": "Human positional gene sets from MSigDB.",
56
+ "msigdb_human_c2_curated_geneset.parquet": "Curated human gene sets from MSigDB.",
57
+ "msigdb_human_c3_regulatory_target_geneset.parquet": "Regulatory target gene sets from MSigDB.",
58
+ "msigdb_human_c3_subset_transcription_factor_targets_from_GTRD.parquet": "Transcription factor targets from GTRD/MSigDB.",
59
+ "msigdb_human_c4_computational_geneset.parquet": "Computationally derived gene sets from MSigDB.",
60
+ "msigdb_human_c5_ontology_geneset.parquet": "Ontology-based gene sets from MSigDB.",
61
+ "msigdb_human_c6_oncogenic_signature_geneset.parquet": "Oncogenic signatures from MSigDB.",
62
+ "msigdb_human_c7_immunologic_signature_geneset.parquet": "Immunologic signatures from MSigDB.",
63
+ "msigdb_human_c8_celltype_signature_geneset.parquet": "Cell type signatures from MSigDB.",
64
+ "msigdb_human_h_hallmark_geneset.parquet": "Hallmark gene sets from MSigDB.",
65
+ "omim.parquet": "Genetic disorders and associated genes from OMIM.",
66
+ "proteinatlas.tsv": "Protein expression data from Human Protein Atlas.",
67
+ "proximity_label-ms.parquet": "Protein interactions via proximity labeling and mass spectrometry.",
68
+ "reconstituted_complex.parquet": "Protein complexes reconstituted in vitro.",
69
+ "sgRNA_KO_SP_mouse.txt": "sgRNA knockout data for mouse.",
70
+ "sgRNA_KO_SP_human.txt": "sgRNA knockout data for human.",
71
+ "synthetic_growth_defect.parquet": "Synthetic growth defects from genetic interactions.",
72
+ "synthetic_lethality.parquet": "Synthetic lethal interactions.",
73
+ "synthetic_rescue.parquet": "Genetic interactions rescuing phenotypes.",
74
+ "two-hybrid.parquet": "Protein-protein interactions detected by yeast two-hybrid assays.",
75
+ "variant_table.parquet": "Annotated genetic variants table.",
76
+ "Virus-Host_PPI_P-HIPSTER_2020.parquet": "Virus-host protein-protein interactions from P-HIPSTER.",
77
+ "txgnn_name_mapping.pkl": "Name mapping for TXGNN.",
78
+ "txgnn_prediction.pkl": "Prediction data for TXGNN.",
79
+ }
80
+
81
+ # Updated library_content as a dictionary with detailed descriptions
82
+ library_content_dict = {
83
+ # === PYTHON PACKAGES ===
84
+ # Core Bioinformatics Libraries (Python)
85
+ "biopython": "[Python Package] A set of tools for biological computation including parsers for bioinformatics files, access to online services, and interfaces to common bioinformatics programs.",
86
+ "biom-format": "[Python Package] The Biological Observation Matrix (BIOM) format is designed for representing biological sample by observation contingency tables with associated metadata.",
87
+ "scanpy": "[Python Package] A scalable toolkit for analyzing single-cell gene expression data, specifically designed for large datasets using AnnData.",
88
+ "scikit-bio": "[Python Package] Data structures, algorithms, and educational resources for bioinformatics, including sequence analysis, phylogenetics, and ordination methods.",
89
+ "anndata": "[Python Package] A Python package for handling annotated data matrices in memory and on disk, primarily used for single-cell genomics data.",
90
+ "mudata": "[Python Package] A Python package for multimodal data storage and manipulation, extending AnnData to handle multiple modalities.",
91
+ "pyliftover": "[Python Package] A Python implementation of UCSC liftOver tool for converting genomic coordinates between genome assemblies.",
92
+ "biopandas": "[Python Package] A package that provides pandas DataFrames for working with molecular structures and biological data.",
93
+ "biotite": "[Python Package] A comprehensive library for computational molecular biology, providing tools for sequence analysis, structure analysis, and more.",
94
+ "lazyslide": "[Python Package] A Python framework that brings interoperable, reproducible whole slide image analysis, enabling seamless histopathology workflows from preprocessing to deep learning.",
95
+ # Genomics & Variant Analysis (Python)
96
+ "gget": "[Python Package] A toolkit for accessing genomic databases and retrieving sequences, annotations, and other genomic data.",
97
+ "lifelines": "[Python Package] A complete survival analysis library for fitting models, plotting, and statistical tests.",
98
+ # "scvi-tools": "[Python Package] A package for probabilistic modeling of single-cell omics data, including deep generative models.",
99
+ "gseapy": "[Python Package] A Python wrapper for Gene Set Enrichment Analysis (GSEA) and visualization.",
100
+ "scrublet": "[Python Package] A tool for detecting doublets in single-cell RNA-seq data.",
101
+ "cellxgene-census": "[Python Package] A tool for accessing and analyzing the CellxGene Census, a collection of single-cell datasets. To download a dataset, use the download_source_h5ad function with the dataset id as the argument (856c1b98-5727-49da-bf0f-151bdb8cb056, no .h5ad extension).",
102
+ "hyperopt": "[Python Package] A Python library for optimizing hyperparameters of machine learning algorithms.",
103
+ "scvelo": "[Python Package] A tool for RNA velocity analysis in single cells using dynamical models.",
104
+ "pysam": "[Python Package] A Python module for reading, manipulating and writing genomic data sets in SAM/BAM/VCF/BCF formats.",
105
+ "pyfaidx": "[Python Package] A Python package for efficient random access to FASTA files.",
106
+ "pyranges": "[Python Package] A Python package for interval manipulation with a pandas-like interface.",
107
+ "pybedtools": "[Python Package] A Python wrapper for Aaron Quinlan's BEDTools programs.",
108
+ # "panhumanpy": "A Python package for hierarchical, cross-tissue cell type annotation of human single-cell RNA-seq data",
109
+ # Structural Biology & Drug Discovery (Python)
110
+ "rdkit": "[Python Package] A collection of cheminformatics and machine learning tools for working with chemical structures and drug discovery.",
111
+ "deeppurpose": "[Python Package] A deep learning library for drug-target interaction prediction and virtual screening.",
112
+ "pyscreener": "[Python Package] A Python package for virtual screening of chemical compounds.",
113
+ "openbabel": "[Python Package] A chemical toolbox designed to speak the many languages of chemical data, supporting file format conversion and molecular modeling.",
114
+ "descriptastorus": "[Python Package] A library for computing molecular descriptors for machine learning applications in drug discovery.",
115
+ # "pymol": "[Python Package] A molecular visualization system for rendering and animating 3D molecular structures.",
116
+ "openmm": "[Python Package] A toolkit for molecular simulation using high-performance GPU computing.",
117
+ "pytdc": "[Python Package] A Python package for Therapeutics Data Commons, providing access to machine learning datasets for drug discovery.",
118
+ # Data Science & Statistical Analysis (Python)
119
+ "pandas": "[Python Package] A fast, powerful, and flexible data analysis and manipulation library for Python.",
120
+ "numpy": "[Python Package] The fundamental package for scientific computing with Python, providing support for arrays, matrices, and mathematical functions.",
121
+ "scipy": "[Python Package] A Python library for scientific and technical computing, including modules for optimization, linear algebra, integration, and statistics.",
122
+ "scikit-learn": "[Python Package] A machine learning library featuring various classification, regression, and clustering algorithms.",
123
+ "matplotlib": "[Python Package] A comprehensive library for creating static, animated, and interactive visualizations in Python.",
124
+ "seaborn": "[Python Package] A statistical data visualization library based on matplotlib with a high-level interface for drawing attractive statistical graphics.",
125
+ "statsmodels": "[Python Package] A Python module for statistical modeling and econometrics, including descriptive statistics and estimation of statistical models.",
126
+ "pymc3": "[Python Package] A Python package for Bayesian statistical modeling and probabilistic machine learning.",
127
+ # "pystan": "[Python Package] A Python interface to Stan, a platform for statistical modeling and high-performance statistical computation.",
128
+ "umap-learn": "[Python Package] Uniform Manifold Approximation and Projection, a dimension reduction technique.",
129
+ "faiss-cpu": "[Python Package] A library for efficient similarity search and clustering of dense vectors.",
130
+ "harmony-pytorch": "[Python Package] A PyTorch implementation of the Harmony algorithm for integrating single-cell data.",
131
+ # General Bioinformatics & Computational Utilities (Python)
132
+ "tiledb": "[Python Package] A powerful engine for storing and analyzing large-scale genomic data.",
133
+ "tiledbsoma": "[Python Package] A library for working with the SOMA (Stack of Matrices) format using TileDB.",
134
+ "h5py": "[Python Package] A Python interface to the HDF5 binary data format, allowing storage of large amounts of numerical data.",
135
+ "tqdm": "[Python Package] A fast, extensible progress bar for loops and CLI applications.",
136
+ "joblib": "[Python Package] A set of tools to provide lightweight pipelining in Python, including transparent disk-caching and parallel computing.",
137
+ "opencv-python": "[Python Package] OpenCV library for computer vision tasks, useful for image analysis in biological contexts.",
138
+ "PyPDF2": "[Python Package] A library for working with PDF files, useful for extracting text from scientific papers.",
139
+ "googlesearch-python": "[Python Package] A library for performing Google searches programmatically.",
140
+ "scikit-image": "[Python Package] A collection of algorithms for image processing in Python.",
141
+ "pymed": "[Python Package] A Python library for accessing PubMed articles.",
142
+ "arxiv": "[Python Package] A Python wrapper for the arXiv API, allowing access to scientific papers.",
143
+ "scholarly": "[Python Package] A module to retrieve author and publication information from Google Scholar.",
144
+ "cryosparc-tools": "[Python Package] Tools for working with cryoSPARC, a platform for cryo-EM data processing.",
145
+ "mageck": "[Python Package] Analysis of CRISPR screen data.",
146
+ "igraph": "[Python Package] Network analysis and visualization.",
147
+ "pyscenic": "[Python Package] Analysis of single-cell RNA-seq data and gene regulatory networks.",
148
+ "cooler": "[Python Package] Storage and analysis of Hi-C data.",
149
+ "trackpy": "[Python Package] Particle tracking in images and video.",
150
+ "nnunet": "[Python Package] A deep learning framework for biomedical image segmentation, providing a standardized approach to training and inference.",
151
+ # "flowcytometrytools": "[Python Package] Analysis and visualization of flow cytometry data.",
152
+ "cellpose": "[Python Package] Cell segmentation in microscopy images.",
153
+ "viennarna": "[Python Package] RNA secondary structure prediction.",
154
+ "PyMassSpec": "[Python Package] Mass spectrometry data analysis.",
155
+ "python-libsbml": "[Python Package] Working with SBML files for computational biology.",
156
+ "cobra": "[Python Package] Constraint-based modeling of metabolic networks.",
157
+ "reportlab": "[Python Package] Creation of PDF documents.",
158
+ "flowkit": "[Python Package] Toolkit for processing flow cytometry data.",
159
+ "hmmlearn": "[Python Package] Hidden Markov model analysis.",
160
+ "msprime": "[Python Package] Simulation of genetic variation.",
161
+ "tskit": "[Python Package] Handling tree sequences and population genetics data.",
162
+ "cyvcf2": "[Python Package] Fast parsing of VCF files.",
163
+ "pykalman": "[Python Package] Kalman filter and smoother implementation.",
164
+ "fanc": "[Python Package] Analysis of chromatin conformation data.",
165
+ "loompy": "A Python implementation of the Loom file format for efficiently storing and working with large omics datasets.",
166
+ "pyBigWig": "A Python library for accessing bigWig and bigBed files for genome browser track data.",
167
+ "pymzml": "A Python module for high-throughput bioinformatics analysis of mass spectrometry data.",
168
+ "optlang": "A Python package for modeling optimization problems symbolically.",
169
+ "FlowIO": "A Python package for reading and writing flow cytometry data files.",
170
+ "FlowUtils": "Utilities for processing and analyzing flow cytometry data.",
171
+ "arboreto": "A Python package for inferring gene regulatory networks from single-cell RNA-seq data.",
172
+ "pdbfixer": "A Python package for fixing problems in PDB files in preparation for molecular simulations.",
173
+ # === R PACKAGES ===
174
+ # Core R Packages for Data Analysis
175
+ "ggplot2": "[R Package] A system for declaratively creating graphics, based on The Grammar of Graphics. Use with subprocess.run(['Rscript', '-e', 'library(ggplot2); ...']).",
176
+ "dplyr": "[R Package] A grammar of data manipulation, providing a consistent set of verbs that help you solve the most common data manipulation challenges. Use with subprocess.",
177
+ "tidyr": "[R Package] A package that helps you create tidy data, where each column is a variable, each row is an observation, and each cell is a single value. Use with subprocess.",
178
+ "readr": "[R Package] A fast and friendly way to read rectangular data like CSV, TSV, and FWF. Use with subprocess.run(['Rscript', '-e', 'library(readr); ...']).",
179
+ "stringr": "[R Package] A cohesive set of functions designed to make working with strings as easy as possible. Use with subprocess calls.",
180
+ "Matrix": "[R Package] A package that provides classes and methods for dense and sparse matrices. Required for Seurat. Use with subprocess calls.",
181
+ # "Rcpp": "[R Package] Seamless R and C++ Integration, allowing R functions to call compiled C++ code. Use with subprocess calls.",
182
+ # "devtools": "[R Package] Tools to make developing R packages easier, including functions to install packages from GitHub. Use with subprocess calls.",
183
+ # "remotes": "[R Package] Install R packages from GitHub, GitLab, Bitbucket, or other remote repositories. Use with subprocess calls.",
184
+ # Bioinformatics R Packages
185
+ "DESeq2": "[R Package] Differential gene expression analysis based on the negative binomial distribution. Use with subprocess.run(['Rscript', '-e', 'library(DESeq2); ...']).",
186
+ "clusterProfiler": "[R Package] A package for statistical analysis and visualization of functional profiles for genes and gene clusters. Use with subprocess calls.",
187
+ # "DADA2": "[R Package] A package for modeling and correcting Illumina-sequenced amplicon errors. Use with subprocess calls.",
188
+ # "xcms": "[R Package] A package for processing and visualization of LC-MS and GC-MS data. Use with subprocess calls.",
189
+ # "FlowCore": "[R Package] Basic infrastructure for flow cytometry data. Use with subprocess calls.",
190
+ "edgeR": "[R Package] Empirical Analysis of Digital Gene Expression Data in R, for differential expression analysis. Use with subprocess calls.",
191
+ "limma": "[R Package] Linear Models for Microarray Data, for differential expression analysis. Use with subprocess calls.",
192
+ "harmony": "[R Package] A method for integrating and analyzing single-cell data across datasets. Use with subprocess calls.",
193
+ "WGCNA": "[R Package] Weighted Correlation Network Analysis for studying biological networks. Use with subprocess calls.",
194
+ # === CLI TOOLS ===
195
+ # Sequence Analysis Tools
196
+ "samtools": "[CLI Tool] A suite of programs for interacting with high-throughput sequencing data. Use with subprocess.run(['samtools', ...]).",
197
+ "bowtie2": "[CLI Tool] An ultrafast and memory-efficient tool for aligning sequencing reads to long reference sequences. Use with subprocess.run(['bowtie2', ...]).",
198
+ "bwa": "[CLI Tool] Burrows-Wheeler Aligner for mapping low-divergent sequences against a large reference genome. Use with subprocess.run(['bwa', ...]).",
199
+ "bedtools": "[CLI Tool] A powerful toolset for genome arithmetic, allowing operations like intersect, merge, count, and complement on genomic features. Use with subprocess.run(['bedtools', ...]).",
200
+ "macs2": "[CLI Tool] Model-based Analysis of ChIP-Seq data, a tool for identifying transcript factor binding sites.",
201
+ # Quality Control and Processing Tools
202
+ "fastqc": "[CLI Tool] A quality control tool for high throughput sequence data. Use with subprocess.run(['fastqc', ...]).",
203
+ "trimmomatic": "[CLI Tool] A flexible read trimming tool for Illumina NGS data. Use with subprocess.run(['trimmomatic', ...]).",
204
+ # Multiple Sequence Alignment and Phylogenetics
205
+ "mafft": "[CLI Tool] A multiple sequence alignment program for unix-like operating systems. Use with subprocess.run(['mafft', ...]).",
206
+ "Homer": "[CLI Tool] Motif discovery and next-gen sequencing analysis.",
207
+ "FastTree": "[CLI Tool] Phylogenetic trees from sequence alignments.",
208
+ "muscle": "[CLI Tool] Multiple sequence alignment tool.",
209
+ # Genetic Analysis Tools
210
+ "plink": "[CLI Tool] A comprehensive toolkit for genome association studies that can perform a range of large-scale analyses in a computationally efficient manner. Use with subprocess.run(['plink', ...]).",
211
+ "plink2": "[CLI Tool] A comprehensive toolkit for genome association studies that can perform a range of large-scale analyses in a computationally efficient manner. Use with subprocess.run(['plink2', ...]).",
212
+ "gcta64": "[CLI Tool] Genome-wide Complex Trait Analysis (GCTA) tool for estimating the proportion of phenotypic variance explained by genome-wide SNPs and analyzing genetic relationships. Use with subprocess.run(['gcta64', ...]).",
213
+ "iqtree2": "[CLI Tool] An efficient phylogenetic software for maximum likelihood analysis with built-in model selection and ultrafast bootstrap. Use with subprocess.run(['iqtree2', ...]).",
214
+ "ADFR": "AutoDock for Receptors suite for molecular docking and virtual screening. ",
215
+ "diamond": "A sequence aligner for protein and translated DNA searches, designed for high performance analysis of big sequence data. ",
216
+ "fcsparser": "A command-line tool for parsing and analyzing flow cytometry standard (FCS) files. ",
217
+ "plannotate": "[CLI Tool] A tool for annotating plasmid sequences with common features. ",
218
+ "vina": "[CLI Tool] An open-source program for molecular docking and virtual screening, known for its speed and accuracy improvements over AutoDock 4.",
219
+ "autosite": "[CLI Tool] A binding site detection tool used to identify potential ligand binding pockets on protein structures for molecular docking.",
220
+ "PyLabRobot": "[Python Package] A Python package for controlling liquid-handling robots and other lab automation equipment.",
221
+ }
BioScientist/agent_system/engines/v1_executor_backup/env_desc_cm.py ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Data lake dictionary with detailed descriptions (Commercial Mode - Non-commercial datasets commented out)
2
+ data_lake_dict = {
3
+ "affinity_capture-ms.parquet": "Protein-protein interactions detected via affinity capture and mass spectrometry.",
4
+ "affinity_capture-rna.parquet": "Protein-RNA interactions detected by affinity capture.",
5
+ # "BindingDB_All_202409.tsv": "Measured binding affinities between proteins and small molecules for drug discovery.", # Requires commercial license
6
+ "broad_repurposing_hub_molecule_with_smiles.parquet": "Molecules from Broad Institute's Drug Repurposing Hub with SMILES annotations.",
7
+ "broad_repurposing_hub_phase_moa_target_info.parquet": "Drug phases, mechanisms of action, and target information from Broad Institute.",
8
+ "co-fractionation.parquet": "Protein-protein interactions from co-fractionation experiments.",
9
+ "czi_census_datasets_v4.parquet": "Datasets from the Chan Zuckerberg Initiative's Cell Census.",
10
+ "DepMap_CRISPRGeneDependency.csv": "Gene dependency probability estimates for cancer cell lines, including all DepMap models.",
11
+ "DepMap_CRISPRGeneEffect.csv": "Genome-wide CRISPR gene effect estimates for cancer cell lines, including all DepMap models.",
12
+ "DepMap_Model.csv": "Metadata describing all cancer models/cell lines which are referenced by a dataset contained within the DepMap portal.",
13
+ "DepMap_OmicsExpressionProteinCodingGenesTPMLogp1.csv": "Gene expression in TPMs for cancer cell lines, including all DepMap models.",
14
+ # "ddinter_alimentary_tract_metabolism.csv": "Drug-drug interactions for alimentary tract and metabolism drugs from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
15
+ # "ddinter_antineoplastic.csv": "Drug-drug interactions for antineoplastic and immunomodulating agents from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
16
+ # "ddinter_antiparasitic.csv": "Drug-drug interactions for antiparasitic products from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
17
+ # "ddinter_blood_organs.csv": "Drug-drug interactions for blood and blood forming organs drugs from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
18
+ # "ddinter_dermatological.csv": "Drug-drug interactions for dermatological drugs from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
19
+ # "ddinter_hormonal.csv": "Drug-drug interactions for systemic hormonal preparations from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
20
+ # "ddinter_respiratory.csv": "Drug-drug interactions for respiratory system drugs from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
21
+ # "ddinter_various.csv": "Drug-drug interactions for various drugs from DDInter 2.0 database.", # CC BY-NC-SA 4.0 - Non-commercial only
22
+ # "DisGeNET.parquet": "Gene-disease associations from multiple sources.", # CC BY-NC-SA 4.0 - Non-commercial only
23
+ "dosage_growth_defect.parquet": "Gene dosage changes affecting growth.",
24
+ # "enamine_cloud_library_smiles.pkl": "Compounds from Enamine REAL library with SMILES annotations.", # Proprietary - Requires license
25
+ # "evebio_assay_table.csv": "Assay metadata with one row per assay from EveBio pharmome mapping.", # Proprietary - Requires permission
26
+ # "evebio_bundle_table.csv": "Target subfamily bundles used for screening-to-profiling progression.", # Proprietary - Requires permission
27
+ # "evebio_compound_table.csv": "Compound metadata with common identifiers from EveBio screening.", # Proprietary - Requires permission
28
+ # "evebio_control_table.csv": "Control datapoints for all screening and profiling plates.", # Proprietary - Requires permission
29
+ # "evebio_detailed_result_table.csv": "Expanded results on evebio_summary_result_table with curve fit parameters and phase categories.", # Proprietary - Requires permission
30
+ # "evebio_observed_points_table.csv": "Raw observed datapoints from all screening and profiling experiments.", # Proprietary - Requires permission
31
+ # "evebio_summary_result_table.csv": "Succinct summary of results for each assay-compound combination.", # Proprietary - Requires permission
32
+ # "evebio_target_table.csv": "Target metadata with common identifiers from EveBio screening.", # Proprietary - Requires permission
33
+ "genebass_missense_LC_filtered.pkl": "Filtered missense variants from GeneBass.",
34
+ "genebass_pLoF_filtered.pkl": "Predicted loss-of-function variants from GeneBass.",
35
+ "genebass_synonymous_filtered.pkl": "Filtered synonymous variants from GeneBass.",
36
+ "gene_info.parquet": "Comprehensive gene information.",
37
+ "genetic_interaction.parquet": "Genetic interactions between genes.",
38
+ "go-plus.json": "Gene ontology data for functional gene annotations.",
39
+ "gtex_tissue_gene_tpm.parquet": "Gene expression (TPM) across human tissues from GTEx.",
40
+ "gwas_catalog.pkl": "Genome-wide association studies (GWAS) results.",
41
+ "hp.obo": "Official HPO release in obographs format",
42
+ "kg.csv": "Precision medicine knowledge graph with 17,080 diseases and 4+ million relationships across biological scales.",
43
+ "marker_celltype.parquet": "Cell type marker genes for identification.",
44
+ # "McPAS-TCR.parquet": "T-cell receptor sequences and specificity data from McPAS database.", # CC BY-NC-SA 4.0 - Non-commercial only
45
+ # "miRDB_v6.0_results.parquet": "Predicted microRNA targets from miRDB.", # Non-commercial use only
46
+ # "miRTarBase_microRNA_target_interaction.parquet": "Experimentally validated microRNA-target interactions from miRTarBase.", # CC BY-NC 4.0 - Non-commercial only
47
+ # "miRTarBase_microRNA_target_interaction_pubmed_abtract.txt": "PubMed abstracts for microRNA-target interactions in miRTarBase.", # CC BY-NC 4.0 - Non-commercial only
48
+ # "miRTarBase_MicroRNA_Target_Sites.parquet": "Binding sites of microRNAs on target genes from miRTarBase.", # CC BY-NC 4.0 - Non-commercial only
49
+ "mousemine_m1_positional_geneset.parquet": "Positional gene sets from MouseMine.",
50
+ "mousemine_m2_curated_geneset.parquet": "Curated gene sets from MouseMine.",
51
+ "mousemine_m3_regulatory_target_geneset.parquet": "Regulatory target gene sets from MouseMine.",
52
+ "mousemine_m5_ontology_geneset.parquet": "Ontology-based gene sets from MouseMine.",
53
+ "mousemine_m8_celltype_signature_geneset.parquet": "Cell type signature gene sets from MouseMine.",
54
+ "mousemine_mh_hallmark_geneset.parquet": "Hallmark gene sets from MouseMine.",
55
+ # "msigdb_human_c1_positional_geneset.parquet": "Human positional gene sets from MSigDB.", # Requires commercial license
56
+ # "msigdb_human_c2_curated_geneset.parquet": "Curated human gene sets from MSigDB.", # Requires commercial license
57
+ # "msigdb_human_c3_regulatory_target_geneset.parquet": "Regulatory target gene sets from MSigDB.", # Requires commercial license
58
+ # "msigdb_human_c3_subset_transcription_factor_targets_from_GTRD.parquet": "Transcription factor targets from GTRD/MSigDB.", # Requires commercial license
59
+ # "msigdb_human_c4_computational_geneset.parquet": "Computationally derived gene sets from MSigDB.", # Requires commercial license
60
+ # "msigdb_human_c5_ontology_geneset.parquet": "Ontology-based gene sets from MSigDB.", # Requires commercial license
61
+ # "msigdb_human_c6_oncogenic_signature_geneset.parquet": "Oncogenic signatures from MSigDB.", # Requires commercial license
62
+ # "msigdb_human_c7_immunologic_signature_geneset.parquet": "Immunologic signatures from MSigDB.", # Requires commercial license
63
+ # "msigdb_human_c8_celltype_signature_geneset.parquet": "Cell type signatures from MSigDB.", # Requires commercial license
64
+ # "msigdb_human_h_hallmark_geneset.parquet": "Hallmark gene sets from MSigDB.", # Requires commercial license
65
+ # "omim.parquet": "Genetic disorders and associated genes from OMIM.", # Requires commercial license
66
+ "proteinatlas.tsv": "Protein expression data from Human Protein Atlas.",
67
+ "proximity_label-ms.parquet": "Protein interactions via proximity labeling and mass spectrometry.",
68
+ "reconstituted_complex.parquet": "Protein complexes reconstituted in vitro.",
69
+ "sgRNA_KO_SP_mouse.txt": "sgRNA knockout data for mouse.",
70
+ "sgRNA_KO_SP_human.txt": "sgRNA knockout data for human.",
71
+ "synthetic_growth_defect.parquet": "Synthetic growth defects from genetic interactions.",
72
+ "synthetic_lethality.parquet": "Synthetic lethal interactions.",
73
+ "synthetic_rescue.parquet": "Genetic interactions rescuing phenotypes.",
74
+ "two-hybrid.parquet": "Protein-protein interactions detected by yeast two-hybrid assays.",
75
+ "variant_table.parquet": "Annotated genetic variants table.",
76
+ "Virus-Host_PPI_P-HIPSTER_2020.parquet": "Virus-host protein-protein interactions from P-HIPSTER.",
77
+ "txgnn_name_mapping.pkl": "Name mapping for TXGNN.",
78
+ "txgnn_prediction.pkl": "Prediction data for TXGNN.",
79
+ }
80
+
81
+ # Updated library_content as a dictionary with detailed descriptions
82
+ library_content_dict = {
83
+ # === PYTHON PACKAGES ===
84
+ # Core Bioinformatics Libraries (Python)
85
+ "biopython": "[Python Package] A set of tools for biological computation including parsers for bioinformatics files, access to online services, and interfaces to common bioinformatics programs.",
86
+ "biom-format": "[Python Package] The Biological Observation Matrix (BIOM) format is designed for representing biological sample by observation contingency tables with associated metadata.",
87
+ "scanpy": "[Python Package] A scalable toolkit for analyzing single-cell gene expression data, specifically designed for large datasets using AnnData.",
88
+ "scikit-bio": "[Python Package] Data structures, algorithms, and educational resources for bioinformatics, including sequence analysis, phylogenetics, and ordination methods.",
89
+ "anndata": "[Python Package] A Python package for handling annotated data matrices in memory and on disk, primarily used for single-cell genomics data.",
90
+ "mudata": "[Python Package] A Python package for multimodal data storage and manipulation, extending AnnData to handle multiple modalities.",
91
+ "pyliftover": "[Python Package] A Python implementation of UCSC liftOver tool for converting genomic coordinates between genome assemblies.",
92
+ "biopandas": "[Python Package] A package that provides pandas DataFrames for working with molecular structures and biological data.",
93
+ "biotite": "[Python Package] A comprehensive library for computational molecular biology, providing tools for sequence analysis, structure analysis, and more.",
94
+ "lazyslide": "[Python Package] A Python framework that brings interoperable, reproducible whole slide image analysis, enabling seamless histopathology workflows from preprocessing to deep learning.",
95
+ # Genomics & Variant Analysis (Python)
96
+ "gget": "[Python Package] A toolkit for accessing genomic databases and retrieving sequences, annotations, and other genomic data.",
97
+ "lifelines": "[Python Package] A complete survival analysis library for fitting models, plotting, and statistical tests.",
98
+ # "scvi-tools": "[Python Package] A package for probabilistic modeling of single-cell omics data, including deep generative models.",
99
+ "gseapy": "[Python Package] A Python wrapper for Gene Set Enrichment Analysis (GSEA) and visualization.",
100
+ "scrublet": "[Python Package] A tool for detecting doublets in single-cell RNA-seq data.",
101
+ "cellxgene-census": "[Python Package] A tool for accessing and analyzing the CellxGene Census, a collection of single-cell datasets. To download a dataset, use the download_source_h5ad function with the dataset id as the argument (856c1b98-5727-49da-bf0f-151bdb8cb056, no .h5ad extension).",
102
+ "hyperopt": "[Python Package] A Python library for optimizing hyperparameters of machine learning algorithms.",
103
+ "scvelo": "[Python Package] A tool for RNA velocity analysis in single cells using dynamical models.",
104
+ "pysam": "[Python Package] A Python module for reading, manipulating and writing genomic data sets in SAM/BAM/VCF/BCF formats.",
105
+ "pyfaidx": "[Python Package] A Python package for efficient random access to FASTA files.",
106
+ "pyranges": "[Python Package] A Python package for interval manipulation with a pandas-like interface.",
107
+ "pybedtools": "[Python Package] A Python wrapper for Aaron Quinlan's BEDTools programs.",
108
+ # "panhumanpy": "A Python package for hierarchical, cross-tissue cell type annotation of human single-cell RNA-seq data",
109
+ # Structural Biology & Drug Discovery (Python)
110
+ "rdkit": "[Python Package] A collection of cheminformatics and machine learning tools for working with chemical structures and drug discovery.",
111
+ "deeppurpose": "[Python Package] A deep learning library for drug-target interaction prediction and virtual screening.",
112
+ "pyscreener": "[Python Package] A Python package for virtual screening of chemical compounds.",
113
+ "openbabel": "[Python Package] A chemical toolbox designed to speak the many languages of chemical data, supporting file format conversion and molecular modeling.",
114
+ "descriptastorus": "[Python Package] A library for computing molecular descriptors for machine learning applications in drug discovery.",
115
+ # "pymol": "[Python Package] A molecular visualization system for rendering and animating 3D molecular structures.",
116
+ "openmm": "[Python Package] A toolkit for molecular simulation using high-performance GPU computing.",
117
+ "pytdc": "[Python Package] A Python package for Therapeutics Data Commons, providing access to machine learning datasets for drug discovery.",
118
+ # Data Science & Statistical Analysis (Python)
119
+ "pandas": "[Python Package] A fast, powerful, and flexible data analysis and manipulation library for Python.",
120
+ "numpy": "[Python Package] The fundamental package for scientific computing with Python, providing support for arrays, matrices, and mathematical functions.",
121
+ "scipy": "[Python Package] A Python library for scientific and technical computing, including modules for optimization, linear algebra, integration, and statistics.",
122
+ "scikit-learn": "[Python Package] A machine learning library featuring various classification, regression, and clustering algorithms.",
123
+ "matplotlib": "[Python Package] A comprehensive library for creating static, animated, and interactive visualizations in Python.",
124
+ "seaborn": "[Python Package] A statistical data visualization library based on matplotlib with a high-level interface for drawing attractive statistical graphics.",
125
+ "statsmodels": "[Python Package] A Python module for statistical modeling and econometrics, including descriptive statistics and estimation of statistical models.",
126
+ "pymc3": "[Python Package] A Python package for Bayesian statistical modeling and probabilistic machine learning.",
127
+ # "pystan": "[Python Package] A Python interface to Stan, a platform for statistical modeling and high-performance statistical computation.",
128
+ "umap-learn": "[Python Package] Uniform Manifold Approximation and Projection, a dimension reduction technique.",
129
+ "faiss-cpu": "[Python Package] A library for efficient similarity search and clustering of dense vectors.",
130
+ "harmony-pytorch": "[Python Package] A PyTorch implementation of the Harmony algorithm for integrating single-cell data.",
131
+ # General Bioinformatics & Computational Utilities (Python)
132
+ "tiledb": "[Python Package] A powerful engine for storing and analyzing large-scale genomic data.",
133
+ "tiledbsoma": "[Python Package] A library for working with the SOMA (Stack of Matrices) format using TileDB.",
134
+ "h5py": "[Python Package] A Python interface to the HDF5 binary data format, allowing storage of large amounts of numerical data.",
135
+ "tqdm": "[Python Package] A fast, extensible progress bar for loops and CLI applications.",
136
+ "joblib": "[Python Package] A set of tools to provide lightweight pipelining in Python, including transparent disk-caching and parallel computing.",
137
+ "opencv-python": "[Python Package] OpenCV library for computer vision tasks, useful for image analysis in biological contexts.",
138
+ "PyPDF2": "[Python Package] A library for working with PDF files, useful for extracting text from scientific papers.",
139
+ "googlesearch-python": "[Python Package] A library for performing Google searches programmatically.",
140
+ "scikit-image": "[Python Package] A collection of algorithms for image processing in Python.",
141
+ "pymed": "[Python Package] A Python library for accessing PubMed articles.",
142
+ "arxiv": "[Python Package] A Python wrapper for the arXiv API, allowing access to scientific papers.",
143
+ "scholarly": "[Python Package] A module to retrieve author and publication information from Google Scholar.",
144
+ "cryosparc-tools": "[Python Package] Tools for working with cryoSPARC, a platform for cryo-EM data processing.",
145
+ "mageck": "[Python Package] Analysis of CRISPR screen data.",
146
+ "igraph": "[Python Package] Network analysis and visualization.",
147
+ "pyscenic": "[Python Package] Analysis of single-cell RNA-seq data and gene regulatory networks.",
148
+ "cooler": "[Python Package] Storage and analysis of Hi-C data.",
149
+ "trackpy": "[Python Package] Particle tracking in images and video.",
150
+ # "flowcytometrytools": "[Python Package] Analysis and visualization of flow cytometry data.",
151
+ "cellpose": "[Python Package] Cell segmentation in microscopy images.",
152
+ "viennarna": "[Python Package] RNA secondary structure prediction.",
153
+ "PyMassSpec": "[Python Package] Mass spectrometry data analysis.",
154
+ "python-libsbml": "[Python Package] Working with SBML files for computational biology.",
155
+ "cobra": "[Python Package] Constraint-based modeling of metabolic networks.",
156
+ "reportlab": "[Python Package] Creation of PDF documents.",
157
+ "flowkit": "[Python Package] Toolkit for processing flow cytometry data.",
158
+ "hmmlearn": "[Python Package] Hidden Markov model analysis.",
159
+ "msprime": "[Python Package] Simulation of genetic variation.",
160
+ "tskit": "[Python Package] Handling tree sequences and population genetics data.",
161
+ "cyvcf2": "[Python Package] Fast parsing of VCF files.",
162
+ "pykalman": "[Python Package] Kalman filter and smoother implementation.",
163
+ "fanc": "[Python Package] Analysis of chromatin conformation data.",
164
+ "loompy": "A Python implementation of the Loom file format for efficiently storing and working with large omics datasets.",
165
+ "pyBigWig": "A Python library for accessing bigWig and bigBed files for genome browser track data.",
166
+ "pymzml": "A Python module for high-throughput bioinformatics analysis of mass spectrometry data.",
167
+ "optlang": "A Python package for modeling optimization problems symbolically.",
168
+ "FlowIO": "A Python package for reading and writing flow cytometry data files.",
169
+ "FlowUtils": "Utilities for processing and analyzing flow cytometry data.",
170
+ "arboreto": "A Python package for inferring gene regulatory networks from single-cell RNA-seq data.",
171
+ "pdbfixer": "A Python package for fixing problems in PDB files in preparation for molecular simulations.",
172
+ # === R PACKAGES ===
173
+ # Core R Packages for Data Analysis
174
+ "ggplot2": "[R Package] A system for declaratively creating graphics, based on The Grammar of Graphics. Use with subprocess.run(['Rscript', '-e', 'library(ggplot2); ...']).",
175
+ "dplyr": "[R Package] A grammar of data manipulation, providing a consistent set of verbs that help you solve the most common data manipulation challenges. Use with subprocess.",
176
+ "tidyr": "[R Package] A package that helps you create tidy data, where each column is a variable, each row is an observation, and each cell is a single value. Use with subprocess.",
177
+ "readr": "[R Package] A fast and friendly way to read rectangular data like CSV, TSV, and FWF. Use with subprocess.run(['Rscript', '-e', 'library(readr); ...']).",
178
+ "stringr": "[R Package] A cohesive set of functions designed to make working with strings as easy as possible. Use with subprocess calls.",
179
+ "Matrix": "[R Package] A package that provides classes and methods for dense and sparse matrices. Required for Seurat. Use with subprocess calls.",
180
+ # "Rcpp": "[R Package] Seamless R and C++ Integration, allowing R functions to call compiled C++ code. Use with subprocess calls.",
181
+ # "devtools": "[R Package] Tools to make developing R packages easier, including functions to install packages from GitHub. Use with subprocess calls.",
182
+ # "remotes": "[R Package] Install R packages from GitHub, GitLab, Bitbucket, or other remote repositories. Use with subprocess calls.",
183
+ # Bioinformatics R Packages
184
+ "DESeq2": "[R Package] Differential gene expression analysis based on the negative binomial distribution. Use with subprocess.run(['Rscript', '-e', 'library(DESeq2); ...']).",
185
+ "clusterProfiler": "[R Package] A package for statistical analysis and visualization of functional profiles for genes and gene clusters. Use with subprocess calls.",
186
+ # "DADA2": "[R Package] A package for modeling and correcting Illumina-sequenced amplicon errors. Use with subprocess calls.",
187
+ # "xcms": "[R Package] A package for processing and visualization of LC-MS and GC-MS data. Use with subprocess calls.",
188
+ # "FlowCore": "[R Package] Basic infrastructure for flow cytometry data. Use with subprocess calls.",
189
+ "edgeR": "[R Package] Empirical Analysis of Digital Gene Expression Data in R, for differential expression analysis. Use with subprocess calls.",
190
+ "limma": "[R Package] Linear Models for Microarray Data, for differential expression analysis. Use with subprocess calls.",
191
+ "harmony": "[R Package] A method for integrating and analyzing single-cell data across datasets. Use with subprocess calls.",
192
+ "WGCNA": "[R Package] Weighted Correlation Network Analysis for studying biological networks. Use with subprocess calls.",
193
+ # === CLI TOOLS ===
194
+ # Sequence Analysis Tools
195
+ "samtools": "[CLI Tool] A suite of programs for interacting with high-throughput sequencing data. Use with subprocess.run(['samtools', ...]).",
196
+ "bowtie2": "[CLI Tool] An ultrafast and memory-efficient tool for aligning sequencing reads to long reference sequences. Use with subprocess.run(['bowtie2', ...]).",
197
+ "bwa": "[CLI Tool] Burrows-Wheeler Aligner for mapping low-divergent sequences against a large reference genome. Use with subprocess.run(['bwa', ...]).",
198
+ "bedtools": "[CLI Tool] A powerful toolset for genome arithmetic, allowing operations like intersect, merge, count, and complement on genomic features. Use with subprocess.run(['bedtools', ...]).",
199
+ "macs2": "[CLI Tool] Model-based Analysis of ChIP-Seq data, a tool for identifying transcript factor binding sites.",
200
+ # Quality Control and Processing Tools
201
+ "fastqc": "[CLI Tool] A quality control tool for high throughput sequence data. Use with subprocess.run(['fastqc', ...]).",
202
+ "trimmomatic": "[CLI Tool] A flexible read trimming tool for Illumina NGS data. Use with subprocess.run(['trimmomatic', ...]).",
203
+ # Multiple Sequence Alignment and Phylogenetics
204
+ "mafft": "[CLI Tool] A multiple sequence alignment program for unix-like operating systems. Use with subprocess.run(['mafft', ...]).",
205
+ "Homer": "[CLI Tool] Motif discovery and next-gen sequencing analysis.",
206
+ "FastTree": "[CLI Tool] Phylogenetic trees from sequence alignments.",
207
+ "muscle": "[CLI Tool] Multiple sequence alignment tool.",
208
+ # Genetic Analysis Tools
209
+ "plink": "[CLI Tool] A comprehensive toolkit for genome association studies that can perform a range of large-scale analyses in a computationally efficient manner. Use with subprocess.run(['plink', ...]).",
210
+ "plink2": "[CLI Tool] A comprehensive toolkit for genome association studies that can perform a range of large-scale analyses in a computationally efficient manner. Use with subprocess.run(['plink2', ...]).",
211
+ "gcta64": "[CLI Tool] Genome-wide Complex Trait Analysis (GCTA) tool for estimating the proportion of phenotypic variance explained by genome-wide SNPs and analyzing genetic relationships. Use with subprocess.run(['gcta64', ...]).",
212
+ "iqtree2": "[CLI Tool] An efficient phylogenetic software for maximum likelihood analysis with built-in model selection and ultrafast bootstrap. Use with subprocess.run(['iqtree2', ...]).",
213
+ "ADFR": "AutoDock for Receptors suite for molecular docking and virtual screening. ",
214
+ "diamond": "A sequence aligner for protein and translated DNA searches, designed for high performance analysis of big sequence data. ",
215
+ "fcsparser": "A command-line tool for parsing and analyzing flow cytometry standard (FCS) files. ",
216
+ "plannotate": "[CLI Tool] A tool for annotating plasmid sequences with common features. ",
217
+ "vina": "[CLI Tool] An open-source program for molecular docking and virtual screening, known for its speed and accuracy improvements over AutoDock 4.",
218
+ "autosite": "[CLI Tool] A binding site detection tool used to identify potential ligand binding pockets on protein structures for molecular docking.",
219
+ }
BioScientist/agent_system/engines/v1_executor_backup/eval/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .biomni_eval1 import BiomniEval1
2
+
3
+ __all__ = ["BiomniEval1"]
BioScientist/agent_system/engines/v1_executor_backup/eval/biomni_eval1.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ BiomniEval1: Evaluation loader for Biomni tasks
3
+
4
+ This class provides a unified interface to evaluate user answers against ground truth
5
+ for all tasks in the BiomniEval1 benchmark.
6
+ """
7
+
8
+ import json
9
+ from typing import Any
10
+
11
+ import pandas as pd
12
+
13
+
14
+ class BiomniEval1:
15
+ """
16
+ Evaluation loader for BiomniEval1 benchmark
17
+
18
+ Usage:
19
+ evaluator = BiomniEval1('biomni_eval1_dataset.parquet')
20
+ score = evaluator.evaluate('gwas_causal_gene_opentargets', 0, 'BRCA1')
21
+ """
22
+
23
+ def __init__(self):
24
+ """
25
+ Initialize the BiomniEval1 evaluator
26
+
27
+ Args:
28
+ dataset_path: Path to the merged dataset parquet file
29
+ """
30
+
31
+ self.df = pd.read_parquet("hf://datasets/biomni/Eval1/biomni_eval1_dataset.parquet")
32
+
33
+ # Create index mapping for fast lookup using task_instance_id
34
+ self.instance_map = {}
35
+ for idx, row in self.df.iterrows():
36
+ key = (row["task_name"], row["task_instance_id"])
37
+ self.instance_map[key] = idx
38
+
39
+ print(f"Loaded BiomniEval1 dataset: {len(self.df)} instances across {self.df['task_name'].nunique()} tasks")
40
+
41
+ def evaluate(self, task_name: str, task_instance_id: int, user_answer: str) -> float:
42
+ """
43
+ Evaluate a user's answer for a given task and instance
44
+
45
+ Args:
46
+ task_name: Name of the task (e.g., 'gwas_causal_gene_opentargets')
47
+ task_instance_id: Task-specific instance ID (not the global instance_id)
48
+ user_answer: User's answer (format depends on task)
49
+
50
+ Returns:
51
+ float: Reward score (0.0 to 1.0)
52
+ """
53
+ # Look up the instance in the dataset using task_instance_id
54
+ key = (task_name, task_instance_id)
55
+ if key not in self.instance_map:
56
+ raise ValueError(f"Instance not found: task={task_name}, task_instance_id={task_instance_id}")
57
+
58
+ df_idx = self.instance_map[key]
59
+ row = self.df.iloc[df_idx]
60
+ ground_truth = row["answer"]
61
+
62
+ # Call task-specific evaluation function
63
+ try:
64
+ reward = self._compute_reward(task_name, user_answer, ground_truth)
65
+ return float(reward)
66
+
67
+ except Exception as e:
68
+ # Preserve original traceback context for easier debugging
69
+ raise RuntimeError(f"Error computing reward for {task_name} instance {task_instance_id}: {e}") from e
70
+
71
+ def _compute_reward(self, task_name: str, user_answer: str, ground_truth: str) -> float:
72
+ """Compute reward using task-specific logic"""
73
+
74
+ if task_name == "crispr_delivery":
75
+ # CRISPR expects answer as a letter (a-f), exact match
76
+ return 1.0 if user_answer.strip().lower() == ground_truth.strip().lower() else 0.0
77
+
78
+ elif task_name.startswith("gwas_causal_gene"):
79
+ # GWAS causal gene expects exact gene match (case-insensitive)
80
+ return 1.0 if user_answer.strip().upper() == ground_truth.strip().upper() else 0.0
81
+
82
+ elif task_name == "gwas_variant_prioritization":
83
+ # GWAS variant expects exact variant match
84
+ return 1.0 if user_answer.strip() == ground_truth.strip() else 0.0
85
+
86
+ elif task_name == "hle":
87
+ # HLE expects letter answer (A-Z), case-insensitive
88
+ return 1.0 if user_answer.strip().upper() == ground_truth.strip().upper() else 0.0
89
+
90
+ elif task_name.startswith("lab_bench"):
91
+ # Lab bench expects letter answer (A-Z), case-insensitive
92
+ return 1.0 if user_answer.strip().upper() == ground_truth.strip().upper() else 0.0
93
+
94
+ elif task_name == "rare_disease_diagnosis":
95
+ # Rare disease expects JSON with OMIM_ID match
96
+ # Parse both user answer and ground truth
97
+ try:
98
+ if isinstance(user_answer, str):
99
+ try:
100
+ user_dict = json.loads(user_answer)
101
+ except json.JSONDecodeError:
102
+ import ast
103
+
104
+ user_dict = ast.literal_eval(user_answer)
105
+ else:
106
+ user_dict = user_answer
107
+
108
+ if isinstance(ground_truth, str):
109
+ gt_dict = json.loads(ground_truth)
110
+ else:
111
+ gt_dict = ground_truth
112
+
113
+ # Compare OMIM_ID
114
+ return 1.0 if user_dict.get("OMIM_ID") == gt_dict.get("OMIM_ID") else 0.0
115
+
116
+ except Exception:
117
+ return 0.0
118
+
119
+ elif task_name == "screen_gene_retrieval":
120
+ # Screen gene retrieval expects gene symbol (case-insensitive)
121
+ return 1.0 if user_answer.strip().upper() == ground_truth.strip().upper() else 0.0
122
+
123
+ elif task_name == "patient_gene_detection":
124
+ # Patient gene detection expects JSON with causal_gene list
125
+ # Ground truth is a comma-separated string or single gene ID
126
+ try:
127
+ if isinstance(user_answer, str):
128
+ try:
129
+ user_dict = json.loads(user_answer)
130
+ except json.JSONDecodeError:
131
+ import ast
132
+
133
+ user_dict = ast.literal_eval(user_answer)
134
+ else:
135
+ user_dict = user_answer
136
+
137
+ # Get predicted genes
138
+ predicted_genes = user_dict.get("causal_gene", [])
139
+ if not isinstance(predicted_genes, list):
140
+ predicted_genes = [predicted_genes]
141
+
142
+ # Get ground truth genes (stored as comma-separated or single)
143
+ if "," in ground_truth:
144
+ true_genes = [g.strip() for g in ground_truth.split(",")]
145
+ else:
146
+ true_genes = [ground_truth]
147
+
148
+ # Check for intersection
149
+ if predicted_genes and set(true_genes) & set(predicted_genes):
150
+ return 1.0
151
+ else:
152
+ return 0.0
153
+
154
+ except Exception:
155
+ return 0.0
156
+
157
+ else:
158
+ raise ValueError(f"Unknown task: {task_name}")
159
+
160
+ def get_instance(self, task_name: str, task_instance_id: int) -> dict[str, Any]:
161
+ """
162
+ Get information about a specific instance
163
+
164
+ Args:
165
+ task_name: Name of the task
166
+ task_instance_id: Task-specific instance ID
167
+
168
+ Returns:
169
+ dict: Instance information including prompt, answer, etc.
170
+ """
171
+ key = (task_name, task_instance_id)
172
+ if key not in self.instance_map:
173
+ raise ValueError(f"Instance not found: task={task_name}, task_instance_id={task_instance_id}")
174
+
175
+ df_idx = self.instance_map[key]
176
+ row = self.df.iloc[df_idx]
177
+
178
+ return {
179
+ "global_instance_id": row["instance_id"],
180
+ "task_instance_id": row["task_instance_id"],
181
+ "task_name": row["task_name"],
182
+ "split": row["split"],
183
+ "prompt": row["prompt"],
184
+ "answer": row["answer"],
185
+ }
186
+
187
+ def list_tasks(self) -> list:
188
+ """Get list of all available tasks"""
189
+ return sorted(self.df["task_name"].unique().tolist())
190
+
191
+ def get_task_stats(self, task_name: str = None) -> dict[str, Any]:
192
+ """
193
+ Get statistics for a task or all tasks
194
+
195
+ Args:
196
+ task_name: Optional task name to filter by
197
+
198
+ Returns:
199
+ dict: Statistics including counts by split
200
+ """
201
+ if task_name:
202
+ task_df = self.df[self.df["task_name"] == task_name]
203
+ if len(task_df) == 0:
204
+ raise ValueError(f"Task not found: {task_name}")
205
+ else:
206
+ task_df = self.df
207
+
208
+ stats = {
209
+ "total_instances": len(task_df),
210
+ "train_instances": len(task_df[task_df["split"] == "train"]),
211
+ "val_instances": len(task_df[task_df["split"] == "val"]),
212
+ }
213
+
214
+ if not task_name:
215
+ stats["tasks"] = {}
216
+ for tn in self.list_tasks():
217
+ stats["tasks"][tn] = self.get_task_stats(tn)
218
+
219
+ return stats
220
+
221
+ def batch_evaluate(self, evaluations: list) -> list:
222
+ """
223
+ Evaluate multiple instances at once
224
+
225
+ Args:
226
+ evaluations: List of tuples (task_name, task_instance_id, user_answer)
227
+
228
+ Returns:
229
+ list: List of reward scores
230
+ """
231
+ results = []
232
+ for task_name, task_instance_id, user_answer in evaluations:
233
+ try:
234
+ score = self.evaluate(task_name, task_instance_id, user_answer)
235
+ results.append(score)
236
+ except Exception as e:
237
+ print(f"Error evaluating {task_name} instance {task_instance_id}: {e}")
238
+ results.append(0.0)
239
+
240
+ return results
241
+
242
+ def get_instances_by_task(self, task_name: str, split: str = None) -> pd.DataFrame:
243
+ """
244
+ Get all instances for a specific task
245
+
246
+ Args:
247
+ task_name: Name of the task
248
+ split: Optional split filter ('train' or 'val')
249
+
250
+ Returns:
251
+ DataFrame with instances
252
+ """
253
+ task_df = self.df[self.df["task_name"] == task_name]
254
+
255
+ if split:
256
+ task_df = task_df[task_df["split"] == split]
257
+
258
+ return task_df.copy()
259
+
260
+ def __repr__(self):
261
+ return f"BiomniEval1(instances={len(self.df)}, tasks={self.df['task_name'].nunique()})"
262
+
263
+ def __len__(self):
264
+ return len(self.df)
265
+
266
+
267
+ def main():
268
+ """Demo usage of BiomniEval1"""
269
+ evaluator = BiomniEval1()
270
+
271
+ print("\nAvailable tasks:")
272
+ for task in evaluator.list_tasks():
273
+ print(f" - {task}")
274
+
275
+ print("\nOverall statistics:")
276
+ stats = evaluator.get_task_stats()
277
+ print(f" Total instances: {stats['total_instances']}")
278
+ print(f" Train: {stats['train_instances']}, Val: {stats['val_instances']}")
279
+
280
+ print("\nPer-task statistics:")
281
+ for task_name in evaluator.list_tasks():
282
+ task_stats = evaluator.get_task_stats(task_name)
283
+ print(
284
+ f" {task_name}: {task_stats['total_instances']} total ({task_stats['train_instances']} train, {task_stats['val_instances']} val)"
285
+ )
286
+
287
+ # Example evaluation
288
+ print("\n" + "=" * 60)
289
+ print("Example evaluation:")
290
+ print("=" * 60)
291
+
292
+ # Get first instance from gwas_variant_prioritization
293
+ first_instance = evaluator.df[evaluator.df["task_name"] == "gwas_variant_prioritization"].iloc[0]
294
+ task_name = first_instance["task_name"]
295
+ task_instance_id = first_instance["task_instance_id"]
296
+ ground_truth = first_instance["answer"]
297
+
298
+ print(f"\nTask: {task_name}")
299
+ print(f"Task Instance ID: {task_instance_id}")
300
+ print(f"Ground truth: {ground_truth}")
301
+ print(f"Prompt preview: {first_instance['prompt'][:200]}...")
302
+
303
+ # Test with correct answer
304
+ score = evaluator.evaluate(task_name, task_instance_id, ground_truth)
305
+ print(f"\nScore (correct answer '{ground_truth}'): {score}")
306
+
307
+ # Test with wrong answer
308
+ score = evaluator.evaluate(task_name, task_instance_id, "wrong_answer")
309
+ print(f"Score (wrong answer 'wrong_answer'): {score}")
310
+
311
+ # Batch evaluation example
312
+ print("\n" + "=" * 60)
313
+ print("Batch evaluation example:")
314
+ print("=" * 60)
315
+ batch_evals = [
316
+ (task_name, task_instance_id, ground_truth), # Correct
317
+ (task_name, task_instance_id, "wrong"), # Wrong
318
+ ]
319
+ scores = evaluator.batch_evaluate(batch_evals)
320
+ print(f"Batch scores: {scores}")
321
+
322
+
323
+ if __name__ == "__main__":
324
+ main()
BioScientist/agent_system/engines/v1_executor_backup/llm.py ADDED
@@ -0,0 +1,276 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from typing import TYPE_CHECKING, Literal, Optional
3
+
4
+ from langchain_core.language_models.chat_models import BaseChatModel
5
+
6
+ if TYPE_CHECKING:
7
+ from biomni.config import BiomniConfig
8
+
9
+ SourceType = Literal["OpenAI", "AzureOpenAI", "Anthropic", "Ollama", "Gemini", "Bedrock", "Groq", "Custom"]
10
+ ALLOWED_SOURCES: set[str] = set(SourceType.__args__)
11
+
12
+
13
+ def get_llm(
14
+ model: str | None = None,
15
+ temperature: float | None = None,
16
+ stop_sequences: list[str] | None = None,
17
+ source: SourceType | None = None,
18
+ base_url: str | None = None,
19
+ api_key: str | None = None,
20
+ config: Optional["BiomniConfig"] = None,
21
+ ) -> BaseChatModel:
22
+ """
23
+ Get a language model instance based on the specified model name and source.
24
+ This function supports models from OpenAI, Azure OpenAI, Anthropic, Ollama, Gemini, Bedrock, and custom model serving.
25
+ Args:
26
+ model (str): The model name to use
27
+ temperature (float): Temperature setting for generation
28
+ stop_sequences (list): Sequences that will stop generation
29
+ source (str): Source provider: "OpenAI", "AzureOpenAI", "Anthropic", "Ollama", "Gemini", "Bedrock", or "Custom"
30
+ If None, will attempt to auto-detect from model name
31
+ base_url (str): The base URL for custom model serving (e.g., "http://localhost:8000/v1"), default is None
32
+ api_key (str): The API key for the custom llm
33
+ config (BiomniConfig): Optional configuration object. If provided, unspecified parameters will use config values
34
+ """
35
+ # Use config values for any unspecified parameters
36
+ if config is not None:
37
+ if model is None:
38
+ model = config.llm_model
39
+ if temperature is None:
40
+ temperature = config.temperature
41
+ if source is None:
42
+ source = config.source
43
+ if base_url is None:
44
+ base_url = config.base_url
45
+ if api_key is None:
46
+ api_key = config.api_key or "EMPTY"
47
+
48
+ # Use defaults if still not specified
49
+ if model is None:
50
+ model = "claude-3-5-sonnet-20241022"
51
+ if temperature is None:
52
+ temperature = 0.7
53
+ if api_key is None:
54
+ api_key = "EMPTY"
55
+ # Auto-detect source from model name if not specified
56
+ if source is None:
57
+ env_source = os.getenv("LLM_SOURCE")
58
+ if env_source in ALLOWED_SOURCES:
59
+ source = env_source
60
+ else:
61
+ if model[:7] == "claude-":
62
+ source = "Anthropic"
63
+ elif model[:7] == "gpt-oss":
64
+ source = "Ollama"
65
+ elif model[:4] == "gpt-":
66
+ source = "OpenAI"
67
+ elif model.startswith("azure-"):
68
+ source = "AzureOpenAI"
69
+ elif model[:7] == "gemini-":
70
+ source = "Gemini"
71
+ elif "groq" in model.lower():
72
+ source = "Groq"
73
+ elif base_url is not None:
74
+ source = "Custom"
75
+ elif "/" in model or any(
76
+ name in model.lower()
77
+ for name in [
78
+ "llama",
79
+ "mistral",
80
+ "qwen",
81
+ "gemma",
82
+ "phi",
83
+ "dolphin",
84
+ "orca",
85
+ "vicuna",
86
+ "deepseek",
87
+ ]
88
+ ):
89
+ source = "Ollama"
90
+ elif model.startswith(
91
+ ("anthropic.claude-", "amazon.titan-", "meta.llama-", "mistral.", "cohere.", "ai21.", "us.")
92
+ ):
93
+ source = "Bedrock"
94
+ else:
95
+ raise ValueError("Unable to determine model source. Please specify 'source' parameter.")
96
+
97
+ # Create appropriate model based on source
98
+ if source == "OpenAI":
99
+ try:
100
+ from langchain_openai import ChatOpenAI
101
+ except ImportError:
102
+ raise ImportError( # noqa: B904
103
+ "langchain-openai package is required for OpenAI models. Install with: pip install langchain-openai"
104
+ )
105
+ # Newer OpenAI models (e.g., gpt-5-*) require the Responses API and may reject
106
+ # legacy Chat Completions parameters like `stop`. Force Responses API when
107
+ # using gpt-5 models to avoid 400 errors such as: "Unsupported parameter: 'stop'".
108
+ use_responses = model.startswith("gpt-5")
109
+
110
+ if use_responses:
111
+ # Define a minimal subclass that drops the `stop` field when using the
112
+ # Responses API, since certain models (gpt-5-*) reject it entirely.
113
+ class _ChatOpenAIResponsesNoStop(ChatOpenAI):
114
+ def _get_request_payload(self, input_, *, stop=None, **kwargs): # type: ignore[override]
115
+ payload = super()._get_request_payload(input_, stop=stop, **kwargs)
116
+ try:
117
+ # If this call will use the Responses API, drop `stop` to avoid 400s.
118
+ if hasattr(self, "_use_responses_api") and self._use_responses_api(payload): # type: ignore[attr-defined]
119
+ payload.pop("stop", None)
120
+ # Also drop temperature for gpt-5 models as they only support default value
121
+ payload.pop("temperature", None)
122
+ except Exception:
123
+ # Be conservative: if anything goes wrong, still remove `stop` and `temperature`.
124
+ payload.pop("stop", None)
125
+ payload.pop("temperature", None)
126
+ return payload
127
+
128
+ return _ChatOpenAIResponsesNoStop(
129
+ model=model,
130
+ temperature=1, # Set to default value for gpt-5, will be removed in payload
131
+ stop_sequences=stop_sequences,
132
+ use_responses_api=True,
133
+ output_version="v0",
134
+ )
135
+ else:
136
+ return ChatOpenAI(
137
+ model=model,
138
+ temperature=temperature,
139
+ stop_sequences=stop_sequences,
140
+ )
141
+
142
+ elif source == "AzureOpenAI":
143
+ try:
144
+ from langchain_openai import AzureChatOpenAI
145
+ except ImportError:
146
+ raise ImportError( # noqa: B904
147
+ "langchain-openai package is required for Azure OpenAI models. Install with: pip install langchain-openai"
148
+ )
149
+ API_VERSION = "2024-12-01-preview"
150
+ model = model.replace("azure-", "")
151
+ return AzureChatOpenAI(
152
+ openai_api_key=os.getenv("OPENAI_API_KEY"),
153
+ azure_endpoint=os.getenv("OPENAI_ENDPOINT"),
154
+ azure_deployment=model,
155
+ openai_api_version=API_VERSION,
156
+ temperature=temperature,
157
+ )
158
+
159
+ elif source == "Anthropic":
160
+ try:
161
+ from langchain_anthropic import ChatAnthropic
162
+ except ImportError:
163
+ raise ImportError( # noqa: B904
164
+ "langchain-anthropic package is required for Anthropic models. Install with: pip install langchain-anthropic"
165
+ )
166
+
167
+ # Ensure ANTHROPIC_API_KEY is loaded from bash_profile if not in environment
168
+ if not os.environ.get("ANTHROPIC_API_KEY"):
169
+ try:
170
+ import subprocess
171
+
172
+ result = subprocess.run(
173
+ ["bash", "-c", "source ~/.bash_profile 2>/dev/null && echo $ANTHROPIC_API_KEY"],
174
+ capture_output=True,
175
+ text=True,
176
+ timeout=5,
177
+ )
178
+ if result.stdout.strip():
179
+ os.environ["ANTHROPIC_API_KEY"] = result.stdout.strip()
180
+ print("✓ Loaded ANTHROPIC_API_KEY from ~/.bash_profile")
181
+ except Exception as e:
182
+ print(f"Note: Could not load ANTHROPIC_API_KEY from bash_profile: {e}")
183
+
184
+ # Newer Claude models can reject an explicit temperature parameter.
185
+ # Omit it and use provider defaults to keep compatibility.
186
+ return ChatAnthropic(
187
+ model=model,
188
+ max_tokens=8192,
189
+ stop_sequences=stop_sequences,
190
+ )
191
+
192
+ elif source == "Gemini":
193
+ # If you want to use ChatGoogleGenerativeAI, you need to pass the stop sequences upon invoking the model.
194
+ # return ChatGoogleGenerativeAI(
195
+ # model=model,
196
+ # temperature=temperature,
197
+ # google_api_key=api_key,
198
+ # )
199
+ try:
200
+ from langchain_openai import ChatOpenAI
201
+ except ImportError:
202
+ raise ImportError( # noqa: B904
203
+ "langchain-openai package is required for Gemini models. Install with: pip install langchain-openai"
204
+ )
205
+ return ChatOpenAI(
206
+ model=model,
207
+ temperature=temperature,
208
+ api_key=os.getenv("GEMINI_API_KEY"),
209
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai/",
210
+ stop_sequences=stop_sequences,
211
+ )
212
+
213
+ elif source == "Groq":
214
+ try:
215
+ from langchain_openai import ChatOpenAI
216
+ except ImportError:
217
+ raise ImportError( # noqa: B904
218
+ "langchain-openai package is required for Groq models. Install with: pip install langchain-openai"
219
+ )
220
+ return ChatOpenAI(
221
+ model=model,
222
+ temperature=temperature,
223
+ api_key=os.getenv("GROQ_API_KEY"),
224
+ base_url="https://api.groq.com/openai/v1",
225
+ stop_sequences=stop_sequences,
226
+ )
227
+
228
+ elif source == "Ollama":
229
+ try:
230
+ from langchain_ollama import ChatOllama
231
+ except ImportError:
232
+ raise ImportError( # noqa: B904
233
+ "langchain-ollama package is required for Ollama models. Install with: pip install langchain-ollama"
234
+ )
235
+ return ChatOllama(
236
+ model=model,
237
+ temperature=temperature,
238
+ )
239
+
240
+ elif source == "Bedrock":
241
+ try:
242
+ from langchain_aws import ChatBedrock
243
+ except ImportError:
244
+ raise ImportError( # noqa: B904
245
+ "langchain-aws package is required for Bedrock models. Install with: pip install langchain-aws"
246
+ )
247
+ return ChatBedrock(
248
+ model=model,
249
+ temperature=temperature,
250
+ stop_sequences=stop_sequences,
251
+ region_name=os.getenv("AWS_REGION", "us-east-1"),
252
+ )
253
+
254
+ elif source == "Custom":
255
+ try:
256
+ from langchain_openai import ChatOpenAI
257
+ except ImportError:
258
+ raise ImportError( # noqa: B904
259
+ "langchain-openai package is required for custom models. Install with: pip install langchain-openai"
260
+ )
261
+ # Custom LLM serving such as SGLang. Must expose an openai compatible API.
262
+ assert base_url is not None, "base_url must be provided for customly served LLMs"
263
+ llm = ChatOpenAI(
264
+ model=model,
265
+ temperature=temperature,
266
+ max_tokens=8192,
267
+ stop_sequences=stop_sequences,
268
+ base_url=base_url,
269
+ api_key=api_key,
270
+ )
271
+ return llm
272
+
273
+ else:
274
+ raise ValueError(
275
+ f"Invalid source: {source}. Valid options are 'OpenAI', 'AzureOpenAI', 'Anthropic', 'Gemini', 'Groq', 'Bedrock', or 'Ollama'"
276
+ )
BioScientist/agent_system/engines/v1_executor_backup/mcp_config_bioscientist_generated.yaml ADDED
The diff for this file is too large to render. See raw diff
 
BioScientist/agent_system/engines/v1_executor_backup/task/__init__.py ADDED
File without changes
BioScientist/agent_system/engines/v1_executor_backup/task/base_task.py ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class base_task:
2
+ def __init__(self):
3
+ pass
4
+
5
+ def get_example(self):
6
+ pass
7
+
8
+ def get_iterator(self):
9
+ pass
10
+
11
+ def evaluate(self):
12
+ pass
13
+
14
+ def output_class(self):
15
+ pass
16
+
17
+ def get_prompt_from_input(self, input):
18
+ return self.get_example(input)["prompt"]
BioScientist/agent_system/engines/v1_executor_backup/task/hle.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from biomni.task.base_task import base_task
5
+
6
+ np.random.seed(42)
7
+
8
+
9
+ def shuffle(x):
10
+ np.random.shuffle(x)
11
+ return x
12
+
13
+
14
+ class humanity_last_exam(base_task):
15
+ def __init__(self, path="./data", category="Biology/Medicine", answer_type="multipleChoice"):
16
+ if category not in [
17
+ "Other",
18
+ "Humanities/Social Science",
19
+ "Math",
20
+ "Physics",
21
+ "Computer Science/AI",
22
+ "Biology/Medicine",
23
+ "Chemistry",
24
+ "Engineering",
25
+ ]:
26
+ raise ValueError(
27
+ "category must be one of ['Other', 'Humanities/Social Science', 'Math', 'Physics', 'Computer Science/AI', 'Biology/Medicine', 'Chemistry', 'Engineering']"
28
+ )
29
+ if answer_type not in ["exactMatch", "multipleChoice"]:
30
+ raise ValueError("answer_type must be one of ['exactMatch' or 'multipleChoice']")
31
+
32
+ self.dataset = category # Store dataset type
33
+ self.answer_type = answer_type
34
+ df = pd.read_parquet(path + "/hle/test_sampled_biology_medicine.parquet")
35
+
36
+ # Extract answer choices from question text
37
+ def extract_options(question):
38
+ # Find the "Answer Choices:" section
39
+ if "Answer Choices:" not in question:
40
+ return []
41
+ choices = question.split("Answer Choices:")[1].strip()
42
+
43
+ # Split on A., B., C. etc and clean up
44
+ options = []
45
+ letters = [
46
+ "A.",
47
+ "B.",
48
+ "C.",
49
+ "D.",
50
+ "E.",
51
+ "F.",
52
+ "G.",
53
+ "H.",
54
+ "I.",
55
+ "J.",
56
+ "K.",
57
+ "L.",
58
+ "M.",
59
+ "N.",
60
+ "O.",
61
+ "P.",
62
+ "Q.",
63
+ "R.",
64
+ "S.",
65
+ "T.",
66
+ "U.",
67
+ "V.",
68
+ "W.",
69
+ "X.",
70
+ "Y.",
71
+ "Z.",
72
+ ]
73
+ for i, letter in enumerate(letters):
74
+ if letter in choices:
75
+ # Define the next letter if available
76
+ next_letter = letters[i + 1] if i + 1 < len(letters) else None
77
+
78
+ # Split between current letter and next letter
79
+ parts = choices.split(letter)[1]
80
+ if next_letter and next_letter in parts:
81
+ option = parts.split(next_letter)[0].strip()
82
+ else:
83
+ option = parts.strip()
84
+
85
+ options.append(option)
86
+ return options
87
+
88
+ def extract_question(question):
89
+ return question.split("Answer Choices:")[0].strip()
90
+
91
+ # Extract options and answers only for multiple choice questions and category is the same as the dataset
92
+ df = df[df["category"] == self.dataset]
93
+ df = df[df["answer_type"] == "multipleChoice"]
94
+ df["question_text"] = df.question
95
+ df["letter_answer"] = df["answer"].apply(lambda x: x[0])
96
+
97
+ self.query = df.question_text.values
98
+ # self.options = df.options_letters.values
99
+ self.answer = df.letter_answer.values
100
+
101
+ self.prompt = """Question: {question}"""
102
+
103
+ def get_example(self, index=None):
104
+ if index is None:
105
+ index = np.random.randint(len(self.query))
106
+
107
+ return {
108
+ "prompt": self.prompt.format(
109
+ question=self.query[index],
110
+ # options = self.options[index]
111
+ ),
112
+ "answer": self.answer[index],
113
+ }
114
+
115
+ def get_iterator(self):
116
+ for i in range(len(self.query)):
117
+ yield self.get_example(i)
118
+
119
+ def evaluate(self, response):
120
+ ## expected a list/array of symbols
121
+ from sklearn.metrics import accuracy_score
122
+
123
+ ground_truth = self.answer
124
+ response = np.array(response)
125
+
126
+ return {
127
+ "accuracy": accuracy_score(ground_truth, response),
128
+ "coverage": np.mean(response != self.refrain_label),
129
+ "refrain_ratio": np.mean(response == self.refrain_label),
130
+ "precision": accuracy_score(
131
+ ground_truth[np.where(response != self.refrain_label)],
132
+ response[np.where(response != self.refrain_label)],
133
+ ),
134
+ }
135
+
136
+ def output_class(self):
137
+ from pydantic import BaseModel, Field
138
+
139
+ class MultipleChoiceOutput(BaseModel):
140
+ """Multiple choice output."""
141
+
142
+ choice: str | None = Field(
143
+ description="Multiple choice answer. For example, if there is <answer>A</answer> in the prompt, the output should be 'A'."
144
+ )
145
+
146
+ return MultipleChoiceOutput
BioScientist/agent_system/engines/v1_executor_backup/task/lab_bench.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numpy as np
2
+ import pandas as pd
3
+
4
+ from biomni.task.base_task import base_task
5
+
6
+ np.random.seed(42)
7
+
8
+
9
+ def shuffle(x):
10
+ np.random.shuffle(x)
11
+ return x
12
+
13
+
14
+ class lab_bench(base_task):
15
+ def __init__(self, path="./data", dataset="DbQA"):
16
+ if dataset not in ["DbQA", "SeqQA"]:
17
+ raise ValueError("dataset must be one of 'DbQA', 'SeqQA'")
18
+
19
+ self.dataset = dataset # Store dataset type
20
+ df = pd.read_parquet(path + "/" + dataset + "/train-00000-of-00001_test.parquet")
21
+
22
+ self.prompt = """The following is a multiple choice question about biology.
23
+ Please answer by responding with the letter of the correct answer.
24
+
25
+ Question: {question}
26
+ Options:
27
+ {options}
28
+
29
+ You MUST include the letter of the correct answer within the following tags:
30
+ [ANSWER] and [/ANSWER]. For example, '[ANSWER]<answer>[/ANSWER]',
31
+ where <answer> is the correct letter. Always answer in exactly this format
32
+ of a single letter between the two tags, even if you are unsure.
33
+ We require this because we use automatic parsing.
34
+ """
35
+
36
+ np.random.seed(42)
37
+ df["options"] = df.apply(
38
+ lambda x: shuffle(
39
+ x.distractors.tolist() + [x.ideal] + ["Insufficient information to answer the question."]
40
+ ),
41
+ axis=1,
42
+ )
43
+ df["options_letters"] = df.options.apply(
44
+ lambda x: "\n".join([chr(ord("A") + i) + "." + item for i, item in enumerate(x)])
45
+ )
46
+ df["letter_answer"] = df.apply(
47
+ lambda x: chr(ord("A") + np.where(np.array(x.options) == x.ideal)[0][0]),
48
+ axis=1,
49
+ )
50
+ df["letter_refrain"] = df.apply(
51
+ lambda x: chr(
52
+ ord("A") + np.where(np.array(x.options) == "Insufficient information to answer the question.")[0][0]
53
+ ),
54
+ axis=1,
55
+ )
56
+
57
+ self.query = df.question.values
58
+ self.options = df.options_letters.values
59
+ self.answer = df.letter_answer.values
60
+ self.refrain_label = df.letter_refrain.values
61
+
62
+ # Store protocol information if available
63
+ self.protocol = df.protocol.values if "protocol" in df.columns else None
64
+
65
+ def get_example(self, index=None):
66
+ if index is None:
67
+ index = np.random.randint(len(self.query))
68
+
69
+ if self.dataset == "ProtocolQA" and self.protocol is not None:
70
+ return {
71
+ "prompt": self.prompt.format(
72
+ protocol=self.protocol[index],
73
+ question=self.query[index],
74
+ options=self.options[index],
75
+ ),
76
+ "answer": self.answer[index],
77
+ }
78
+ else:
79
+ return {
80
+ "prompt": self.prompt.format(question=self.query[index], options=self.options[index]),
81
+ "answer": self.answer[index],
82
+ }
83
+
84
+ def get_iterator(self):
85
+ for i in range(len(self.query)):
86
+ yield self.get_example(i)
87
+
88
+ def evaluate(self, response):
89
+ ## expected a list/array of symbols
90
+ from sklearn.metrics import accuracy_score
91
+
92
+ ground_truth = self.answer
93
+ response = np.array(response)
94
+
95
+ return {
96
+ "accuracy": accuracy_score(ground_truth, response),
97
+ "coverage": np.mean(response != self.refrain_label),
98
+ "refrain_ratio": np.mean(response == self.refrain_label),
99
+ "precision": accuracy_score(
100
+ ground_truth[np.where(response != self.refrain_label)],
101
+ response[np.where(response != self.refrain_label)],
102
+ ),
103
+ }
104
+
105
+ def output_class(self):
106
+ from pydantic import BaseModel, Field
107
+
108
+ class MultipleChoiceOutput(BaseModel):
109
+ """Multiple choice output."""
110
+
111
+ choice: str | None = Field(
112
+ description="Multiple choice answer. For example, if there is <answer>A</answer> in the prompt, the output should be 'A'."
113
+ )
114
+
115
+ return MultipleChoiceOutput
BioScientist/agent_system/engines/v1_executor_backup/tool/__init__.py ADDED
@@ -0,0 +1 @@
 
 
1
+ from biomni.utils import get_tool_decorated_functions # noqa: F401
BioScientist/agent_system/engines/v1_executor_backup/tool/biochemistry.py ADDED
@@ -0,0 +1,1026 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def analyze_circular_dichroism_spectra(
2
+ sample_name,
3
+ sample_type,
4
+ wavelength_data,
5
+ cd_signal_data,
6
+ temperature_data=None,
7
+ thermal_cd_data=None,
8
+ output_dir="./",
9
+ ):
10
+ """Analyzes circular dichroism (CD) spectroscopy data to determine secondary structure and thermal stability.
11
+
12
+ Parameters
13
+ ----------
14
+ sample_name : str
15
+ Name of the biomolecule sample (e.g., "Znf706", "G-quadruplex")
16
+ sample_type : str
17
+ Type of biomolecule ("protein" or "nucleic_acid")
18
+ wavelength_data : list or numpy.ndarray
19
+ Wavelength values in nm for CD spectrum
20
+ cd_signal_data : list or numpy.ndarray
21
+ CD signal intensity values (typically in mdeg or Δε)
22
+ temperature_data : list or numpy.ndarray, optional
23
+ Temperature values (°C) for thermal denaturation experiment
24
+ thermal_cd_data : list or numpy.ndarray, optional
25
+ CD signal values at specific wavelength across different temperatures
26
+ output_dir : str, optional
27
+ Directory to save result files, defaults to current directory
28
+
29
+ Returns
30
+ -------
31
+ str
32
+ Research log summarizing the CD analysis steps and results
33
+
34
+ """
35
+ import os
36
+ from datetime import datetime
37
+
38
+ import numpy as np
39
+
40
+ # Initialize research log
41
+ log = f"# Circular Dichroism Analysis Report for {sample_name}\n"
42
+ log += f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
43
+ log += "## Sample Information\n"
44
+ log += f"- Sample Name: {sample_name}\n"
45
+ log += f"- Sample Type: {sample_type}\n\n"
46
+
47
+ # Convert inputs to numpy arrays if they aren't already
48
+ wavelength_data = np.array(wavelength_data)
49
+ cd_signal_data = np.array(cd_signal_data)
50
+
51
+ # Ensure output directory exists
52
+ if not os.path.exists(output_dir):
53
+ os.makedirs(output_dir)
54
+
55
+ # 1. Analyze CD spectrum for secondary structure
56
+ log += "## Secondary Structure Analysis\n"
57
+
58
+ # Different analysis approaches based on sample type
59
+ if sample_type.lower() == "protein":
60
+ # Analyze protein secondary structure based on characteristic spectral features
61
+ alpha_helix_signal = np.sum((wavelength_data >= 190) & (wavelength_data <= 195) & (cd_signal_data > 0))
62
+ beta_sheet_signal = np.sum((wavelength_data >= 215) & (wavelength_data <= 220) & (cd_signal_data < 0))
63
+ random_coil_signal = np.sum((wavelength_data >= 195) & (wavelength_data <= 200) & (cd_signal_data < 0))
64
+
65
+ # Simple classification based on signal patterns
66
+ if alpha_helix_signal > beta_sheet_signal and alpha_helix_signal > random_coil_signal:
67
+ structure = "predominantly alpha-helical"
68
+ elif beta_sheet_signal > alpha_helix_signal and beta_sheet_signal > random_coil_signal:
69
+ structure = "predominantly beta-sheet"
70
+ else:
71
+ structure = "mixed or predominantly random coil"
72
+
73
+ log += f"- The CD spectrum indicates {structure} structure for {sample_name}.\n"
74
+ log += "- Key spectral features:\n"
75
+ log += " - 190-195 nm region: associated with alpha-helical content\n"
76
+ log += " - 215-220 nm region: associated with beta-sheet content\n\n"
77
+
78
+ elif sample_type.lower() == "nucleic_acid":
79
+ # Analyze nucleic acid structure (e.g., G-quadruplex has characteristic positive peak ~295 nm)
80
+ g_quadruplex_signal = np.sum((wavelength_data >= 290) & (wavelength_data <= 300) & (cd_signal_data > 0))
81
+ b_form_signal = np.sum((wavelength_data >= 270) & (wavelength_data <= 280) & (cd_signal_data > 0))
82
+
83
+ if g_quadruplex_signal > 0:
84
+ structure = "G-quadruplex characteristics"
85
+ elif b_form_signal > 0:
86
+ structure = "B-form characteristics"
87
+ else:
88
+ structure = "non-standard structure"
89
+
90
+ log += f"- The CD spectrum indicates {structure} for {sample_name}.\n"
91
+ log += "- Key spectral features:\n"
92
+ log += " - 290-300 nm positive peak: characteristic of G-quadruplex structures\n"
93
+ log += " - 270-280 nm positive peak: characteristic of B-form DNA\n\n"
94
+
95
+ # Save spectral data results
96
+ spectral_file = os.path.join(output_dir, f"{sample_name}_cd_spectrum_analysis.txt")
97
+ with open(spectral_file, "w") as f:
98
+ f.write("Wavelength (nm)\tCD Signal\n")
99
+ for wl, signal in zip(wavelength_data, cd_signal_data, strict=False):
100
+ f.write(f"{wl:.1f}\t{signal:.4f}\n")
101
+
102
+ log += f"- Detailed spectral data saved to: {spectral_file}\n\n"
103
+
104
+ # 2. Thermal stability analysis (if temperature data provided)
105
+ if temperature_data is not None and thermal_cd_data is not None:
106
+ temperature_data = np.array(temperature_data)
107
+ thermal_cd_data = np.array(thermal_cd_data)
108
+
109
+ log += "## Thermal Stability Analysis\n"
110
+
111
+ # Simple Tm estimation (melting temperature) - find temperature at 50% unfolding
112
+ # Normalize thermal data to 0-1 range for unfolding fraction
113
+ min_signal = np.min(thermal_cd_data)
114
+ max_signal = np.max(thermal_cd_data)
115
+ unfolded_fraction = (thermal_cd_data - min_signal) / (max_signal - min_signal)
116
+
117
+ # Find the temperature closest to 50% unfolding
118
+ tm_idx = np.argmin(np.abs(unfolded_fraction - 0.5))
119
+ tm = temperature_data[tm_idx]
120
+
121
+ log += f"- Estimated melting temperature (Tm): {tm:.1f}°C\n"
122
+
123
+ # Cooperativity assessment (crude estimate based on transition steepness)
124
+ t_range = temperature_data[-1] - temperature_data[0]
125
+ transition_width = (
126
+ t_range / len(temperature_data) * np.sum((unfolded_fraction > 0.2) & (unfolded_fraction < 0.8))
127
+ )
128
+
129
+ if transition_width < 0.2 * t_range:
130
+ cooperativity = "highly cooperative (sharp transition)"
131
+ elif transition_width < 0.4 * t_range:
132
+ cooperativity = "moderately cooperative"
133
+ else:
134
+ cooperativity = "non-cooperative (broad transition)"
135
+
136
+ log += f"- Thermal transition: {cooperativity}\n"
137
+
138
+ # Save thermal denaturation data
139
+ thermal_file = os.path.join(output_dir, f"{sample_name}_thermal_denaturation.txt")
140
+ with open(thermal_file, "w") as f:
141
+ f.write("Temperature (°C)\tCD Signal\tUnfolded Fraction\n")
142
+ for temp, signal, unfold in zip(temperature_data, thermal_cd_data, unfolded_fraction, strict=False):
143
+ f.write(f"{temp:.1f}\t{signal:.4f}\t{unfold:.4f}\n")
144
+
145
+ log += f"- Thermal denaturation data saved to: {thermal_file}\n\n"
146
+
147
+ # 3. Summary and conclusions
148
+ log += "## Conclusions\n"
149
+ if sample_type.lower() == "protein":
150
+ log += f"- {sample_name} shows {structure} according to CD spectroscopy.\n"
151
+ else:
152
+ log += f"- {sample_name} exhibits {structure} according to CD spectroscopy.\n"
153
+
154
+ if temperature_data is not None:
155
+ log += f"- The molecule has a melting temperature of {tm:.1f}°C with {cooperativity}.\n"
156
+
157
+ return log
158
+
159
+
160
+ def analyze_rna_secondary_structure_features(dot_bracket_structure, sequence=None):
161
+ """Calculate numeric values for various structural features of an RNA secondary structure.
162
+
163
+ Parameters
164
+ ----------
165
+ dot_bracket_structure : str
166
+ RNA secondary structure in dot-bracket notation (e.g., "(((...)))").
167
+ Parentheses represent base pairs, dots represent unpaired bases.
168
+ sequence : str, optional
169
+ The RNA sequence corresponding to the structure. If provided,
170
+ sequence-dependent energy calculations will be performed.
171
+
172
+ Returns
173
+ -------
174
+ str
175
+ A research log summarizing the calculated structural features and analysis steps.
176
+
177
+ """
178
+ # Initialize research log
179
+ log = "# RNA Secondary Structure Feature Analysis\n\n"
180
+
181
+ # Validate input
182
+ if not all(c in "().[]{}" for c in dot_bracket_structure):
183
+ return "Error: Invalid dot-bracket notation. Use only '()', '[]', '{}', and '.'"
184
+
185
+ log += f"Input structure (length: {len(dot_bracket_structure)}): {dot_bracket_structure}\n"
186
+ if sequence:
187
+ log += f"Input sequence (length: {len(sequence)}): {sequence}\n"
188
+ if len(sequence) != len(dot_bracket_structure):
189
+ return "Error: Sequence and structure lengths do not match."
190
+
191
+ # Extract base pairs
192
+ pairs = []
193
+ stack = []
194
+
195
+ for i, char in enumerate(dot_bracket_structure):
196
+ if char in "([{":
197
+ stack.append((i, char))
198
+ elif char in ")]}":
199
+ if not stack:
200
+ return "Error: Unbalanced structure. More closing than opening brackets."
201
+
202
+ j, opening_char = stack.pop()
203
+ # Check for matching bracket types
204
+ if (
205
+ (opening_char == "(" and char != ")")
206
+ or (opening_char == "[" and char != "]")
207
+ or (opening_char == "{" and char != "}")
208
+ ):
209
+ return "Error: Mismatched bracket types. Opening and closing brackets must match."
210
+
211
+ pairs.append((j, i))
212
+
213
+ if stack:
214
+ return "Error: Unbalanced structure. More opening than closing brackets."
215
+
216
+ # Sort pairs by position
217
+ pairs.sort()
218
+
219
+ # Identify stems (consecutive base pairs)
220
+ stems = []
221
+ current_stem = []
222
+
223
+ for i, (start, end) in enumerate(pairs):
224
+ if (i == 0 or start != pairs[i - 1][0] + 1 or end != pairs[i - 1][1] - 1) and current_stem:
225
+ stems.append(current_stem)
226
+ current_stem = []
227
+ current_stem.append((start, end))
228
+
229
+ if current_stem:
230
+ stems.append(current_stem)
231
+
232
+ # Calculate stem lengths
233
+ stem_lengths = [len(stem) for stem in stems]
234
+
235
+ # Calculate loop sizes
236
+ loops = []
237
+ for i in range(len(stems)):
238
+ stem = stems[i]
239
+ last_pair = stem[-1]
240
+ next_stem_start = stems[i + 1][0][0] if i < len(stems) - 1 else len(dot_bracket_structure)
241
+ loop_size = next_stem_start - last_pair[1] - 1
242
+ if loop_size > 0:
243
+ loops.append(loop_size)
244
+
245
+ # Calculate base pair statistics
246
+ total_paired_bases = len(pairs) * 2
247
+ total_unpaired_bases = len(dot_bracket_structure) - total_paired_bases
248
+
249
+ # Calculate simplified free energy if sequence is provided
250
+ stem_energies = []
251
+ if sequence and len(stems) > 0:
252
+ # Simplified energy parameters for nearest-neighbor model
253
+ # Values are approximate and simplified for illustration
254
+ energy_params = {
255
+ "AU": -0.9,
256
+ "UA": -0.9,
257
+ "GC": -2.1,
258
+ "CG": -2.1,
259
+ "GU": -0.5,
260
+ "UG": -0.5,
261
+ }
262
+
263
+ for stem in stems:
264
+ stem_energy = 0
265
+ for start, end in stem:
266
+ if start < len(sequence) and end < len(sequence):
267
+ pair = sequence[start] + sequence[end]
268
+ stem_energy += energy_params.get(pair, 0)
269
+ stem_energies.append(stem_energy)
270
+
271
+ # Prepare results
272
+ log += "\n## Structural Features\n\n"
273
+ log += f"Total base pairs: {len(pairs)}\n"
274
+ log += f"Number of stems: {len(stems)}\n"
275
+ log += f"Longest stem length: {max(stem_lengths) if stem_lengths else 0}\n"
276
+ log += f"Average stem length: {sum(stem_lengths) / len(stem_lengths) if stem_lengths else 0:.2f}\n"
277
+ log += f"Paired bases: {total_paired_bases} ({total_paired_bases / len(dot_bracket_structure) * 100:.1f}%)\n"
278
+ log += f"Unpaired bases: {total_unpaired_bases} ({total_unpaired_bases / len(dot_bracket_structure) * 100:.1f}%)\n"
279
+
280
+ if loops:
281
+ log += f"Number of loops: {len(loops)}\n"
282
+ log += f"Average loop size: {sum(loops) / len(loops):.2f}\n"
283
+ log += f"Largest loop size: {max(loops)}\n"
284
+
285
+ if sequence and stem_energies:
286
+ log += "\n## Energy Calculations\n\n"
287
+ log += f"Total estimated free energy: {sum(stem_energies):.2f} kcal/mol\n"
288
+
289
+ if len(stems) >= 2:
290
+ log += f"Upstream stem free energy: {stem_energies[0]:.2f} kcal/mol\n"
291
+ log += f"Downstream stem free energy: {stem_energies[-1]:.2f} kcal/mol\n"
292
+
293
+ # If the first stem is the "zipper" stem
294
+ if stem_lengths and stem_lengths[0] >= 3:
295
+ log += f"Zipper stem free energy: {stem_energies[0]:.2f} kcal/mol\n"
296
+
297
+ log += "\n## Stem Details\n\n"
298
+ for i, stem in enumerate(stems):
299
+ log += f"Stem {i + 1}: {len(stem)} base pairs\n"
300
+ log += f" Positions: {stem[0][0]}-{stem[0][1]} to {stem[-1][0]}-{stem[-1][1]}\n"
301
+ if sequence and i < len(stem_energies):
302
+ log += f" Estimated stability: {stem_energies[i]:.2f} kcal/mol\n"
303
+
304
+ return log
305
+
306
+
307
+ def analyze_protease_kinetics(
308
+ time_points,
309
+ fluorescence_data,
310
+ substrate_concentrations,
311
+ enzyme_concentration,
312
+ output_prefix="protease_kinetics",
313
+ output_dir="./",
314
+ ):
315
+ """Analyze protease kinetics data from fluorogenic peptide cleavage assays.
316
+
317
+ This function processes time-course fluorescence data from protease-mediated peptide
318
+ cleavage assays, fits the data to Michaelis-Menten kinetics, and determines key
319
+ kinetic parameters (kcat, KM, and catalytic efficiency).
320
+
321
+ Parameters
322
+ ----------
323
+ time_points : numpy.ndarray
324
+ Array of time points (in seconds) at which measurements were taken
325
+
326
+ fluorescence_data : numpy.ndarray
327
+ 2D array of fluorescence measurements where each row corresponds to a different
328
+ substrate concentration and each column corresponds to a time point
329
+
330
+ substrate_concentrations : numpy.ndarray
331
+ Array of substrate concentrations (in μM) corresponding to each row in fluorescence_data
332
+
333
+ enzyme_concentration : float
334
+ Concentration of the protease enzyme (in μM)
335
+
336
+ output_prefix : str, optional
337
+ Prefix for output files (default: "protease_kinetics")
338
+
339
+ output_dir : str, optional
340
+ Directory to save output files (default: "./")
341
+
342
+ Returns
343
+ -------
344
+ str
345
+ A research log summarizing the analysis steps and results
346
+
347
+ """
348
+ import os
349
+
350
+ import matplotlib.pyplot as plt
351
+ import numpy as np
352
+ from scipy.optimize import curve_fit
353
+
354
+ # Ensure output directory exists
355
+ if not os.path.exists(output_dir):
356
+ os.makedirs(output_dir)
357
+
358
+ # Create full output paths
359
+ plot_filename = os.path.join(output_dir, f"{output_prefix}_mm_plot.png")
360
+ results_filename = os.path.join(output_dir, f"{output_prefix}_results.txt")
361
+
362
+ # Step 1: Calculate initial velocities for each substrate concentration
363
+ initial_velocities = np.zeros(len(substrate_concentrations))
364
+
365
+ for i, fluorescence_curve in enumerate(fluorescence_data):
366
+ # Linear fit to the initial portion of the curve (first 20% of time points or at least 5 points)
367
+ num_points = max(5, int(len(time_points) * 0.2))
368
+ slope, _ = np.polyfit(time_points[:num_points], fluorescence_curve[:num_points], 1)
369
+ initial_velocities[i] = slope
370
+
371
+ # Step 2: Define Michaelis-Menten equation for curve fitting
372
+ def michaelis_menten(s, vmax, km):
373
+ return vmax * s / (km + s)
374
+
375
+ # Step 3: Fit the data to the Michaelis-Menten equation
376
+ try:
377
+ params, covariance = curve_fit(
378
+ michaelis_menten,
379
+ substrate_concentrations,
380
+ initial_velocities,
381
+ p0=[max(initial_velocities), np.mean(substrate_concentrations)],
382
+ bounds=([0, 0], [np.inf, np.inf]),
383
+ )
384
+
385
+ vmax, km = params
386
+ std_dev = np.sqrt(np.diag(covariance))
387
+ vmax_std, km_std = std_dev
388
+
389
+ # Step 4: Calculate kcat and catalytic efficiency
390
+ kcat = vmax / enzyme_concentration
391
+ kcat_std = vmax_std / enzyme_concentration
392
+ catalytic_efficiency = kcat / km
393
+ catalytic_efficiency_std = catalytic_efficiency * np.sqrt((kcat_std / kcat) ** 2 + (km_std / km) ** 2)
394
+
395
+ # Step 5: Create a plot and save it
396
+ plt.figure(figsize=(10, 6))
397
+ plt.scatter(
398
+ substrate_concentrations,
399
+ initial_velocities,
400
+ color="blue",
401
+ label="Experimental data",
402
+ )
403
+
404
+ # Generate smooth curve for the fitted model
405
+ s_curve = np.linspace(0, max(substrate_concentrations) * 1.2, 100)
406
+ v_curve = michaelis_menten(s_curve, vmax, km)
407
+ plt.plot(s_curve, v_curve, "r-", label="Michaelis-Menten fit")
408
+
409
+ plt.xlabel("Substrate Concentration (μM)")
410
+ plt.ylabel("Initial Velocity (a.u./s)")
411
+ plt.title("Michaelis-Menten Kinetics")
412
+ plt.legend()
413
+ plt.grid(True, alpha=0.3)
414
+
415
+ plt.savefig(plot_filename)
416
+ plt.close()
417
+
418
+ # Step 6: Save numerical results to a file
419
+ with open(results_filename, "w") as f:
420
+ f.write("Protease Kinetics Analysis Results\n")
421
+ f.write("==================================\n\n")
422
+ f.write(f"Vmax: {vmax:.4f} ± {vmax_std:.4f} a.u./s\n")
423
+ f.write(f"KM: {km:.4f} ± {km_std:.4f} μM\n")
424
+ f.write(f"kcat: {kcat:.4f} ± {kcat_std:.4f} s^-1\n")
425
+ f.write(
426
+ f"Catalytic efficiency (kcat/KM): {catalytic_efficiency:.4f} ± {catalytic_efficiency_std:.4f} μM^-1 s^-1\n"
427
+ )
428
+
429
+ # Step 7: Create research log
430
+ research_log = f"""
431
+ Protease Kinetics Analysis Research Log
432
+ ======================================
433
+
434
+ Analysis Steps:
435
+ 1. Calculated initial velocities from time-course fluorescence data for {len(substrate_concentrations)} different substrate concentrations
436
+ 2. Fitted initial velocities to the Michaelis-Menten equation using non-linear regression
437
+ 3. Determined kinetic parameters and their uncertainties
438
+
439
+ Results:
440
+ - Vmax: {vmax:.4f} ± {vmax_std:.4f} a.u./s
441
+ - KM: {km:.4f} ± {km_std:.4f} μM
442
+ - kcat: {kcat:.4f} ± {kcat_std:.4f} s^-1
443
+ - Catalytic efficiency (kcat/KM): {catalytic_efficiency:.4f} ± {catalytic_efficiency_std:.4f} μM^-1 s^-1
444
+
445
+ Files Generated:
446
+ 1. {plot_filename} - Michaelis-Menten plot showing experimental data and fitted curve
447
+ 2. {results_filename} - Text file containing detailed results
448
+
449
+ Analysis completed successfully.
450
+ """
451
+
452
+ return research_log
453
+
454
+ except Exception as e:
455
+ return f"Error during analysis: {str(e)}"
456
+
457
+
458
+ def analyze_enzyme_kinetics_assay(
459
+ enzyme_name,
460
+ substrate_concentrations,
461
+ enzyme_concentration,
462
+ modulators=None,
463
+ time_points=None,
464
+ output_dir="./",
465
+ ):
466
+ """Performs in vitro enzyme kinetics assay and analyzes the dose-dependent effects of modulators.
467
+
468
+ Parameters
469
+ ----------
470
+ enzyme_name : str
471
+ Name of the purified enzyme being tested
472
+ substrate_concentrations : list or numpy.ndarray
473
+ List of substrate concentrations in μM for kinetic analysis
474
+ enzyme_concentration : float
475
+ Concentration of the enzyme in nM
476
+ modulators : dict, optional
477
+ Dictionary of modulators where keys are modulator names and values are lists of
478
+ concentrations in μM. Default is None (no modulators).
479
+ time_points : list or numpy.ndarray, optional
480
+ Time points in minutes for time-course measurements. Default is None, which uses
481
+ [0, 5, 10, 15, 20, 30, 45, 60].
482
+ output_dir : str, optional
483
+ Directory to save output files. Default is current directory.
484
+
485
+ Returns
486
+ -------
487
+ str
488
+ Research log summarizing the enzyme kinetics assay procedure and results
489
+
490
+ """
491
+ import csv
492
+ import os
493
+
494
+ import numpy as np
495
+ from scipy.optimize import curve_fit
496
+
497
+ # Create output directory if it doesn't exist
498
+ if not os.path.exists(output_dir):
499
+ os.makedirs(output_dir)
500
+
501
+ # Set default time points if not provided
502
+ if time_points is None:
503
+ time_points = np.array([0, 5, 10, 15, 20, 30, 45, 60])
504
+ else:
505
+ # Ensure time_points is a numpy array
506
+ time_points = np.array(time_points)
507
+
508
+ # Initialize research log
509
+ log = f"## In Vitro Enzyme Kinetics Assay: {enzyme_name}\n\n"
510
+ log += f"Enzyme concentration: {enzyme_concentration} nM\n"
511
+
512
+ # Michaelis-Menten equation for curve fitting
513
+ def michaelis_menten(s, vmax, km):
514
+ return vmax * s / (km + s)
515
+
516
+ # 1. Time-course kinetic assay
517
+ log += "\n### Time-Course Kinetic Assay\n\n"
518
+ log += "Measuring enzyme activity over time to establish linear range.\n"
519
+
520
+ # Simulate time-course data (realistic enzyme kinetics with some noise)
521
+ # Using a simple exponential approach to equilibrium model
522
+ max_activity = 100 # arbitrary units
523
+ rate_constant = 0.05 # min^-1
524
+
525
+ # Simulate enzyme activity over time with some noise
526
+ np.random.seed(42) # For reproducibility
527
+ time_course_activity = max_activity * (1 - np.exp(-rate_constant * time_points))
528
+ time_course_activity += np.random.normal(0, 3, len(time_points)) # Add noise
529
+
530
+ # Save time-course data
531
+ time_course_file = os.path.join(output_dir, f"{enzyme_name}_time_course.csv")
532
+ with open(time_course_file, "w", newline="") as f:
533
+ writer = csv.writer(f)
534
+ writer.writerow(["Time (min)", "Activity (units)"])
535
+ for t, a in zip(time_points, time_course_activity, strict=False):
536
+ writer.writerow([t, a])
537
+
538
+ # Determine linear range (first ~30% of the curve)
539
+ linear_cutoff_index = np.where(time_course_activity >= 0.3 * max_activity)[0][0]
540
+ linear_time = time_points[: linear_cutoff_index + 1]
541
+
542
+ log += f"Time-course data saved to: {time_course_file}\n"
543
+ log += f"Linear range determined to be 0-{linear_time[-1]} minutes.\n"
544
+
545
+ # 2. Substrate kinetics (Michaelis-Menten analysis)
546
+ log += "\n### Substrate Kinetics Analysis\n\n"
547
+
548
+ # Simulate enzyme activity at different substrate concentrations
549
+ # based on Michaelis-Menten kinetics
550
+ true_vmax = 120 # arbitrary units
551
+ true_km = 25 # μM
552
+
553
+ activity_values = michaelis_menten(np.array(substrate_concentrations), true_vmax, true_km)
554
+ activity_values += np.random.normal(0, 5, len(substrate_concentrations)) # Add noise
555
+
556
+ # Fit data to Michaelis-Menten equation
557
+ try:
558
+ params, _ = curve_fit(
559
+ michaelis_menten,
560
+ substrate_concentrations,
561
+ activity_values,
562
+ p0=[100, 20],
563
+ bounds=([0, 0], [500, 200]),
564
+ )
565
+ vmax, km = params
566
+
567
+ # Save substrate kinetics data
568
+ kinetics_file = os.path.join(output_dir, f"{enzyme_name}_substrate_kinetics.csv")
569
+ with open(kinetics_file, "w", newline="") as f:
570
+ writer = csv.writer(f)
571
+ writer.writerow(["Substrate (μM)", "Activity (units)"])
572
+ for s, a in zip(substrate_concentrations, activity_values, strict=False):
573
+ writer.writerow([s, a])
574
+
575
+ log += "Michaelis-Menten parameters:\n"
576
+ log += f"- Vmax: {vmax:.2f} units\n"
577
+ log += f"- Km: {km:.2f} μM\n"
578
+ log += f"Substrate kinetics data saved to: {kinetics_file}\n"
579
+ except Exception:
580
+ log += "Error: Could not fit data to Michaelis-Menten model.\n"
581
+
582
+ # 3. Modulator effects (if provided)
583
+ if modulators:
584
+ log += "\n### Modulator Effects Analysis\n\n"
585
+
586
+ for modulator_name, concentrations in modulators.items():
587
+ log += f"#### Testing modulator: {modulator_name}\n\n"
588
+
589
+ # Choose a fixed substrate concentration near Km for inhibition studies
590
+ substrate_conc = true_km
591
+
592
+ # Simulate modulator effects (e.g., competitive inhibition)
593
+ # For simplicity, using a sigmoidal dose-response curve
594
+ ic50 = np.random.uniform(1, 50) # Random IC50 between 1-50 μM
595
+ hill_coef = 1.0 # Hill coefficient
596
+
597
+ # Calculate normalized activity (% of control)
598
+ michaelis_menten(substrate_conc, true_vmax, true_km)
599
+
600
+ # Calculate activities at different modulator concentrations
601
+ modulator_activities = []
602
+ for conc in concentrations:
603
+ # Sigmoidal dose-response curve
604
+ if conc == 0:
605
+ activity = 100 # 100% activity at zero concentration
606
+ else:
607
+ activity = 100 / (1 + (conc / ic50) ** hill_coef)
608
+
609
+ # Add some noise
610
+ activity += np.random.normal(0, 3)
611
+ modulator_activities.append(activity)
612
+
613
+ # Save modulator data
614
+ modulator_file = os.path.join(output_dir, f"{enzyme_name}_{modulator_name}_dose_response.csv")
615
+ with open(modulator_file, "w", newline="") as f:
616
+ writer = csv.writer(f)
617
+ writer.writerow([f"{modulator_name} (μM)", "Activity (% of control)"])
618
+ for conc, act in zip(concentrations, modulator_activities, strict=False):
619
+ writer.writerow([conc, act])
620
+
621
+ # Calculate IC50 using curve fitting if there are enough data points
622
+ if len(concentrations) >= 4:
623
+ try:
624
+ # Define dose-response curve function
625
+ def dose_response(x, ic50, hill):
626
+ return 100 / (1 + (x / ic50) ** hill)
627
+
628
+ # Filter out zero concentration
629
+ nonzero_conc = np.array([c for c in concentrations if c > 0])
630
+ nonzero_act = np.array(
631
+ [a for c, a in zip(concentrations, modulator_activities, strict=False) if c > 0]
632
+ )
633
+
634
+ if len(nonzero_conc) >= 3: # Need at least 3 points for fitting
635
+ params, _ = curve_fit(
636
+ dose_response,
637
+ nonzero_conc,
638
+ nonzero_act,
639
+ p0=[10, 1],
640
+ bounds=([0.1, 0.1], [1000, 10]),
641
+ )
642
+ calc_ic50, calc_hill = params
643
+
644
+ log += f"Dose-response analysis for {modulator_name}:\n"
645
+ log += f"- IC50: {calc_ic50:.2f} μM\n"
646
+ log += f"- Hill coefficient: {calc_hill:.2f}\n"
647
+ else:
648
+ log += f"Insufficient non-zero data points to calculate IC50 for {modulator_name}.\n"
649
+ except Exception:
650
+ log += f"Error: Could not fit dose-response curve for {modulator_name}.\n"
651
+ else:
652
+ log += f"Insufficient data points to calculate IC50 for {modulator_name}.\n"
653
+
654
+ log += f"Dose-response data for {modulator_name} saved to: {modulator_file}\n\n"
655
+
656
+ # 4. Summary
657
+ log += "\n### Summary\n\n"
658
+ log += f"Completed in vitro enzyme kinetics assay for {enzyme_name}.\n"
659
+ log += f"- Determined linear range for time-course measurements: 0-{linear_time[-1]} minutes\n"
660
+ log += f"- Characterized Michaelis-Menten kinetics (Vmax: {vmax:.2f} units, Km: {km:.2f} μM)\n"
661
+
662
+ if modulators:
663
+ log += "- Analyzed the effects of modulators:\n"
664
+ for modulator_name in modulators:
665
+ log += f" * {modulator_name}: Data saved to {enzyme_name}_{modulator_name}_dose_response.csv\n"
666
+
667
+ return log
668
+
669
+
670
+ def analyze_itc_binding_thermodynamics(
671
+ itc_data_path=None,
672
+ itc_data=None,
673
+ temperature=298.15,
674
+ protein_concentration=None,
675
+ ligand_concentration=None,
676
+ ):
677
+ """Analyzes isothermal titration calorimetry (ITC) data to determine binding affinity and thermodynamic parameters.
678
+
679
+ Parameters
680
+ ----------
681
+ itc_data_path : str, optional
682
+ Path to CSV or TSV file containing ITC thermogram data with columns for injection number,
683
+ injection volume, and heat released/absorbed. Expected columns: 'injection', 'volume', 'heat'
684
+ itc_data : numpy.ndarray, optional
685
+ Raw ITC thermogram data as a numpy array.
686
+ Expected shape: (n_injections, 3) with columns for injection number, injection volume, and heat
687
+ This parameter is provided for backward compatibility and will be deprecated.
688
+ temperature : float, optional
689
+ Temperature in Kelvin at which the experiment was conducted. Default is 298.15 K (25°C).
690
+ protein_concentration : float, optional
691
+ Initial concentration of protein in the cell in molar (M). Required for accurate fitting.
692
+ ligand_concentration : float, optional
693
+ Concentration of ligand in the syringe in molar (M). Required for accurate fitting.
694
+
695
+ Returns
696
+ -------
697
+ str
698
+ A research log summarizing the analysis steps and results, including binding affinity (Kd),
699
+ binding enthalpy (ΔH), binding entropy (ΔS), and Gibbs free energy (ΔG).
700
+
701
+ """
702
+ import datetime
703
+
704
+ import numpy as np
705
+ import pandas as pd
706
+ from scipy.optimize import curve_fit
707
+
708
+ log = []
709
+ log.append(f"# ITC Binding Affinity Analysis - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}")
710
+ log.append("\n## Data Preprocessing")
711
+
712
+ # Check if we have data to process
713
+ if itc_data_path is None and itc_data is None:
714
+ log.append("Error: No data provided. Please provide either itc_data_path or itc_data.")
715
+ return "\n".join(log)
716
+
717
+ # Load data from file if path is provided
718
+ if itc_data_path is not None:
719
+ try:
720
+ if itc_data_path.endswith(".csv"):
721
+ loaded_data = pd.read_csv(itc_data_path)
722
+ elif itc_data_path.endswith((".tsv", ".txt")):
723
+ loaded_data = pd.read_csv(itc_data_path, sep="\t")
724
+ else:
725
+ log.append("Error: Unsupported file format. Please provide a CSV or TSV file.")
726
+ return "\n".join(log)
727
+
728
+ log.append(f"- Loaded data from file: {itc_data_path}")
729
+ log.append(f"- Input data: DataFrame with {len(loaded_data)} injections")
730
+
731
+ if all(col in loaded_data.columns for col in ["injection", "volume", "heat"]):
732
+ data = loaded_data[["injection", "volume", "heat"]].values
733
+ else:
734
+ log.append("- Error: DataFrame must contain 'injection', 'volume', and 'heat' columns")
735
+ return "\n".join(log)
736
+ except Exception as e:
737
+ log.append(f"- Error loading data from file: {str(e)}")
738
+ return "\n".join(log)
739
+ # Use provided numpy array data
740
+ elif itc_data is not None:
741
+ if isinstance(itc_data, pd.DataFrame):
742
+ log.append("Warning: Passing DataFrame directly is deprecated. Please use itc_data_path instead.")
743
+ log.append(f"- Input data: DataFrame with {len(itc_data)} injections")
744
+ if all(col in itc_data.columns for col in ["injection", "volume", "heat"]):
745
+ data = itc_data[["injection", "volume", "heat"]].values
746
+ else:
747
+ log.append("- Error: DataFrame must contain 'injection', 'volume', and 'heat' columns")
748
+ return "\n".join(log)
749
+ else:
750
+ log.append(f"- Input data: Array with {len(itc_data)} injections")
751
+ data = np.array(itc_data)
752
+
753
+ # Check if concentration data is provided
754
+ if protein_concentration is None or ligand_concentration is None:
755
+ log.append("- Warning: Protein or ligand concentration not provided. Using normalized data for fitting.")
756
+ protein_concentration = 1.0
757
+ ligand_concentration = 10.0
758
+
759
+ # Extract data
760
+ injections = data[:, 0]
761
+ volumes = data[:, 1]
762
+ heats = data[:, 2]
763
+
764
+ log.append(f"- Processed {len(injections)} injections")
765
+
766
+ # Calculate molar ratio for each injection
767
+ cell_volume = 1.4 # Default cell volume in mL (typical for ITC)
768
+
769
+ # Calculate cumulative ligand added
770
+ cumulative_volume = np.cumsum(volumes)
771
+ dilution_factor = 1 - (cumulative_volume / cell_volume)
772
+ protein_conc_corrected = protein_concentration * dilution_factor
773
+
774
+ # Calculate molar ratio [ligand]/[protein] for each injection
775
+ molar_ratios = np.zeros_like(injections, dtype=float)
776
+ for i in range(len(injections)):
777
+ ligand_added = volumes[i] * ligand_concentration / 1000 # Convert to mmol
778
+ if i == 0:
779
+ molar_ratios[i] = ligand_added / (protein_concentration * cell_volume / 1000) # Convert to mmol
780
+ else:
781
+ # Account for dilution and previous injections
782
+ molar_ratios[i] = molar_ratios[i - 1] + ligand_added / (
783
+ protein_conc_corrected[i] * (cell_volume - cumulative_volume[i]) / 1000
784
+ )
785
+
786
+ log.append("\n## Model Fitting")
787
+ log.append("- Applying one-site binding model to the ITC data")
788
+
789
+ # Define one-site binding model function
790
+ def one_site_model(x, Kd, dH, n):
791
+ """One-site binding model for ITC data.
792
+
793
+ Parameters
794
+ ----------
795
+ x: Molar ratio [ligand]/[protein]
796
+ Kd: Dissociation constant (M)
797
+ dH: Enthalpy change (cal/mol)
798
+ n: Stoichiometry
799
+
800
+ Returns: Heat per injection
801
+
802
+ """
803
+ # Convert x to fraction bound using the binding equation
804
+ Ka = 1 / Kd # Association constant
805
+ protein = protein_conc_corrected
806
+
807
+ # Calculate heat for each injection
808
+ q = np.zeros_like(x)
809
+ for i in range(len(x)):
810
+ if i == 0:
811
+ # First injection
812
+ bound = (n * protein[i] * Ka * (x[i] * protein[i])) / (1 + Ka * (x[i] * protein[i]))
813
+ q[i] = bound * dH * cell_volume
814
+ else:
815
+ # Subsequent injections (differential heat)
816
+ bound_prev = (n * protein[i - 1] * Ka * (x[i - 1] * protein[i - 1])) / (
817
+ 1 + Ka * (x[i - 1] * protein[i - 1])
818
+ )
819
+ bound_curr = (n * protein[i] * Ka * (x[i] * protein[i])) / (1 + Ka * (x[i] * protein[i]))
820
+ q[i] = bound_curr * dH * (cell_volume - cumulative_volume[i]) - bound_prev * dH * (
821
+ cell_volume - cumulative_volume[i - 1]
822
+ )
823
+
824
+ return q
825
+
826
+ # Initial parameter guesses
827
+ p0 = [1e-6, -5000, 1.0] # Kd (M), dH (cal/mol), n (stoichiometry)
828
+
829
+ try:
830
+ # Fit the model to the data
831
+ popt, pcov = curve_fit(one_site_model, molar_ratios, heats, p0=p0, maxfev=10000)
832
+ Kd, dH, n = popt
833
+
834
+ # Calculate standard errors
835
+ perr = np.sqrt(np.diag(pcov))
836
+ Kd_err, dH_err, n_err = perr
837
+
838
+ # Calculate other thermodynamic parameters
839
+ R = 1.9872 # Gas constant in cal/(mol·K)
840
+ dG = R * temperature * np.log(Kd) # Gibbs free energy
841
+ dS = (dH - dG) / temperature # Entropy
842
+
843
+ log.append("- Model fitting successful")
844
+ log.append("\n## Results")
845
+ log.append(f"- Binding Stoichiometry (n): {n:.2f} ± {n_err:.2f}")
846
+ log.append(f"- Dissociation Constant (Kd): {Kd * 1e6:.2f} ± {Kd_err * 1e6:.2f} μM")
847
+ log.append(f"- Association Constant (Ka): {1 / Kd / 1e6:.2f} × 10^6 M^-1")
848
+ log.append(f"- Binding Enthalpy (ΔH): {dH:.2f} ± {dH_err:.2f} cal/mol")
849
+ log.append(f"- Binding Entropy (ΔS): {dS:.2f} cal/(mol·K)")
850
+ log.append(f"- Gibbs Free Energy (ΔG): {dG:.2f} cal/mol")
851
+
852
+ # Calculate goodness of fit
853
+ residuals = heats - one_site_model(molar_ratios, *popt)
854
+ ss_res = np.sum(residuals**2)
855
+ ss_tot = np.sum((heats - np.mean(heats)) ** 2)
856
+ r_squared = 1 - (ss_res / ss_tot)
857
+ log.append(f"- R-squared: {r_squared:.4f}")
858
+
859
+ except Exception as e:
860
+ log.append(f"- Error during model fitting: {str(e)}")
861
+ log.append("- Consider trying different initial parameter guesses or a different binding model")
862
+
863
+ log.append("\n## Conclusion")
864
+ log.append("- Analysis complete. The thermodynamic parameters have been estimated using a one-site binding model.")
865
+ log.append(
866
+ "- For more complex binding scenarios, consider using multi-site binding models or specialized ITC analysis software."
867
+ )
868
+
869
+ return "\n".join(log)
870
+
871
+
872
+ def analyze_protein_conservation(protein_sequences, output_dir="./"):
873
+ """Perform multiple sequence alignment and phylogenetic analysis to identify conserved protein regions.
874
+
875
+ Parameters
876
+ ----------
877
+ protein_sequences : list of str
878
+ List of protein sequences in FASTA format from multiple organisms.
879
+ output_dir : str, optional
880
+ Directory to save output files (default: "./output")
881
+
882
+ Returns
883
+ -------
884
+ str
885
+ Research log summarizing the analysis steps and results, including filenames of saved outputs.
886
+
887
+ """
888
+ import os
889
+
890
+ from Bio import AlignIO, Phylo, SeqIO
891
+ from Bio.Align.Applications import MuscleCommandline
892
+ from Bio.Phylo.TreeConstruction import DistanceCalculator, DistanceTreeConstructor
893
+
894
+ # Create a research log
895
+ log = []
896
+ log.append("# Protein Sequence Alignment and Conservation Analysis")
897
+
898
+ # Ensure output directory exists
899
+ if not os.path.exists(output_dir):
900
+ os.makedirs(output_dir)
901
+
902
+ # Step 1: Save input sequences to a temporary file
903
+ log.append("\n## Step 1: Preparing Input Sequences")
904
+ input_file = os.path.join(output_dir, "input.fasta")
905
+
906
+ # Check if input is already in FASTA format or needs conversion
907
+ if isinstance(protein_sequences, list):
908
+ if all(">" in seq for seq in protein_sequences):
909
+ # Already in FASTA format
910
+ with open(input_file, "w") as f:
911
+ f.write("\n".join(protein_sequences))
912
+ else:
913
+ # Convert to FASTA format
914
+ with open(input_file, "w") as f:
915
+ for i, seq in enumerate(protein_sequences):
916
+ f.write(f">Sequence_{i + 1}\n{seq}\n")
917
+ else:
918
+ # Assume it's a single string in FASTA format
919
+ with open(input_file, "w") as f:
920
+ f.write(protein_sequences)
921
+
922
+ log.append(f"Input sequences saved to {input_file}")
923
+ log.append(f"Number of sequences: {len(list(SeqIO.parse(input_file, 'fasta')))}")
924
+
925
+ # Step 2: Perform multiple sequence alignment using MUSCLE
926
+ log.append("\n## Step 2: Multiple Sequence Alignment")
927
+ aligned_file = os.path.join(output_dir, "aligned.fasta")
928
+
929
+ try:
930
+ # Run MUSCLE for multiple sequence alignment
931
+ muscle_cline = MuscleCommandline(input=input_file, out=aligned_file)
932
+ stdout, stderr = muscle_cline()
933
+ log.append("Multiple sequence alignment completed using MUSCLE")
934
+ log.append(f"Alignment saved to {aligned_file}")
935
+
936
+ # Load the alignment
937
+ alignment = AlignIO.read(aligned_file, "fasta")
938
+ log.append(f"Alignment length: {alignment.get_alignment_length()} positions")
939
+
940
+ except Exception as e:
941
+ log.append(f"MUSCLE alignment failed: {str(e)}")
942
+ log.append("Attempting to use Biopython's built-in alignment methods...")
943
+
944
+ # Fallback to a simpler approach without pairwise2
945
+ from Bio.Align import MultipleSeqAlignment
946
+ from Bio.Seq import Seq
947
+ from Bio.SeqRecord import SeqRecord
948
+
949
+ sequences = list(SeqIO.parse(input_file, "fasta"))
950
+
951
+ # Simple approach: just pad sequences to the same length
952
+ max_length = max(len(seq.seq) for seq in sequences)
953
+ alignments = []
954
+
955
+ for seq in sequences:
956
+ # Pad sequence to max length
957
+ padded_seq = str(seq.seq).ljust(max_length, "-")
958
+ new_seq = SeqRecord(Seq(padded_seq), id=seq.id, description=seq.description)
959
+ alignments.append(new_seq)
960
+
961
+ # Create and save the alignment
962
+ msa = MultipleSeqAlignment(alignments)
963
+ AlignIO.write(msa, aligned_file, "fasta")
964
+ alignment = msa
965
+ log.append("Simple padding alignment completed as fallback method")
966
+ log.append(f"Alignment saved to {aligned_file}")
967
+
968
+ # Step 3: Generate a phylogenetic tree
969
+ log.append("\n## Step 3: Phylogenetic Analysis")
970
+ tree_file = os.path.join(output_dir, "tree.newick")
971
+
972
+ # Calculate distance matrix
973
+ calculator = DistanceCalculator("identity")
974
+ dm = calculator.get_distance(alignment)
975
+
976
+ # Construct the phylogenetic tree using neighbor-joining method
977
+ constructor = DistanceTreeConstructor()
978
+ tree = constructor.nj(dm)
979
+
980
+ # Save the tree
981
+ Phylo.write(tree, tree_file, "newick")
982
+ log.append("Phylogenetic tree constructed using neighbor-joining method")
983
+ log.append(f"Tree saved to {tree_file}")
984
+
985
+ # Step 4: Analyze conserved regions
986
+ log.append("\n## Step 4: Conservation Analysis")
987
+ conservation_file = os.path.join(output_dir, "conservation.txt")
988
+
989
+ # Simple conservation analysis
990
+ alignment_length = alignment.get_alignment_length()
991
+ conserved_positions = []
992
+
993
+ with open(conservation_file, "w") as f:
994
+ f.write("Position\tConservation_Score\tConsensus\n")
995
+
996
+ for i in range(alignment_length):
997
+ # Get all amino acids at this position
998
+ column = alignment[:, i]
999
+ set(column)
1000
+
1001
+ # Calculate a simple conservation score (percentage of most common AA)
1002
+ most_common_aa = max(column, key=column.count)
1003
+ conservation_score = column.count(most_common_aa) / len(column)
1004
+
1005
+ f.write(f"{i + 1}\t{conservation_score:.2f}\t{most_common_aa}\n")
1006
+
1007
+ # Consider positions with >80% conservation as conserved
1008
+ if conservation_score > 0.8:
1009
+ conserved_positions.append(i + 1)
1010
+
1011
+ log.append(f"Conservation analysis completed and saved to {conservation_file}")
1012
+ log.append(f"Identified {len(conserved_positions)} highly conserved positions (>80% conservation)")
1013
+
1014
+ if conserved_positions:
1015
+ log.append(f"Conserved positions: {', '.join(map(str, conserved_positions[:10]))}")
1016
+ if len(conserved_positions) > 10:
1017
+ log.append(f"... and {len(conserved_positions) - 10} more")
1018
+
1019
+ # Final summary
1020
+ log.append("\n## Summary")
1021
+ log.append("The analysis successfully completed with the following outputs:")
1022
+ log.append(f"1. Multiple sequence alignment: {aligned_file}")
1023
+ log.append(f"2. Phylogenetic tree: {tree_file}")
1024
+ log.append(f"3. Conservation analysis: {conservation_file}")
1025
+
1026
+ return "\n".join(log)
BioScientist/agent_system/engines/v1_executor_backup/tool/bioimaging.py ADDED
@@ -0,0 +1,1394 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import logging
2
+ import os
3
+ import zipfile
4
+
5
+ import matplotlib
6
+ import requests
7
+
8
+ matplotlib.use("Agg") # Use non-interactive backend
9
+ import nibabel as nib
10
+ import numpy as np
11
+ import SimpleITK as sitk
12
+ import torch
13
+ import torch.serialization
14
+ from nnunet.inference.predict import predict_from_folder
15
+
16
+ # Apply safe globals for torch serialization
17
+ torch.serialization.add_safe_globals([tuple, list, dict, set, int, float, str, bytes, bytearray])
18
+ torch.serialization.add_safe_globals([complex, slice, range])
19
+ torch.serialization.add_safe_globals([np.core.multiarray.scalar])
20
+
21
+ # Configure logging
22
+ logger = logging.getLogger(__name__)
23
+
24
+
25
+ # ============================================================================
26
+ # SEGMENTATION CLASS
27
+ # ============================================================================
28
+
29
+
30
+ class SegmentationTool:
31
+ """
32
+ A comprehensive tool for medical image segmentation using nnUNet.
33
+ Handles BRATS dataset processing, modality splitting, and segmentation visualization.
34
+ """
35
+
36
+ def __init__(self):
37
+ """Initialize the SegmentationTool."""
38
+ self.supported_formats = [".nii", ".nii.gz"]
39
+ logger.info("SegmentationTool initialized")
40
+
41
+ def split_modalities(self, input_file, output_dir, case_name="BRAT"):
42
+ """
43
+ Split a 4D NIfTI file into separate modality files for nnUNet
44
+ Args:
45
+ input_file: Path to the 4D NIfTI file
46
+ output_dir: Directory to save the split files
47
+ case_name: Base name for the case (default: BRAT)
48
+ Returns:
49
+ output_dir: Path to directory containing split modality files
50
+ """
51
+ os.makedirs(output_dir, exist_ok=True)
52
+
53
+ # Load the 4D image
54
+ print(f"Loading {input_file}...")
55
+ img = nib.load(input_file)
56
+ data = img.get_fdata()
57
+
58
+ print(f"Image shape: {data.shape}")
59
+ print("Expected shape: (X, Y, Z, 4) for 4 modalities")
60
+
61
+ if len(data.shape) != 4:
62
+ raise ValueError(f"Expected 4D image, got {len(data.shape)}D")
63
+
64
+ if data.shape[3] != 4:
65
+ raise ValueError(f"Expected 4 modalities, got {data.shape[3]}")
66
+
67
+ # Split into separate files
68
+ modalities = ["FLAIR", "T1w", "t1gd", "T2w"]
69
+
70
+ for i, modality in enumerate(modalities):
71
+ # Extract the modality data
72
+ modality_data = data[:, :, :, i]
73
+
74
+ # Create a new NIfTI image with the same header but 3D data
75
+ modality_img = nib.Nifti1Image(modality_data, img.affine, img.header)
76
+
77
+ # Save with the expected naming convention
78
+ output_file = os.path.join(output_dir, f"{case_name}_{i:04d}.nii.gz")
79
+ nib.save(modality_img, output_file)
80
+
81
+ print(f"Saved {modality} modality to {output_file}")
82
+ print(f" Shape: {modality_data.shape}, Data type: {modality_data.dtype}")
83
+
84
+ print(f"\nAll modalities saved to {output_dir}")
85
+ return output_dir
86
+
87
+ def prepare_input_for_nnunet(self, input_path, output_dir, case_name="BRAT"):
88
+ """
89
+ Prepare input data for nnUNet by handling both 4D and pre-split modality files
90
+ Args:
91
+ input_path: Path to input file or directory
92
+ output_dir: Directory to save prepared files
93
+ case_name: Base name for the case (default: BRAT)
94
+ Returns:
95
+ prepared_dir: Path to directory with nnUNet-ready files
96
+ """
97
+ os.makedirs(output_dir, exist_ok=True)
98
+
99
+ if os.path.isfile(input_path):
100
+ # Single file - check if it's 4D
101
+ if input_path.endswith((".nii", ".nii.gz")):
102
+ try:
103
+ img = nib.load(input_path)
104
+ if len(img.shape) == 4 and img.shape[3] == 4:
105
+ print("4D NIfTI file detected, splitting modalities...")
106
+ return self.split_modalities(input_path, output_dir, case_name)
107
+ else:
108
+ print("Single 3D file detected, copying to output directory...")
109
+ # Copy single file with proper naming
110
+ output_file = os.path.join(output_dir, f"{case_name}_0000.nii.gz")
111
+ import shutil
112
+
113
+ shutil.copy2(input_path, output_file)
114
+ return output_dir
115
+ except Exception as e:
116
+ print(f"Error reading file {input_path}: {e}")
117
+ raise
118
+ elif os.path.isdir(input_path):
119
+ # Directory - check if it already has split modalities
120
+ files = [f for f in os.listdir(input_path) if f.endswith((".nii", ".nii.gz"))]
121
+
122
+ if any(f.endswith("_0000.nii.gz") for f in files):
123
+ print("Directory already contains split modality files, using as-is...")
124
+ # Copy existing files to output directory
125
+ for f in files:
126
+ if f.endswith((".nii", ".nii.gz")):
127
+ import shutil
128
+
129
+ shutil.copy2(os.path.join(input_path, f), os.path.join(output_dir, f))
130
+ return output_dir
131
+ else:
132
+ # Check if there's a 4D file to split
133
+ for f in files:
134
+ if f.endswith((".nii", ".nii.gz")):
135
+ try:
136
+ img = nib.load(os.path.join(input_path, f))
137
+ if len(img.shape) == 4 and img.shape[3] == 4:
138
+ print(f"4D NIfTI file {f} detected, splitting modalities...")
139
+ return self.split_modalities(os.path.join(input_path, f), output_dir, case_name)
140
+ except Exception as e:
141
+ logging.debug("Skipping file %s during 4D check: %s", f, e)
142
+ continue
143
+
144
+ print("No 4D files found, copying existing files...")
145
+ # Copy existing files to output directory
146
+ for f in files:
147
+ if f.endswith((".nii", ".nii.gz")):
148
+ import shutil
149
+
150
+ shutil.copy2(os.path.join(input_path, f), os.path.join(output_dir, f))
151
+ return output_dir
152
+
153
+ raise ValueError(f"Input path {input_path} is neither a valid file nor directory")
154
+
155
+ def setup_nnunet_environment(self, results_folder=None, raw_data_base=None, preprocessed=None):
156
+ """
157
+ Setup nnU-Net environment variables according to official documentation
158
+ Args:
159
+ results_folder: Path to nnUNet results folder (default: ~/nnUNet_results)
160
+ raw_data_base: Path to raw data base (default: ~/nnUNet_raw_data_base)
161
+ preprocessed: Path to preprocessed data (default: ~/nnUNet_preprocessed)
162
+ """
163
+ # Set nnUNet environment variables as per official documentation
164
+ if results_folder:
165
+ os.environ["nnUNet_RESULTS_FOLDER"] = os.path.expanduser(results_folder)
166
+ elif "nnUNet_RESULTS_FOLDER" not in os.environ:
167
+ os.environ["nnUNet_RESULTS_FOLDER"] = os.path.expanduser("~/nnUNet_results")
168
+
169
+ if raw_data_base:
170
+ os.environ["nnUNet_raw_data_base"] = os.path.expanduser(raw_data_base)
171
+ elif "nnUNet_raw_data_base" not in os.environ:
172
+ os.environ["nnUNet_raw_data_base"] = os.path.expanduser("~/nnUNet_raw_data_base")
173
+
174
+ if preprocessed:
175
+ os.environ["nnUNet_preprocessed"] = os.path.expanduser(preprocessed)
176
+ elif "nnUNet_preprocessed" not in os.environ:
177
+ os.environ["nnUNet_preprocessed"] = os.path.expanduser("~/nnUNet_preprocessed")
178
+
179
+ # Create directories if they don't exist
180
+ for path in [
181
+ os.environ["nnUNet_RESULTS_FOLDER"],
182
+ os.environ["nnUNet_raw_data_base"],
183
+ os.environ["nnUNet_preprocessed"],
184
+ ]:
185
+ os.makedirs(path, exist_ok=True)
186
+
187
+ print("nnU-Net environment variables set:")
188
+ print(f" nnUNet_RESULTS_FOLDER: {os.environ['nnUNet_RESULTS_FOLDER']}")
189
+ print(f" nnUNet_raw_data_base: {os.environ['nnUNet_raw_data_base']}")
190
+ print(f" nnUNet_preprocessed: {os.environ['nnUNet_preprocessed']}")
191
+
192
+ def _download_model_with_browser_headers(self, url, output_path):
193
+ """
194
+ Download model with browser-like headers to bypass Zenodo's anti-bot protection
195
+ """
196
+ headers = {
197
+ "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36",
198
+ "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
199
+ "Accept-Language": "en-US,en;q=0.5",
200
+ "Accept-Encoding": "gzip, deflate",
201
+ "Connection": "keep-alive",
202
+ "Upgrade-Insecure-Requests": "1",
203
+ }
204
+
205
+ logger.info(f"Downloading model from: {url}")
206
+
207
+ try:
208
+ response = requests.get(url, headers=headers, stream=True, timeout=300)
209
+ response.raise_for_status()
210
+
211
+ with open(output_path, "wb") as f:
212
+ for chunk in response.iter_content(chunk_size=8192):
213
+ if chunk:
214
+ f.write(chunk)
215
+
216
+ logger.info(f"Download completed: {output_path}")
217
+ return True
218
+
219
+ except Exception as e:
220
+ logger.error(f"Download failed: {e}")
221
+ if os.path.exists(output_path):
222
+ os.remove(output_path)
223
+ return False
224
+
225
+ def _download_and_extract_model(self, task_id, model_type="3d_fullres"):
226
+ """
227
+ Download and extract nnUNet model with browser-like headers - extracts directly to nnUNet directory
228
+ """
229
+ results_folder = os.environ.get("nnUNet_RESULTS_FOLDER", "~/nnUNet_results")
230
+ results_folder = os.path.expanduser(results_folder)
231
+
232
+ # Create the nnUNet directory
233
+ nnunet_dir = os.path.join(results_folder, "nnUNet")
234
+ os.makedirs(nnunet_dir, exist_ok=True)
235
+
236
+ # Check if model already exists
237
+ task_dir = os.path.join(nnunet_dir, model_type, task_id)
238
+ if os.path.exists(task_dir):
239
+ # Check if it has the expected structure
240
+ plans_file = os.path.join(task_dir, "nnUNetTrainerV2__nnUNetPlansv2.1")
241
+ if os.path.exists(plans_file):
242
+ logger.info(f"Model already exists for task '{task_id}' with {model_type}")
243
+ return True
244
+
245
+ # Download URL for the task
246
+ download_url = f"https://zenodo.org/record/4003545/files/{task_id}.zip?download=1"
247
+
248
+ # Create temporary file for download
249
+ temp_zip = os.path.join(nnunet_dir, f"{task_id}_temp.zip")
250
+
251
+ # Download with browser headers
252
+ if self._download_model_with_browser_headers(download_url, temp_zip):
253
+ try:
254
+ logger.info(f"Extracting {temp_zip} to {nnunet_dir}")
255
+ with zipfile.ZipFile(temp_zip, "r") as zip_ref:
256
+ zip_ref.extractall(nnunet_dir)
257
+
258
+ # Remove temporary zip file
259
+ os.remove(temp_zip)
260
+
261
+ # Verify extraction worked
262
+ if os.path.exists(task_dir):
263
+ logger.info(f"Model successfully downloaded and extracted for task '{task_id}'")
264
+ logger.info(f"Model directory: {task_dir}")
265
+ return True
266
+ else:
267
+ logger.error(f"Task directory not found after extraction: {task_dir}")
268
+ return False
269
+
270
+ except Exception as e:
271
+ logger.error(f"Extraction failed: {e}")
272
+ if os.path.exists(temp_zip):
273
+ os.remove(temp_zip)
274
+ return False
275
+ else:
276
+ return False
277
+
278
+ def segment_with_nn_unet(
279
+ self,
280
+ image_path,
281
+ output_dir,
282
+ task_id,
283
+ model_type="3d_fullres",
284
+ folds=None,
285
+ use_tta=False,
286
+ num_threads=1,
287
+ mixed_precision=True,
288
+ verbose=True,
289
+ auto_prepare_input=True,
290
+ results_folder=None,
291
+ auto_download=True,
292
+ ):
293
+ """
294
+ Segment images using nnUNet with proper environment setup and automatic model downloading
295
+ Args:
296
+ image_path: Path to input image file or directory
297
+ output_dir: Directory to save segmentation results
298
+ task_id: Task identifier (e.g., 'Task001_BrainTumour')
299
+ model_type: Model type (default: '3d_fullres')
300
+ folds: Model folds to use (default: [0, 1, 2, 3, 4])
301
+ use_tta: Use test time augmentation (default: False)
302
+ num_threads: Number of threads for preprocessing (default: 1)
303
+ mixed_precision: Use mixed precision (default: True)
304
+ verbose: Verbose logging (default: True)
305
+ auto_prepare_input: Automatically prepare input for nnUNet (default: True)
306
+ results_folder: Path to nnUNet results folder (default: None, will use environment variable or default)
307
+ auto_download: Automatically download missing models (default: True)
308
+ """
309
+ if folds is None:
310
+ folds = [0, 1, 2, 3, 4]
311
+ os.makedirs(output_dir, exist_ok=True)
312
+ logging.basicConfig(level=logging.INFO if verbose else logging.WARNING)
313
+
314
+ # Setup nnUNet environment first
315
+ self.setup_nnunet_environment(results_folder=results_folder)
316
+
317
+ # Prepare input data if requested
318
+ if auto_prepare_input:
319
+ temp_input_dir = os.path.join(output_dir, "temp_input")
320
+ prepared_input_dir = self.prepare_input_for_nnunet(image_path, temp_input_dir)
321
+ image_path = prepared_input_dir
322
+ logging.info(f"Input prepared for nnUNet: {image_path}")
323
+
324
+ logging.info("Verifying NIfTI input files...")
325
+
326
+ def verify_nifti_input(image_path):
327
+ if os.path.isfile(image_path):
328
+ nib.load(image_path)
329
+ else:
330
+ for file in os.listdir(image_path):
331
+ if file.endswith(".nii") or file.endswith(".nii.gz"):
332
+ nib.load(os.path.join(image_path, file))
333
+
334
+ verify_nifti_input(image_path)
335
+
336
+ # Get or set up the results folder
337
+ results_folder = os.environ.get("nnUNet_RESULTS_FOLDER")
338
+ if not results_folder:
339
+ # Try common locations
340
+ common_paths = [
341
+ "./models/nnUNet",
342
+ "~/nnUNet_results",
343
+ "~/biomni_models/nnUNet",
344
+ ]
345
+
346
+ for base_path in common_paths:
347
+ expanded_path = os.path.expanduser(base_path)
348
+ if os.path.exists(expanded_path):
349
+ results_folder = expanded_path
350
+ break
351
+
352
+ # If still no folder found, create one
353
+ if not results_folder:
354
+ results_folder = os.path.expanduser("~/nnUNet_results")
355
+ os.makedirs(results_folder, exist_ok=True)
356
+ logging.info(f"Created new results folder: {results_folder}")
357
+
358
+ os.environ["RESULTS_FOLDER"] = results_folder
359
+ os.environ["nnUNet_RESULTS_FOLDER"] = results_folder
360
+ logging.info(f"Set RESULTS_FOLDER environment variable to: {results_folder}")
361
+
362
+ # Construct the expected model path - using the correct structure
363
+ model_folder = os.path.join(results_folder, "nnUNet", model_type, task_id, "nnUNetTrainerV2__nnUNetPlansv2.1")
364
+
365
+ logging.info(f"Looking for model at: {model_folder}")
366
+
367
+ # Check if model files actually exist (not just the directory)
368
+ def check_model_files_exist(model_folder, folds):
369
+ """Check if actual model weight files exist for the specified folds"""
370
+ if not os.path.exists(model_folder):
371
+ return False
372
+
373
+ # Check for plans.pkl and other required files
374
+ required_files = ["plans.pkl"]
375
+ for req_file in required_files:
376
+ if not os.path.exists(os.path.join(model_folder, req_file)):
377
+ return False
378
+
379
+ # Check if at least one fold has model files
380
+ for fold in folds:
381
+ fold_dir = os.path.join(model_folder, f"fold_{fold}")
382
+ if os.path.exists(fold_dir):
383
+ # Look for model files (.model, .model.pkl, etc.)
384
+ model_files = [f for f in os.listdir(fold_dir) if f.endswith((".model", ".model.pkl"))]
385
+ if model_files:
386
+ return True
387
+ return False
388
+
389
+ # Check if model exists, download if not
390
+ if not check_model_files_exist(model_folder, folds):
391
+ if auto_download:
392
+ logging.info(f"Model weights for {task_id} not found. Downloading...")
393
+ # Ensure the results folder structure exists
394
+ os.makedirs(os.path.dirname(model_folder), exist_ok=True)
395
+
396
+ # Download the model using our custom download function
397
+ if self._download_and_extract_model(task_id, model_type):
398
+ logging.info(f"Downloaded pretrained model for {task_id} successfully.")
399
+ else:
400
+ raise RuntimeError(f"Failed to download model for {task_id}")
401
+
402
+ # Verify the download worked
403
+ if not check_model_files_exist(model_folder, folds):
404
+ raise RuntimeError(
405
+ f"Model download completed but files not found at expected location: {model_folder}"
406
+ )
407
+ else:
408
+ # Ask user for permission to download
409
+ user_input = (
410
+ input(f"Model weights for {task_id} not found. Do you want to download them? (y/n): ")
411
+ .strip()
412
+ .lower()
413
+ )
414
+ if user_input == "y":
415
+ # Download the model using our custom download function
416
+ if self._download_and_extract_model(task_id, model_type):
417
+ logging.info(f"Downloaded pretrained model for {task_id} successfully.")
418
+ else:
419
+ raise RuntimeError(f"Failed to download model for {task_id}")
420
+ else:
421
+ raise RuntimeError("Model weights not found and download declined by user.")
422
+
423
+ # Double-check that we now have the model
424
+ if not check_model_files_exist(model_folder, folds):
425
+ raise RuntimeError(f"Model files still not found at {model_folder} after download attempt")
426
+
427
+ logging.info(f"Using model: {model_folder}")
428
+
429
+ # Patch torch.load for compatibility
430
+ original_torch_load = torch.load
431
+
432
+ def patched_torch_load(*args, **kwargs):
433
+ kwargs["weights_only"] = False
434
+ return original_torch_load(*args, **kwargs)
435
+
436
+ # Run the segmentation
437
+ torch.load = patched_torch_load
438
+ try:
439
+ predict_from_folder(
440
+ model=model_folder,
441
+ input_folder=image_path,
442
+ output_folder=output_dir,
443
+ folds=folds,
444
+ save_npz=False,
445
+ num_threads_preprocessing=num_threads,
446
+ num_threads_nifti_save=num_threads,
447
+ mixed_precision=mixed_precision,
448
+ lowres_segmentations=None,
449
+ part_id=0,
450
+ num_parts=1,
451
+ tta=use_tta,
452
+ )
453
+ finally:
454
+ # Restore original torch.load behavior
455
+ torch.load = original_torch_load
456
+
457
+ # Clean up temporary input directory if it was created
458
+ if auto_prepare_input and os.path.exists(temp_input_dir):
459
+ import shutil
460
+
461
+ shutil.rmtree(temp_input_dir)
462
+ logging.info("Cleaned up temporary input directory")
463
+
464
+ logging.info(f"Segmentation outputs stored at: {output_dir}")
465
+ return output_dir
466
+
467
+ def create_segmentation_visualization(self, original_mri, segmentation, output_dir="./visualization_output"):
468
+ """
469
+ Create and save visualization of segmentation results using nilearn
470
+ Args:
471
+ original_mri: Path to original MRI file
472
+ segmentation: Path to segmentation file
473
+ output_dir: Directory to save visualization images
474
+ Returns:
475
+ list: List of saved image file paths
476
+ """
477
+ try:
478
+ # Import nilearn here to avoid dependency issues
479
+ from nilearn import plotting
480
+
481
+ # Create output directory
482
+ os.makedirs(output_dir, exist_ok=True)
483
+
484
+ # Check if files exist
485
+ if not os.path.exists(original_mri):
486
+ raise FileNotFoundError(f"Original MRI file not found: {original_mri}")
487
+
488
+ if not os.path.exists(segmentation):
489
+ raise FileNotFoundError(f"Segmentation file not found: {segmentation}")
490
+
491
+ print("✅ Files found, creating visualizations...")
492
+ saved_files = []
493
+
494
+ # Create and save the main overlay plot
495
+ display = plotting.plot_roi(
496
+ segmentation,
497
+ bg_img=original_mri,
498
+ cmap="Set1",
499
+ alpha=0.6,
500
+ title="Segmentation Overlay",
501
+ )
502
+
503
+ # Save the main overlay plot
504
+ output_file = os.path.join(output_dir, "segmentation_overlay.png")
505
+ display.savefig(output_file, dpi=150, bbox_inches="tight")
506
+ saved_files.append(output_file)
507
+ print(f"✅ Main overlay saved to: {output_file}")
508
+ display.close()
509
+
510
+ # Create additional views and save them
511
+ # Axial view
512
+ display_axial = plotting.plot_roi(
513
+ segmentation,
514
+ bg_img=original_mri,
515
+ cmap="Set1",
516
+ alpha=0.6,
517
+ title="Segmentation Overlay - Axial View",
518
+ display_mode="z",
519
+ )
520
+ axial_file = os.path.join(output_dir, "segmentation_axial.png")
521
+ display_axial.savefig(axial_file, dpi=150, bbox_inches="tight")
522
+ saved_files.append(axial_file)
523
+ print(f"✅ Axial view saved to: {axial_file}")
524
+ display_axial.close()
525
+
526
+ # Sagittal view
527
+ display_sagittal = plotting.plot_roi(
528
+ segmentation,
529
+ bg_img=original_mri,
530
+ cmap="Set1",
531
+ alpha=0.6,
532
+ title="Segmentation Overlay - Sagittal View",
533
+ display_mode="x",
534
+ )
535
+ sagittal_file = os.path.join(output_dir, "segmentation_sagittal.png")
536
+ display_sagittal.savefig(sagittal_file, dpi=150, bbox_inches="tight")
537
+ saved_files.append(sagittal_file)
538
+ print(f"✅ Sagittal view saved to: {sagittal_file}")
539
+ display_sagittal.close()
540
+
541
+ # Coronal view
542
+ display_coronal = plotting.plot_roi(
543
+ segmentation,
544
+ bg_img=original_mri,
545
+ cmap="Set1",
546
+ alpha=0.6,
547
+ title="Segmentation Overlay - Coronal View",
548
+ display_mode="y",
549
+ )
550
+ coronal_file = os.path.join(output_dir, "segmentation_coronal.png")
551
+ display_coronal.savefig(coronal_file, dpi=150, bbox_inches="tight")
552
+ saved_files.append(coronal_file)
553
+ print(f"✅ Coronal view saved to: {coronal_file}")
554
+ display_coronal.close()
555
+
556
+ print(f"\n✅ All visualizations saved to: {output_dir}")
557
+ print("Files created:")
558
+ for file_path in saved_files:
559
+ print(f" - {os.path.basename(file_path)}")
560
+
561
+ return saved_files
562
+
563
+ except ImportError:
564
+ print(" nilearn not available. Install with: pip install nilearn")
565
+ return []
566
+ except Exception as e:
567
+ print(f" Visualization failed: {e}")
568
+ import traceback
569
+
570
+ traceback.print_exc()
571
+ return []
572
+
573
+
574
+ # ============================================================================
575
+ # IMAGE REGISTRATION CLASS
576
+ # ============================================================================
577
+
578
+
579
+ class ImageRegistrationTool:
580
+ """
581
+ A comprehensive tool for medical image registration using SimpleITK.
582
+ Supports rigid, affine, and deformable registration with preprocessing and visualization.
583
+ """
584
+
585
+ def __init__(self):
586
+ """Initialize the ImageRegistrationTool."""
587
+ self.supported_formats = [".nii", ".nii.gz", ".nrrd", ".mha", ".mhd"]
588
+ logger.info("ImageRegistrationTool initialized")
589
+
590
+ def load_image(self, image_path: str) -> sitk.Image:
591
+ """
592
+ Load a medical image using SimpleITK.
593
+
594
+ Args:
595
+ image_path: Path to the image file
596
+
597
+ Returns:
598
+ SimpleITK Image object
599
+ """
600
+ if not os.path.exists(image_path):
601
+ raise FileNotFoundError(f"Image file not found: {image_path}")
602
+
603
+ logger.info(f"Loading image: {image_path}")
604
+ try:
605
+ image = sitk.ReadImage(image_path)
606
+ logger.info(f"Successfully loaded image with size: {image.GetSize()}")
607
+ return image
608
+ except Exception as e:
609
+ logger.error(f"Failed to load image {image_path}: {e}")
610
+ raise
611
+
612
+ def save_image(self, image: sitk.Image, output_path: str) -> None:
613
+ """
614
+ Save a SimpleITK image to file.
615
+
616
+ Args:
617
+ image: SimpleITK Image object
618
+ output_path: Path to save the image (must include filename and extension)
619
+ """
620
+ # Validate output path
621
+ if os.path.isdir(output_path):
622
+ raise ValueError(
623
+ f"Output path '{output_path}' is a directory. Please provide a file path with extension (e.g., '.nii.gz', '.png', '.jpg')"
624
+ )
625
+
626
+ if not os.path.splitext(output_path)[1]:
627
+ raise ValueError(
628
+ f"Output path '{output_path}' has no file extension. Please add an extension (e.g., '.nii.gz', '.png', '.jpg')"
629
+ )
630
+
631
+ os.makedirs(os.path.dirname(output_path), exist_ok=True)
632
+ logger.info(f"Saving image to: {output_path}")
633
+ try:
634
+ sitk.WriteImage(image, output_path)
635
+ logger.info("Image saved successfully")
636
+ except Exception as e:
637
+ logger.error(f"Failed to save image to {output_path}: {e}")
638
+ raise
639
+
640
+ def preprocess_image(self, image: sitk.Image, denoise: bool = True, normalize: bool = True) -> sitk.Image:
641
+ """
642
+ Preprocess an image with denoising and normalization.
643
+
644
+ Args:
645
+ image: Input SimpleITK image
646
+ denoise: Whether to apply denoising
647
+ normalize: Whether to apply normalization
648
+
649
+ Returns:
650
+ Preprocessed SimpleITK image
651
+ """
652
+ logger.info("Preprocessing image...")
653
+ processed_image = sitk.Image(image)
654
+
655
+ if denoise:
656
+ # Apply Gaussian smoothing for denoising
657
+ processed_image = sitk.SmoothingRecursiveGaussian(processed_image, sigma=1.0)
658
+ logger.info("Applied denoising")
659
+
660
+ if normalize:
661
+ # Normalize to [0, 1] range
662
+ min_max_filter = sitk.MinimumMaximumImageFilter()
663
+ min_max_filter.Execute(processed_image)
664
+ min_val = min_max_filter.GetMinimum()
665
+ max_val = min_max_filter.GetMaximum()
666
+
667
+ if max_val > min_val:
668
+ processed_image = sitk.IntensityWindowing(
669
+ processed_image, windowMinimum=min_val, windowMaximum=max_val, outputMinimum=0.0, outputMaximum=1.0
670
+ )
671
+ logger.info("Applied normalization")
672
+
673
+ return processed_image
674
+
675
+ def create_rigid_transform(
676
+ self, fixed_image: sitk.Image, moving_image: sitk.Image, initial_transform: sitk.Transform | None = None
677
+ ) -> sitk.Transform:
678
+ """
679
+ Create a rigid transform for image registration.
680
+
681
+ Args:
682
+ fixed_image: Reference (fixed) image
683
+ moving_image: Image to be registered
684
+ initial_transform: Optional initial transform
685
+
686
+ Returns:
687
+ Rigid transform object
688
+ """
689
+ logger.info("Creating rigid transform...")
690
+
691
+ if initial_transform is None:
692
+ # Create identity transform
693
+ transform = sitk.Euler3DTransform()
694
+ else:
695
+ transform = initial_transform
696
+
697
+ return transform
698
+
699
+ def create_affine_transform(
700
+ self, fixed_image: sitk.Image, moving_image: sitk.Image, initial_transform: sitk.Transform | None = None
701
+ ) -> sitk.Transform:
702
+ """
703
+ Create an affine transform for image registration.
704
+
705
+ Args:
706
+ fixed_image: Reference (fixed) image
707
+ moving_image: Image to be registered
708
+ initial_transform: Optional initial transform
709
+
710
+ Returns:
711
+ Affine transform object
712
+ """
713
+ logger.info("Creating affine transform...")
714
+
715
+ if initial_transform is None:
716
+ # Create identity transform
717
+ transform = sitk.AffineTransform(3)
718
+ else:
719
+ transform = initial_transform
720
+
721
+ return transform
722
+
723
+ def create_deformable_transform(
724
+ self, fixed_image: sitk.Image, moving_image: sitk.Image, number_of_control_points: int = 4
725
+ ) -> sitk.Transform:
726
+ """
727
+ Create a deformable (B-spline) transform for image registration.
728
+
729
+ Args:
730
+ fixed_image: Reference (fixed) image
731
+ moving_image: Image to be registered
732
+ number_of_control_points: Number of B-spline control points per dimension
733
+
734
+ Returns:
735
+ Deformable transform object
736
+ """
737
+ logger.info("Creating deformable transform...")
738
+
739
+ # Create B-spline transform
740
+ transform = sitk.BSplineTransformInitializer(fixed_image, [number_of_control_points] * 3, order=3)
741
+
742
+ return transform
743
+
744
+ def setup_registration_method(
745
+ self,
746
+ transform: sitk.Transform,
747
+ metric: str = "mutual_information",
748
+ optimizer: str = "gradient_descent",
749
+ learning_rate: float = 0.01,
750
+ number_of_iterations: int = 100,
751
+ gradient_convergence_tolerance: float = 1e-6,
752
+ ) -> sitk.ImageRegistrationMethod:
753
+ """
754
+ Setup the registration method with specified parameters.
755
+
756
+ Args:
757
+ transform: Transform object
758
+ metric: Similarity metric name
759
+ optimizer: Optimizer name
760
+ learning_rate: Learning rate for gradient descent
761
+ number_of_iterations: Maximum number of iterations
762
+ gradient_convergence_tolerance: Convergence tolerance
763
+
764
+ Returns:
765
+ Configured registration method
766
+ """
767
+ logger.info(f"Setting up registration method: {metric} metric, {optimizer} optimizer")
768
+
769
+ registration_method = sitk.ImageRegistrationMethod()
770
+
771
+ # Set similarity metric
772
+ if metric == "mutual_information":
773
+ registration_method.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
774
+ elif metric == "mean_squares":
775
+ registration_method.SetMetricAsMeanSquares()
776
+ elif metric == "correlation":
777
+ registration_method.SetMetricAsCorrelation()
778
+ elif metric == "normalized_correlation":
779
+ registration_method.SetMetricAsNormalizedCorrelation()
780
+ else:
781
+ logger.warning(f"Unknown metric {metric}, using mutual information")
782
+ registration_method.SetMetricAsMattesMutualInformation(numberOfHistogramBins=50)
783
+
784
+ # Set optimizer
785
+ if optimizer == "gradient_descent":
786
+ registration_method.SetOptimizerAsGradientDescent(
787
+ learningRate=learning_rate,
788
+ numberOfIterations=number_of_iterations,
789
+ convergenceMinimumValue=gradient_convergence_tolerance,
790
+ )
791
+ elif optimizer == "lbfgsb":
792
+ registration_method.SetOptimizerAsLBFGSB(
793
+ gradientConvergenceTolerance=gradient_convergence_tolerance, numberOfIterations=number_of_iterations
794
+ )
795
+ elif optimizer == "powell":
796
+ registration_method.SetOptimizerAsPowell(numberOfIterations=number_of_iterations, maximumLineIterations=20)
797
+ elif optimizer == "amoeba":
798
+ registration_method.SetOptimizerAsAmoeba(
799
+ numberOfIterations=number_of_iterations, parametersConvergenceTolerance=gradient_convergence_tolerance
800
+ )
801
+ else:
802
+ logger.warning(f"Unknown optimizer {optimizer}, using gradient descent")
803
+ registration_method.SetOptimizerAsGradientDescent(
804
+ learningRate=learning_rate,
805
+ numberOfIterations=number_of_iterations,
806
+ convergenceMinimumValue=gradient_convergence_tolerance,
807
+ )
808
+
809
+ # Set interpolator
810
+ registration_method.SetInterpolator(sitk.sitkLinear)
811
+
812
+ # Set initial transform
813
+ registration_method.SetInitialTransform(transform, inPlace=False)
814
+
815
+ return registration_method
816
+
817
+ def register_images(
818
+ self,
819
+ fixed_image: sitk.Image,
820
+ moving_image: sitk.Image,
821
+ transform: sitk.Transform,
822
+ registration_method: sitk.ImageRegistrationMethod,
823
+ ) -> tuple[sitk.Transform, sitk.Image]:
824
+ """
825
+ Perform image registration.
826
+
827
+ Args:
828
+ fixed_image: Reference (fixed) image
829
+ moving_image: Image to be registered
830
+ transform: Transform object
831
+ registration_method: Configured registration method
832
+
833
+ Returns:
834
+ Tuple of (final_transform, registered_image)
835
+ """
836
+ logger.info("Starting image registration...")
837
+
838
+ # Add iteration callback
839
+ def command_iteration():
840
+ logger.info(
841
+ f"Iteration: {registration_method.GetOptimizerIteration()}, "
842
+ f"Metric value: {registration_method.GetMetricValue():.6f}"
843
+ )
844
+
845
+ registration_method.AddCommand(sitk.sitkIterationEvent, command_iteration)
846
+
847
+ try:
848
+ # Execute registration
849
+ final_transform = registration_method.Execute(fixed_image, moving_image)
850
+
851
+ # Apply transform to moving image
852
+ resampler = sitk.ResampleImageFilter()
853
+ resampler.SetReferenceImage(fixed_image)
854
+ resampler.SetInterpolator(sitk.sitkLinear)
855
+ resampler.SetDefaultPixelValue(0)
856
+ resampler.SetTransform(final_transform)
857
+
858
+ registered_image = resampler.Execute(moving_image)
859
+
860
+ logger.info("Registration completed successfully")
861
+ return final_transform, registered_image
862
+
863
+ except Exception as e:
864
+ logger.error(f"Registration failed: {e}")
865
+ raise
866
+
867
+ def calculate_similarity_metrics(self, image1: sitk.Image, image2: sitk.Image) -> dict[str, float]:
868
+ """
869
+ Calculate similarity metrics between two images.
870
+
871
+ Args:
872
+ image1: First image
873
+ image2: Second image
874
+
875
+ Returns:
876
+ Dictionary of similarity metrics
877
+ """
878
+ logger.info("Calculating similarity metrics...")
879
+ metrics = {}
880
+
881
+ try:
882
+ # Convert to numpy arrays
883
+ array1 = sitk.GetArrayFromImage(image1)
884
+ array2 = sitk.GetArrayFromImage(image2)
885
+
886
+ # Flatten arrays
887
+ flat1 = array1.flatten()
888
+ flat2 = array2.flatten()
889
+
890
+ # Remove invalid values
891
+ valid_mask = np.isfinite(flat1) & np.isfinite(flat2)
892
+ flat1 = flat1[valid_mask]
893
+ flat2 = flat2[valid_mask]
894
+
895
+ if len(flat1) == 0:
896
+ logger.warning("No valid pixels for similarity calculation")
897
+ return {
898
+ "mutual_information": 0.0,
899
+ "mean_squares": 0.0,
900
+ "correlation": 0.0,
901
+ "normalized_correlation": 0.0,
902
+ }
903
+
904
+ # Mean Squared Error
905
+ mse = np.mean((flat1 - flat2) ** 2)
906
+ metrics["mean_squares"] = -mse # Negative because we want to maximize
907
+
908
+ # Pearson Correlation
909
+ if np.std(flat1) > 0 and np.std(flat2) > 0:
910
+ correlation = np.corrcoef(flat1, flat2)[0, 1]
911
+ metrics["correlation"] = correlation if not np.isnan(correlation) else 0.0
912
+ else:
913
+ metrics["correlation"] = 0.0
914
+
915
+ # Normalized Cross Correlation
916
+ if np.std(flat1) > 0 and np.std(flat2) > 0:
917
+ ncc = np.corrcoef(flat1, flat2)[0, 1]
918
+ metrics["normalized_correlation"] = ncc if not np.isnan(ncc) else 0.0
919
+ else:
920
+ metrics["normalized_correlation"] = 0.0
921
+
922
+ # Mutual Information (simplified calculation)
923
+ try:
924
+ # Create 2D histogram
925
+ hist_2d, x_edges, y_edges = np.histogram2d(flat1, flat2, bins=50)
926
+ hist_2d = hist_2d + 1e-10 # Add small value to avoid log(0)
927
+
928
+ # Normalize histogram
929
+ pxy = hist_2d / np.sum(hist_2d)
930
+ px = np.sum(pxy, axis=1)
931
+ py = np.sum(pxy, axis=0)
932
+
933
+ # Calculate mutual information
934
+ mi = 0.0
935
+ for i in range(len(px)):
936
+ for j in range(len(py)):
937
+ if pxy[i, j] > 0 and px[i] > 0 and py[j] > 0:
938
+ mi += pxy[i, j] * np.log2(pxy[i, j] / (px[i] * py[j]))
939
+
940
+ metrics["mutual_information"] = mi
941
+
942
+ except Exception as e:
943
+ logger.warning(f"Failed to calculate mutual information: {e}")
944
+ metrics["mutual_information"] = 0.0
945
+
946
+ except Exception as e:
947
+ logger.warning(f"Failed to calculate similarity metrics: {e}")
948
+ metrics = {
949
+ "mutual_information": 0.0,
950
+ "mean_squares": 0.0,
951
+ "correlation": 0.0,
952
+ "normalized_correlation": 0.0,
953
+ }
954
+
955
+ logger.info("Similarity metrics calculated")
956
+ return metrics
957
+
958
+
959
+ # ============================================================================
960
+ # CONVENIENCE FUNCTIONS FOR BIOMNI INTEGRATION
961
+ # ============================================================================
962
+
963
+
964
+ # Segmentation convenience functions
965
+ def split_modalities(input_file, output_dir, case_name="BRAT"):
966
+ """Convenience function for splitting modalities"""
967
+ tool = SegmentationTool()
968
+ return tool.split_modalities(input_file, output_dir, case_name)
969
+
970
+
971
+ def prepare_input_for_nnunet(input_path, output_dir, case_name="BRAT"):
972
+ """Convenience function for preparing nnUNet input"""
973
+ tool = SegmentationTool()
974
+ return tool.prepare_input_for_nnunet(input_path, output_dir, case_name)
975
+
976
+
977
+ def segment_with_nn_unet(
978
+ image_path,
979
+ output_dir,
980
+ task_id,
981
+ model_type="3d_fullres",
982
+ folds=None,
983
+ use_tta=False,
984
+ num_threads=1,
985
+ mixed_precision=True,
986
+ verbose=True,
987
+ auto_prepare_input=True,
988
+ results_folder=None,
989
+ ):
990
+ """Convenience function for nnUNet segmentation"""
991
+ tool = SegmentationTool()
992
+ return tool.segment_with_nn_unet(
993
+ image_path,
994
+ output_dir,
995
+ task_id,
996
+ model_type,
997
+ folds,
998
+ use_tta,
999
+ num_threads,
1000
+ mixed_precision,
1001
+ verbose,
1002
+ auto_prepare_input,
1003
+ results_folder,
1004
+ )
1005
+
1006
+
1007
+ def create_segmentation_visualization(original_mri, segmentation, output_dir="./visualization_output"):
1008
+ """Convenience function for segmentation visualization"""
1009
+ tool = SegmentationTool()
1010
+ return tool.create_segmentation_visualization(original_mri, segmentation, output_dir)
1011
+
1012
+
1013
+ # Registration convenience functions
1014
+ def preprocess_image(image_path: str, output_path: str, denoise: bool = True, normalize: bool = True) -> str:
1015
+ """
1016
+ Standalone image preprocessing function for Biomni integration.
1017
+
1018
+ Args:
1019
+ image_path: Path to input image
1020
+ output_path: Path to save preprocessed image
1021
+ denoise: Whether to apply denoising (default: True)
1022
+ normalize: Whether to apply normalization (default: True)
1023
+
1024
+ Returns:
1025
+ Path to the saved preprocessed image
1026
+ """
1027
+ tool = ImageRegistrationTool()
1028
+ image = tool.load_image(image_path)
1029
+ preprocessed_image = tool.preprocess_image(image, denoise, normalize)
1030
+ tool.save_image(preprocessed_image, output_path)
1031
+ return output_path
1032
+
1033
+
1034
+ def quick_rigid_registration(
1035
+ fixed_image_path: str,
1036
+ moving_image_path: str,
1037
+ output_dir: str,
1038
+ metric: str = "mutual_information",
1039
+ optimizer: str = "gradient_descent",
1040
+ preprocess: bool = True,
1041
+ create_visualizations: bool = True,
1042
+ learning_rate: float = 0.01,
1043
+ number_of_iterations: int = 100,
1044
+ gradient_convergence_tolerance: float = 1e-6,
1045
+ ) -> dict:
1046
+ """
1047
+ Quick rigid registration function for Biomni integration.
1048
+
1049
+ Args:
1050
+ fixed_image_path: Path to reference image
1051
+ moving_image_path: Path to image to register
1052
+ output_dir: Directory to save results
1053
+ metric: Similarity metric
1054
+ optimizer: Optimization method
1055
+ preprocess: Whether to preprocess images
1056
+ create_visualizations: Whether to create visualizations
1057
+ learning_rate: Learning rate for optimizer
1058
+ number_of_iterations: Maximum iterations
1059
+ gradient_convergence_tolerance: Convergence tolerance
1060
+
1061
+ Returns:
1062
+ Dictionary with registration results
1063
+ """
1064
+ tool = ImageRegistrationTool()
1065
+
1066
+ # Load images
1067
+ fixed_image = tool.load_image(fixed_image_path)
1068
+ moving_image = tool.load_image(moving_image_path)
1069
+
1070
+ # Preprocess if requested
1071
+ if preprocess:
1072
+ fixed_image = tool.preprocess_image(fixed_image)
1073
+ moving_image = tool.preprocess_image(moving_image)
1074
+
1075
+ # Create transform and registration method
1076
+ transform = tool.create_rigid_transform(fixed_image, moving_image)
1077
+ registration_method = tool.setup_registration_method(
1078
+ transform, metric, optimizer, learning_rate, number_of_iterations, gradient_convergence_tolerance
1079
+ )
1080
+
1081
+ # Perform registration
1082
+ final_transform, registered_image = tool.register_images(fixed_image, moving_image, transform, registration_method)
1083
+
1084
+ # Save results
1085
+ os.makedirs(output_dir, exist_ok=True)
1086
+
1087
+ # Save registered image
1088
+ registered_path = os.path.join(output_dir, "rigid_registered.nii.gz")
1089
+ tool.save_image(registered_image, registered_path)
1090
+
1091
+ # Save transform
1092
+ transform_path = os.path.join(output_dir, "rigid_transform.tfm")
1093
+ sitk.WriteTransform(final_transform, transform_path)
1094
+
1095
+ # Calculate metrics
1096
+ metrics_before = tool.calculate_similarity_metrics(fixed_image, moving_image)
1097
+ metrics_after = tool.calculate_similarity_metrics(fixed_image, registered_image)
1098
+
1099
+ results = {
1100
+ "registered_image_path": registered_path,
1101
+ "transform_path": transform_path,
1102
+ "metrics_before": metrics_before,
1103
+ "metrics_after": metrics_after,
1104
+ "registration_type": "rigid",
1105
+ }
1106
+
1107
+ return results
1108
+
1109
+
1110
+ def quick_affine_registration(
1111
+ fixed_image_path: str,
1112
+ moving_image_path: str,
1113
+ output_dir: str,
1114
+ metric: str = "mutual_information",
1115
+ optimizer: str = "gradient_descent",
1116
+ preprocess: bool = True,
1117
+ create_visualizations: bool = True,
1118
+ learning_rate: float = 0.01,
1119
+ number_of_iterations: int = 100,
1120
+ gradient_convergence_tolerance: float = 1e-6,
1121
+ ) -> dict:
1122
+ """
1123
+ Quick affine registration function for Biomni integration.
1124
+
1125
+ Args:
1126
+ fixed_image_path: Path to reference image
1127
+ moving_image_path: Path to image to register
1128
+ output_dir: Directory to save results
1129
+ metric: Similarity metric
1130
+ optimizer: Optimization method
1131
+ preprocess: Whether to preprocess images
1132
+ create_visualizations: Whether to create visualizations
1133
+ learning_rate: Learning rate for optimizer
1134
+ number_of_iterations: Maximum iterations
1135
+ gradient_convergence_tolerance: Convergence tolerance
1136
+
1137
+ Returns:
1138
+ Dictionary with registration results
1139
+ """
1140
+ tool = ImageRegistrationTool()
1141
+
1142
+ # Load images
1143
+ fixed_image = tool.load_image(fixed_image_path)
1144
+ moving_image = tool.load_image(moving_image_path)
1145
+
1146
+ # Preprocess if requested
1147
+ if preprocess:
1148
+ fixed_image = tool.preprocess_image(fixed_image)
1149
+ moving_image = tool.preprocess_image(moving_image)
1150
+
1151
+ # Create transform and registration method
1152
+ transform = tool.create_affine_transform(fixed_image, moving_image)
1153
+ registration_method = tool.setup_registration_method(
1154
+ transform, metric, optimizer, learning_rate, number_of_iterations, gradient_convergence_tolerance
1155
+ )
1156
+
1157
+ # Perform registration
1158
+ final_transform, registered_image = tool.register_images(fixed_image, moving_image, transform, registration_method)
1159
+
1160
+ # Save results
1161
+ os.makedirs(output_dir, exist_ok=True)
1162
+
1163
+ # Save registered image
1164
+ registered_path = os.path.join(output_dir, "affine_registered.nii.gz")
1165
+ tool.save_image(registered_image, registered_path)
1166
+
1167
+ # Save transform
1168
+ transform_path = os.path.join(output_dir, "affine_transform.tfm")
1169
+ sitk.WriteTransform(final_transform, transform_path)
1170
+
1171
+ # Calculate metrics
1172
+ metrics_before = tool.calculate_similarity_metrics(fixed_image, moving_image)
1173
+ metrics_after = tool.calculate_similarity_metrics(fixed_image, registered_image)
1174
+
1175
+ results = {
1176
+ "registered_image_path": registered_path,
1177
+ "transform_path": transform_path,
1178
+ "metrics_before": metrics_before,
1179
+ "metrics_after": metrics_after,
1180
+ "registration_type": "affine",
1181
+ }
1182
+
1183
+ return results
1184
+
1185
+
1186
+ def quick_deformable_registration(
1187
+ fixed_image_path: str,
1188
+ moving_image_path: str,
1189
+ output_dir: str,
1190
+ metric: str = "mutual_information",
1191
+ optimizer: str = "gradient_descent",
1192
+ preprocess: bool = True,
1193
+ create_visualizations: bool = True,
1194
+ learning_rate: float = 0.01,
1195
+ number_of_iterations: int = 100,
1196
+ gradient_convergence_tolerance: float = 1e-6,
1197
+ number_of_control_points: int = 4,
1198
+ ) -> dict:
1199
+ """
1200
+ Quick deformable registration function for Biomni integration.
1201
+
1202
+ Args:
1203
+ fixed_image_path: Path to reference image
1204
+ moving_image_path: Path to image to register
1205
+ output_dir: Directory to save results
1206
+ metric: Similarity metric
1207
+ optimizer: Optimization method
1208
+ preprocess: Whether to preprocess images
1209
+ create_visualizations: Whether to create visualizations
1210
+ learning_rate: Learning rate for optimizer
1211
+ number_of_iterations: Maximum iterations
1212
+ gradient_convergence_tolerance: Convergence tolerance
1213
+ number_of_control_points: Number of B-spline control points
1214
+
1215
+ Returns:
1216
+ Dictionary with registration results
1217
+ """
1218
+ tool = ImageRegistrationTool()
1219
+
1220
+ # Load images
1221
+ fixed_image = tool.load_image(fixed_image_path)
1222
+ moving_image = tool.load_image(moving_image_path)
1223
+
1224
+ # Preprocess if requested
1225
+ if preprocess:
1226
+ fixed_image = tool.preprocess_image(fixed_image)
1227
+ moving_image = tool.preprocess_image(moving_image)
1228
+
1229
+ # Create transform and registration method
1230
+ transform = tool.create_deformable_transform(fixed_image, moving_image, number_of_control_points)
1231
+ registration_method = tool.setup_registration_method(
1232
+ transform, metric, optimizer, learning_rate, number_of_iterations, gradient_convergence_tolerance
1233
+ )
1234
+
1235
+ # Perform registration
1236
+ final_transform, registered_image = tool.register_images(fixed_image, moving_image, transform, registration_method)
1237
+
1238
+ # Save results
1239
+ os.makedirs(output_dir, exist_ok=True)
1240
+
1241
+ # Save registered image
1242
+ registered_path = os.path.join(output_dir, "deformable_registered.nii.gz")
1243
+ tool.save_image(registered_image, registered_path)
1244
+
1245
+ # Save transform
1246
+ transform_path = os.path.join(output_dir, "deformable_transform.tfm")
1247
+ sitk.WriteTransform(final_transform, transform_path)
1248
+
1249
+ # Calculate metrics
1250
+ metrics_before = tool.calculate_similarity_metrics(fixed_image, moving_image)
1251
+ metrics_after = tool.calculate_similarity_metrics(fixed_image, registered_image)
1252
+
1253
+ results = {
1254
+ "registered_image_path": registered_path,
1255
+ "transform_path": transform_path,
1256
+ "metrics_before": metrics_before,
1257
+ "metrics_after": metrics_after,
1258
+ "registration_type": "deformable",
1259
+ }
1260
+
1261
+ return results
1262
+
1263
+
1264
+ def batch_register_images(
1265
+ fixed_image_path: str,
1266
+ moving_images_dir: str,
1267
+ output_dir: str,
1268
+ transform_type: str = "rigid",
1269
+ metric: str = "mutual_information",
1270
+ optimizer: str = "gradient_descent",
1271
+ preprocess: bool = True,
1272
+ create_visualizations: bool = True,
1273
+ learning_rate: float = 0.01,
1274
+ number_of_iterations: int = 100,
1275
+ gradient_convergence_tolerance: float = 1e-6,
1276
+ ) -> dict:
1277
+ """
1278
+ Batch registration of multiple images to a single reference.
1279
+
1280
+ Args:
1281
+ fixed_image_path: Path to reference image
1282
+ moving_images_dir: Directory containing images to register
1283
+ output_dir: Directory to save results
1284
+ transform_type: Type of registration ('rigid', 'affine', 'deformable')
1285
+ metric: Similarity metric
1286
+ optimizer: Optimization method
1287
+ preprocess: Whether to preprocess images
1288
+ create_visualizations: Whether to create visualizations
1289
+ learning_rate: Learning rate for optimizer
1290
+ number_of_iterations: Maximum iterations
1291
+ gradient_convergence_tolerance: Convergence tolerance
1292
+
1293
+ Returns:
1294
+ Dictionary with batch registration results
1295
+ """
1296
+ logger.info(f"Starting batch {transform_type} registration...")
1297
+
1298
+ # Find all image files in the directory
1299
+ image_files = []
1300
+ for file in os.listdir(moving_images_dir):
1301
+ if any(file.endswith(ext) for ext in [".nii", ".nii.gz", ".nrrd", ".mha", ".mhd"]):
1302
+ image_files.append(os.path.join(moving_images_dir, file))
1303
+
1304
+ if not image_files:
1305
+ raise ValueError(f"No image files found in {moving_images_dir}")
1306
+
1307
+ logger.info(f"Found {len(image_files)} images to register")
1308
+
1309
+ # Create output directory
1310
+ os.makedirs(output_dir, exist_ok=True)
1311
+
1312
+ # Process each image
1313
+ results = {}
1314
+ ImageRegistrationTool()
1315
+
1316
+ for i, moving_image_path in enumerate(image_files):
1317
+ logger.info(f"Processing {i + 1}/{len(image_files)}: {os.path.basename(moving_image_path)}")
1318
+
1319
+ # Create individual output directory
1320
+ image_name = os.path.splitext(os.path.basename(moving_image_path))[0]
1321
+ if image_name.endswith(".nii"):
1322
+ image_name = os.path.splitext(image_name)[0]
1323
+
1324
+ individual_output_dir = os.path.join(output_dir, f"registration_{image_name}")
1325
+
1326
+ try:
1327
+ if transform_type == "rigid":
1328
+ result = quick_rigid_registration(
1329
+ fixed_image_path,
1330
+ moving_image_path,
1331
+ individual_output_dir,
1332
+ metric,
1333
+ optimizer,
1334
+ preprocess,
1335
+ create_visualizations,
1336
+ learning_rate,
1337
+ number_of_iterations,
1338
+ gradient_convergence_tolerance,
1339
+ )
1340
+ elif transform_type == "affine":
1341
+ result = quick_affine_registration(
1342
+ fixed_image_path,
1343
+ moving_image_path,
1344
+ individual_output_dir,
1345
+ metric,
1346
+ optimizer,
1347
+ preprocess,
1348
+ create_visualizations,
1349
+ learning_rate,
1350
+ number_of_iterations,
1351
+ gradient_convergence_tolerance,
1352
+ )
1353
+ elif transform_type == "deformable":
1354
+ result = quick_deformable_registration(
1355
+ fixed_image_path,
1356
+ moving_image_path,
1357
+ individual_output_dir,
1358
+ metric,
1359
+ optimizer,
1360
+ preprocess,
1361
+ create_visualizations,
1362
+ learning_rate,
1363
+ number_of_iterations,
1364
+ gradient_convergence_tolerance,
1365
+ )
1366
+ else:
1367
+ raise ValueError(f"Unknown transform type: {transform_type}")
1368
+
1369
+ results[image_name] = result
1370
+ logger.info(f"Successfully registered {image_name}")
1371
+
1372
+ except Exception as e:
1373
+ logger.error(f"Failed to register {image_name}: {e}")
1374
+ results[image_name] = {"error": str(e)}
1375
+
1376
+ logger.info(f"Batch registration completed. Processed {len(image_files)} images.")
1377
+ return results
1378
+
1379
+
1380
+ def calculate_similarity_metrics(image1_path: str, image2_path: str) -> dict[str, float]:
1381
+ """
1382
+ Calculate similarity metrics between two images.
1383
+
1384
+ Args:
1385
+ image1_path: Path to first image
1386
+ image2_path: Path to second image
1387
+
1388
+ Returns:
1389
+ Dictionary of similarity metrics
1390
+ """
1391
+ tool = ImageRegistrationTool()
1392
+ image1 = tool.load_image(image1_path)
1393
+ image2 = tool.load_image(image2_path)
1394
+ return tool.calculate_similarity_metrics(image1, image2)
BioScientist/agent_system/engines/v1_executor_backup/tool/biophysics.py ADDED
@@ -0,0 +1,513 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def predict_protein_disorder_regions(protein_sequence, threshold=0.5, output_file="disorder_prediction_results.csv"):
2
+ """Predicts intrinsically disordered regions (IDRs) in a protein sequence using IUPred2A.
3
+
4
+ Parameters
5
+ ----------
6
+ protein_sequence : str
7
+ The amino acid sequence of the protein to analyze
8
+ threshold : float, optional
9
+ The disorder score threshold above which a residue is considered disordered (default: 0.5)
10
+ output_file : str, optional
11
+ Filename to save the per-residue disorder scores (default: "disorder_prediction_results.csv")
12
+
13
+ Returns
14
+ -------
15
+ str
16
+ A research log summarizing the prediction process and results
17
+
18
+ """
19
+ import csv
20
+ import re
21
+
22
+ import requests
23
+
24
+ # Clean the input sequence
25
+ protein_sequence = "".join(re.findall(r"[A-Za-z]", protein_sequence))
26
+
27
+ # Step 1: Submit the sequence to IUPred2A web server
28
+ url = "https://iupred2a.elte.hu/iupred2a"
29
+ payload = {
30
+ "seq": protein_sequence,
31
+ "iupred2": "long", # Use IUPred2 long disorder prediction
32
+ "anchor2": "no", # Don't use ANCHOR2 prediction
33
+ }
34
+
35
+ try:
36
+ response = requests.post(url, data=payload)
37
+ response.raise_for_status()
38
+ except requests.exceptions.RequestException as e:
39
+ return f"Error accessing IUPred2A server: {str(e)}"
40
+
41
+ # Step 2: Parse the results to extract disorder scores
42
+ result_lines = response.text.split("\n")
43
+ scores = []
44
+
45
+ for line in result_lines:
46
+ if line.startswith("#") or not line.strip():
47
+ continue
48
+ parts = line.split()
49
+ if len(parts) >= 3:
50
+ try:
51
+ position = int(parts[0])
52
+ residue = parts[1]
53
+ score = float(parts[2])
54
+ scores.append((position, residue, score))
55
+ except (ValueError, IndexError):
56
+ continue
57
+
58
+ if not scores:
59
+ return "No valid prediction data was returned from the server."
60
+
61
+ # Step 3: Identify disordered regions
62
+ disordered_regions = []
63
+ current_region = []
64
+
65
+ for pos, _, score in scores:
66
+ if score >= threshold:
67
+ if not current_region:
68
+ current_region = [pos]
69
+ elif pos == current_region[-1] + 1:
70
+ current_region.append(pos)
71
+ else:
72
+ if len(current_region) > 1:
73
+ disordered_regions.append((current_region[0], current_region[-1]))
74
+ current_region = [pos]
75
+ elif current_region and len(current_region) > 1:
76
+ disordered_regions.append((current_region[0], current_region[-1]))
77
+ current_region = []
78
+ elif current_region:
79
+ current_region = []
80
+
81
+ # Add the last region if it exists
82
+ if current_region and len(current_region) > 1:
83
+ disordered_regions.append((current_region[0], current_region[-1]))
84
+
85
+ # Step 4: Save results to CSV
86
+ with open(output_file, "w", newline="") as f:
87
+ writer = csv.writer(f)
88
+ writer.writerow(["Position", "Amino_Acid", "Disorder_Score", "Is_Disordered"])
89
+ for pos, aa, score in scores:
90
+ is_disordered = "Yes" if score >= threshold else "No"
91
+ writer.writerow([pos, aa, score, is_disordered])
92
+
93
+ # Step 5: Generate the research log
94
+ total_residues = len(scores)
95
+ disordered_count = sum(1 for _, _, score in scores if score >= threshold)
96
+ disordered_percentage = (disordered_count / total_residues) * 100 if total_residues > 0 else 0
97
+
98
+ log = f"""
99
+ Intrinsically Disordered Region (IDR) Prediction Research Log:
100
+ =============================================================
101
+ Analysis performed using IUPred2A algorithm (long disorder mode)
102
+ Protein sequence length: {total_residues} amino acids
103
+ Disorder threshold: {threshold}
104
+
105
+ Results Summary:
106
+ - {disordered_count} residues ({disordered_percentage:.2f}%) predicted as disordered
107
+ - {len(disordered_regions)} distinct disordered regions identified
108
+
109
+ Disordered Regions:
110
+ """
111
+
112
+ if disordered_regions:
113
+ for start, end in disordered_regions:
114
+ length = end - start + 1
115
+ log += f"- Region {start}-{end} (length: {length} residues)\n"
116
+ else:
117
+ log += "- No significant disordered regions found\n"
118
+
119
+ log += f"\nDetailed per-residue scores saved to: {output_file}"
120
+
121
+ return log
122
+
123
+
124
+ def analyze_cell_morphology_and_cytoskeleton(image_path, output_dir="./results", threshold_method="otsu"):
125
+ """Quantifies cell morphology and cytoskeletal organization from fluorescence microscopy images.
126
+
127
+ Parameters
128
+ ----------
129
+ image_path : str
130
+ Path to the fluorescence microscopy image file
131
+ output_dir : str, optional
132
+ Directory to save output files (default: './results')
133
+ threshold_method : str, optional
134
+ Method for cell segmentation ('otsu', 'adaptive', or 'manual') (default: 'otsu')
135
+
136
+ Returns
137
+ -------
138
+ str
139
+ Research log summarizing the analysis steps and results
140
+
141
+ """
142
+ import os
143
+ from datetime import datetime
144
+
145
+ import cv2
146
+ import numpy as np
147
+ import pandas as pd
148
+ from skimage import exposure, feature, filters, io, measure, morphology
149
+ from skimage.color import rgb2gray
150
+
151
+ # Create output directory if it doesn't exist
152
+ os.makedirs(output_dir, exist_ok=True)
153
+
154
+ # Start log
155
+ log = f"Cell Morphology and Cytoskeleton Analysis Log - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
156
+ log += f"Analyzing image: {image_path}\n\n"
157
+
158
+ # Load image
159
+ log += "Step 1: Loading and preprocessing image\n"
160
+ try:
161
+ image = io.imread(image_path)
162
+ # Convert to grayscale if RGB
163
+ if len(image.shape) > 2:
164
+ gray_image = rgb2gray(image)
165
+ log += "- Converted RGB image to grayscale\n"
166
+ else:
167
+ gray_image = image
168
+
169
+ # Enhance contrast
170
+ gray_image = exposure.equalize_hist(gray_image)
171
+ log += "- Enhanced image contrast\n"
172
+ except Exception as e:
173
+ return f"Error loading image: {str(e)}"
174
+
175
+ # Segment cells
176
+ log += "\nStep 2: Segmenting cells from background\n"
177
+ if threshold_method == "otsu":
178
+ thresh = filters.threshold_otsu(gray_image)
179
+ binary = gray_image > thresh
180
+ log += f"- Applied Otsu thresholding (threshold value: {thresh:.4f})\n"
181
+ elif threshold_method == "adaptive":
182
+ binary = filters.threshold_local(gray_image, block_size=35, offset=0.05)
183
+ binary = gray_image > binary
184
+ log += "- Applied adaptive thresholding\n"
185
+ else: # manual
186
+ thresh = 0.5 # Default value, can be parameterized
187
+ binary = gray_image > thresh
188
+ log += f"- Applied manual thresholding (threshold value: {thresh:.4f})\n"
189
+
190
+ # Clean up binary image
191
+ binary = morphology.remove_small_objects(binary, min_size=100)
192
+ binary = morphology.remove_small_holes(binary, area_threshold=100)
193
+ binary = morphology.binary_closing(binary, morphology.disk(3))
194
+ log += "- Applied morphological operations to clean segmentation\n"
195
+
196
+ # Label cells
197
+ labeled_cells, num_cells = measure.label(binary, return_num=True)
198
+ log += f"- Identified {num_cells} cell regions\n"
199
+
200
+ # Analyze cell properties
201
+ log += "\nStep 3: Analyzing cell morphology\n"
202
+ cell_props = measure.regionprops_table(
203
+ labeled_cells,
204
+ gray_image,
205
+ properties=(
206
+ "area",
207
+ "perimeter",
208
+ "major_axis_length",
209
+ "minor_axis_length",
210
+ "eccentricity",
211
+ "orientation",
212
+ "solidity",
213
+ ),
214
+ )
215
+
216
+ # Convert to DataFrame for easier manipulation
217
+ cell_df = pd.DataFrame(cell_props)
218
+
219
+ # Calculate additional metrics
220
+ if len(cell_df) > 0:
221
+ cell_df["aspect_ratio"] = cell_df["major_axis_length"] / cell_df["minor_axis_length"]
222
+ cell_df["circularity"] = (4 * np.pi * cell_df["area"]) / (cell_df["perimeter"] ** 2)
223
+
224
+ # Summary statistics
225
+ log += f"- Average cell area: {cell_df['area'].mean():.2f} pixels\n"
226
+ log += f"- Average aspect ratio: {cell_df['aspect_ratio'].mean():.2f}\n"
227
+ log += f"- Average circularity: {cell_df['circularity'].mean():.2f}\n"
228
+ log += f"- Average eccentricity: {cell_df['eccentricity'].mean():.2f}\n"
229
+ else:
230
+ log += "- No cells detected for morphological analysis\n"
231
+
232
+ # Analyze cytoskeletal organization
233
+ log += "\nStep 4: Analyzing cytoskeletal organization\n"
234
+
235
+ # Edge detection to highlight cytoskeletal fibers
236
+ edges = feature.canny(gray_image, sigma=2)
237
+
238
+ # Use Hough transform to detect lines (cytoskeletal fibers)
239
+ if np.any(edges):
240
+ lines = cv2.HoughLinesP(
241
+ edges.astype(np.uint8),
242
+ 1,
243
+ np.pi / 180,
244
+ threshold=10,
245
+ minLineLength=10,
246
+ maxLineGap=5,
247
+ )
248
+
249
+ if lines is not None:
250
+ # Calculate line orientations
251
+ orientations = []
252
+ for line in lines:
253
+ x1, y1, x2, y2 = line[0]
254
+ if x2 - x1 != 0: # Avoid division by zero
255
+ angle = np.arctan2(y2 - y1, x2 - x1) * 180 / np.pi
256
+ orientations.append(angle)
257
+
258
+ if orientations:
259
+ # Convert to numpy array for calculations
260
+ orientations = np.array(orientations)
261
+
262
+ # Calculate alignment metrics
263
+ mean_orientation = np.mean(orientations)
264
+ # Normalize angles to -90 to 90 degrees
265
+ norm_angles = np.mod(orientations + 90, 180) - 90
266
+ std_orientation = np.std(norm_angles)
267
+
268
+ # Order parameter (measure of alignment, 1 = perfectly aligned, 0 = random)
269
+ # Convert angles to radians for calculation
270
+ rad_angles = np.radians(norm_angles)
271
+ order_parameter = np.sqrt(np.mean(np.cos(2 * rad_angles)) ** 2 + np.mean(np.sin(2 * rad_angles)) ** 2)
272
+
273
+ log += f"- Detected {len(orientations)} cytoskeletal fibers\n"
274
+ log += f"- Mean fiber orientation: {mean_orientation:.2f} degrees\n"
275
+ log += f"- Standard deviation of orientation: {std_orientation:.2f} degrees\n"
276
+ log += f"- Order parameter (alignment): {order_parameter:.4f} (0=random, 1=aligned)\n"
277
+
278
+ # Add fiber data to dataframe
279
+ fiber_df = pd.DataFrame({"fiber_orientation": orientations})
280
+ else:
281
+ log += "- No fiber orientations could be calculated\n"
282
+ fiber_df = pd.DataFrame()
283
+ else:
284
+ log += "- No cytoskeletal fibers detected\n"
285
+ fiber_df = pd.DataFrame()
286
+ else:
287
+ log += "- No edges detected for cytoskeletal analysis\n"
288
+ fiber_df = pd.DataFrame()
289
+
290
+ # Save results
291
+ log += "\nStep 5: Saving results\n"
292
+
293
+ # Save cell morphology data
294
+ if len(cell_df) > 0:
295
+ cell_csv_path = os.path.join(output_dir, "cell_morphology_data.csv")
296
+ cell_df.to_csv(cell_csv_path, index=False)
297
+ log += f"- Cell morphology data saved to: {cell_csv_path}\n"
298
+
299
+ # Save fiber orientation data
300
+ if len(fiber_df) > 0:
301
+ fiber_csv_path = os.path.join(output_dir, "fiber_orientation_data.csv")
302
+ fiber_df.to_csv(fiber_csv_path, index=False)
303
+ log += f"- Fiber orientation data saved to: {fiber_csv_path}\n"
304
+
305
+ # Save segmentation image
306
+ if num_cells > 0:
307
+ segmentation_path = os.path.join(output_dir, "cell_segmentation.png")
308
+ io.imsave(segmentation_path, labeled_cells.astype(np.uint8) * 50)
309
+ log += f"- Cell segmentation image saved to: {segmentation_path}\n"
310
+
311
+ # Summary
312
+ log += "\nAnalysis Summary:\n"
313
+ log += f"- Processed image: {image_path}\n"
314
+ log += f"- Detected {num_cells} cells\n"
315
+ if len(cell_df) > 0:
316
+ log += f"- Cell size range: {cell_df['area'].min():.1f} to {cell_df['area'].max():.1f} pixels\n"
317
+ log += f"- Cell shape: average aspect ratio = {cell_df['aspect_ratio'].mean():.2f}\n"
318
+ if "order_parameter" in locals():
319
+ log += f"- Cytoskeletal organization: alignment parameter = {order_parameter:.4f}\n"
320
+
321
+ return log
322
+
323
+
324
+ def analyze_tissue_deformation_flow(image_sequence, output_dir="results", pixel_scale=1.0):
325
+ """Quantify tissue deformation and flow dynamics from microscopy image sequence.
326
+
327
+ Parameters
328
+ ----------
329
+ image_sequence : list or numpy.ndarray
330
+ Sequence of microscopy images (either a list of file paths or a 3D numpy array [time, height, width])
331
+ output_dir : str, optional
332
+ Directory to save results (default: "results")
333
+ pixel_scale : float, optional
334
+ Physical scale of pixels (e.g., μm/pixel) for proper scaling of metrics (default: 1.0)
335
+
336
+ Returns
337
+ -------
338
+ str
339
+ Research log summarizing the analysis steps and results
340
+
341
+ """
342
+ import os
343
+ from datetime import datetime
344
+
345
+ import cv2
346
+ import numpy as np
347
+
348
+ # Create output directory if it doesn't exist
349
+ os.makedirs(output_dir, exist_ok=True)
350
+
351
+ # Load images if paths are provided
352
+ if isinstance(image_sequence[0], str):
353
+ loaded_images = []
354
+ for img_path in image_sequence:
355
+ img = cv2.imread(img_path, cv2.IMREAD_GRAYSCALE)
356
+ if img is None:
357
+ return f"Error: Could not load image {img_path}"
358
+ loaded_images.append(img)
359
+ frames = np.array(loaded_images)
360
+ else:
361
+ frames = image_sequence
362
+ # Convert to grayscale if needed
363
+ if len(frames.shape) > 3: # Has color channels
364
+ frames = np.array(
365
+ [cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY) if frame.shape[-1] == 3 else frame for frame in frames]
366
+ )
367
+
368
+ # Parameters for optical flow
369
+ lk_params = {
370
+ "winSize": (15, 15),
371
+ "maxLevel": 2,
372
+ "criteria": (cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 10, 0.03),
373
+ }
374
+
375
+ # Metrics storage
376
+ num_frames = len(frames)
377
+ flow_fields = []
378
+ divergence_maps = []
379
+ curl_maps = []
380
+ strain_maps = []
381
+
382
+ # Log initialization
383
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
384
+ log = f"Tissue Deformation and Flow Analysis - {timestamp}\n"
385
+ log += f"Number of frames analyzed: {num_frames}\n"
386
+ log += f"Image dimensions: {frames[0].shape[0]}x{frames[0].shape[1]} pixels\n"
387
+ log += f"Pixel scale: {pixel_scale} units/pixel\n\n"
388
+ log += "Analysis Steps:\n"
389
+
390
+ # Create feature points grid (evenly spaced points)
391
+ y, x = np.mgrid[0 : frames[0].shape[0] : 20, 0 : frames[0].shape[1] : 20]
392
+ feature_points = np.stack((x.flatten(), y.flatten()), axis=1).astype(np.float32)
393
+
394
+ for i in range(num_frames - 1):
395
+ log += f"Processing frame pair {i} and {i + 1}...\n"
396
+
397
+ # Calculate optical flow using Lucas-Kanade
398
+ prev_frame = frames[i]
399
+ next_frame = frames[i + 1]
400
+
401
+ # Compute flow for the grid points
402
+ next_points, status, _ = cv2.calcOpticalFlowPyrLK(prev_frame, next_frame, feature_points, None, **lk_params)
403
+
404
+ # Filter valid points
405
+ valid_idx = status.flatten() == 1
406
+ valid_prev_points = feature_points[valid_idx]
407
+ valid_next_points = next_points[valid_idx]
408
+
409
+ # Calculate displacement vectors
410
+ displacement = valid_next_points - valid_prev_points
411
+ points = valid_prev_points
412
+
413
+ # Interpolate flow field to full image resolution
414
+ flow_field = np.zeros((frames[0].shape[0], frames[0].shape[1], 2), dtype=np.float32)
415
+
416
+ # Simple nearest-neighbor interpolation for demonstration
417
+ # In a production system, you might use more sophisticated interpolation
418
+ for j, (x, y) in enumerate(points.astype(int)):
419
+ if 0 <= y < flow_field.shape[0] and 0 <= x < flow_field.shape[1]:
420
+ flow_field[y, x] = displacement[j]
421
+
422
+ # Calculate derivatives for deformation analysis
423
+ u = flow_field[:, :, 0] # x-component of flow
424
+ v = flow_field[:, :, 1] # y-component of flow
425
+
426
+ # Calculate divergence (expansion/contraction)
427
+ # Using central differences for derivatives
428
+ u_x = cv2.Sobel(u, cv2.CV_64F, 1, 0, ksize=3) / (8.0 * pixel_scale)
429
+ v_y = cv2.Sobel(v, cv2.CV_64F, 0, 1, ksize=3) / (8.0 * pixel_scale)
430
+ divergence = u_x + v_y
431
+
432
+ # Calculate curl (rotation)
433
+ u_y = cv2.Sobel(u, cv2.CV_64F, 0, 1, ksize=3) / (8.0 * pixel_scale)
434
+ v_x = cv2.Sobel(v, cv2.CV_64F, 1, 0, ksize=3) / (8.0 * pixel_scale)
435
+ curl = v_x - u_y
436
+
437
+ # Calculate strain tensor components
438
+ strain_xx = u_x
439
+ strain_yy = v_y
440
+ strain_xy = 0.5 * (u_y + v_x)
441
+
442
+ # Magnitude of strain tensor (Frobenius norm)
443
+ strain_magnitude = np.sqrt(strain_xx**2 + strain_yy**2 + 2 * strain_xy**2)
444
+
445
+ # Store results
446
+ flow_fields.append(flow_field)
447
+ divergence_maps.append(divergence)
448
+ curl_maps.append(curl)
449
+ strain_maps.append(strain_magnitude)
450
+
451
+ # Visualize flow field
452
+ flow_viz = np.zeros((frames[0].shape[0], frames[0].shape[1], 3), dtype=np.uint8)
453
+ flow_viz[..., 0] = next_frame # Use next frame as background
454
+ flow_viz[..., 1] = next_frame
455
+ flow_viz[..., 2] = next_frame
456
+
457
+ # Draw flow vectors for visualization
458
+ step = 20
459
+ for y in range(0, flow_field.shape[0], step):
460
+ for x in range(0, flow_field.shape[1], step):
461
+ dx, dy = flow_field[y, x]
462
+ if abs(dx) > 0.5 or abs(dy) > 0.5: # Only draw significant flow
463
+ cv2.arrowedLine(
464
+ flow_viz,
465
+ (x, y),
466
+ (int(x + dx), int(y + dy)),
467
+ (0, 255, 0), # Green
468
+ 1,
469
+ tipLength=0.3,
470
+ )
471
+
472
+ # Save visualizations
473
+ flow_viz_path = os.path.join(output_dir, f"flow_viz_{i:03d}.png")
474
+ divergence_path = os.path.join(output_dir, f"divergence_{i:03d}.npy")
475
+ curl_path = os.path.join(output_dir, f"curl_{i:03d}.npy")
476
+ strain_path = os.path.join(output_dir, f"strain_{i:03d}.npy")
477
+
478
+ cv2.imwrite(flow_viz_path, flow_viz)
479
+ np.save(divergence_path, divergence)
480
+ np.save(curl_path, curl)
481
+ np.save(strain_path, strain_magnitude)
482
+
483
+ # Calculate summary statistics
484
+ mean_divergence = np.mean([np.mean(div) for div in divergence_maps])
485
+ max_divergence = np.max([np.max(div) for div in divergence_maps])
486
+ mean_curl = np.mean([np.mean(np.abs(c)) for c in curl_maps])
487
+ mean_strain = np.mean([np.mean(s) for s in strain_maps])
488
+
489
+ # Add summary to log
490
+ log += "\nAnalysis Results:\n"
491
+ log += f"Mean tissue divergence: {mean_divergence:.6f} (expansion/contraction rate)\n"
492
+ log += f"Maximum divergence: {max_divergence:.6f}\n"
493
+ log += f"Mean absolute curl: {mean_curl:.6f} (rotation rate)\n"
494
+ log += f"Mean strain magnitude: {mean_strain:.6f} (deformation intensity)\n\n"
495
+
496
+ # Save summary data
497
+ summary_data = {
498
+ "mean_divergence": mean_divergence,
499
+ "max_divergence": max_divergence,
500
+ "mean_curl": mean_curl,
501
+ "mean_strain": mean_strain,
502
+ }
503
+ summary_path = os.path.join(output_dir, "deformation_summary.npy")
504
+ np.save(summary_path, summary_data)
505
+
506
+ log += "Files Generated:\n"
507
+ log += f"- Flow visualization images: {output_dir}/flow_viz_*.png\n"
508
+ log += f"- Divergence maps: {output_dir}/divergence_*.npy\n"
509
+ log += f"- Curl maps: {output_dir}/curl_*.npy\n"
510
+ log += f"- Strain maps: {output_dir}/strain_*.npy\n"
511
+ log += f"- Summary statistics: {output_dir}/deformation_summary.npy\n"
512
+
513
+ return log
BioScientist/agent_system/engines/v1_executor_backup/tool/cancer_biology.py ADDED
@@ -0,0 +1,1292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def analyze_ddr_network_in_cancer(expression_data_path, mutation_data_path, output_dir="./results"):
2
+ """Analyze DNA Damage Response (DDR) network alterations and dependencies in cancer samples.
3
+
4
+ This function reconstructs the DDR network from genomic data, identifies disruptions
5
+ in the network, and analyzes dependencies between DDR pathway components in cancer.
6
+
7
+ Parameters
8
+ ----------
9
+ expression_data_path : str
10
+ Path to gene expression data file (CSV format with genes as rows, samples as columns)
11
+ mutation_data_path : str
12
+ Path to mutation data file (CSV format with genes as rows, samples as columns,
13
+ values indicating mutation status)
14
+ output_dir : str, optional
15
+ Directory to save output files (default: "./results")
16
+
17
+ Returns
18
+ -------
19
+ str
20
+ Research log summarizing the DDR network analysis, findings about disruptions,
21
+ and potential therapeutic vulnerabilities
22
+
23
+ """
24
+ import os
25
+ from datetime import datetime
26
+
27
+ import gseapy as gp
28
+ import networkx as nx
29
+ import pandas as pd
30
+ from scipy.stats import pearsonr
31
+
32
+ # Create output directory if it doesn't exist
33
+ os.makedirs(output_dir, exist_ok=True)
34
+
35
+ research_log = f"DDR Network Analysis Research Log - {datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
36
+ research_log += "=" * 80 + "\n\n"
37
+
38
+ # Step 1: Load and preprocess genomic data
39
+ research_log += "STEP 1: Loading and preprocessing genomic data\n"
40
+
41
+ try:
42
+ expr_data = pd.read_csv(expression_data_path, index_col=0)
43
+ mut_data = pd.read_csv(mutation_data_path, index_col=0)
44
+
45
+ research_log += f"- Loaded expression data: {expr_data.shape[0]} genes × {expr_data.shape[1]} samples\n"
46
+ research_log += f"- Loaded mutation data: {mut_data.shape[0]} genes × {mut_data.shape[1]} samples\n"
47
+
48
+ # Define core DDR genes (based on literature)
49
+ ddr_genes = [
50
+ # DNA damage sensors
51
+ "ATM",
52
+ "ATR",
53
+ "PRKDC",
54
+ "RAD50",
55
+ "MRE11",
56
+ "NBN",
57
+ # Signal transducers
58
+ "CHEK1",
59
+ "CHEK2",
60
+ "TP53",
61
+ "BRCA1",
62
+ "BRCA2",
63
+ "MDC1",
64
+ "H2AX",
65
+ # Effectors - Homologous Recombination
66
+ "RAD51",
67
+ "RAD52",
68
+ "PALB2",
69
+ "RAD54L",
70
+ # Effectors - Non-Homologous End Joining
71
+ "XRCC4",
72
+ "LIG4",
73
+ "XRCC5",
74
+ "XRCC6",
75
+ # Effectors - Base Excision Repair
76
+ "PARP1",
77
+ "APEX1",
78
+ "OGG1",
79
+ "XRCC1",
80
+ # Effectors - Nucleotide Excision Repair
81
+ "XPA",
82
+ "XPC",
83
+ "ERCC1",
84
+ "ERCC2",
85
+ "ERCC3",
86
+ "ERCC4",
87
+ "ERCC5",
88
+ # Effectors - Mismatch Repair
89
+ "MLH1",
90
+ "MSH2",
91
+ "MSH6",
92
+ "PMS2",
93
+ ]
94
+
95
+ # Filter expression and mutation data for DDR genes
96
+ ddr_expr = expr_data.loc[expr_data.index.isin(ddr_genes)]
97
+ ddr_mut = mut_data.loc[mut_data.index.isin(ddr_genes)]
98
+
99
+ research_log += f"- Filtered data for {len(ddr_genes)} DDR pathway genes\n"
100
+ research_log += f"- Found {ddr_expr.shape[0]} DDR genes in expression data\n"
101
+ research_log += f"- Found {ddr_mut.shape[0]} DDR genes in mutation data\n\n"
102
+
103
+ except Exception as e:
104
+ research_log += f"Error in data loading: {str(e)}\n\n"
105
+ return research_log
106
+
107
+ # Step 2: Network reconstruction
108
+ research_log += "STEP 2: Reconstructing DDR gene network\n"
109
+
110
+ # Create correlation-based network
111
+ G = nx.Graph()
112
+
113
+ # Add nodes (genes)
114
+ for gene in ddr_expr.index:
115
+ # Add node with mutation frequency information
116
+ mutation_freq = ddr_mut.loc[gene].mean() if gene in ddr_mut.index else 0
117
+
118
+ G.add_node(gene, mutation_freq=mutation_freq)
119
+
120
+ # Add edges based on gene expression correlation
121
+ for i, gene1 in enumerate(ddr_expr.index):
122
+ for gene2 in ddr_expr.index[i + 1 :]:
123
+ corr, p_value = pearsonr(ddr_expr.loc[gene1], ddr_expr.loc[gene2])
124
+ if abs(corr) > 0.4 and p_value < 0.05: # Significant correlation threshold
125
+ G.add_edge(gene1, gene2, weight=abs(corr), correlation=corr)
126
+
127
+ research_log += f"- Constructed network with {G.number_of_nodes()} nodes and {G.number_of_edges()} edges\n"
128
+
129
+ # Save network for visualization
130
+ network_file = os.path.join(output_dir, "ddr_network.graphml")
131
+ nx.write_graphml(G, network_file)
132
+ research_log += f"- Network saved to: {network_file}\n\n"
133
+
134
+ # Step 3: Network analysis
135
+ research_log += "STEP 3: Analyzing DDR network properties\n"
136
+
137
+ # Calculate network centrality measures
138
+ degree_centrality = nx.degree_centrality(G)
139
+ betweenness_centrality = nx.betweenness_centrality(G)
140
+
141
+ # Identify hub genes (high degree centrality)
142
+ hub_genes = sorted(degree_centrality.items(), key=lambda x: x[1], reverse=True)[:5]
143
+
144
+ research_log += "- Top 5 hub genes (highest connectivity):\n"
145
+ for gene, centrality in hub_genes:
146
+ research_log += f" * {gene}: {centrality:.4f}\n"
147
+
148
+ # Identify bottleneck genes (high betweenness centrality)
149
+ bottleneck_genes = sorted(betweenness_centrality.items(), key=lambda x: x[1], reverse=True)[:5]
150
+
151
+ research_log += "- Top 5 bottleneck genes (critical for information flow):\n"
152
+ for gene, centrality in bottleneck_genes:
153
+ research_log += f" * {gene}: {centrality:.4f}\n"
154
+
155
+ # Identify frequently mutated DDR genes
156
+ mutation_freq = {node: G.nodes[node]["mutation_freq"] for node in G.nodes()}
157
+ frequently_mutated = sorted(mutation_freq.items(), key=lambda x: x[1], reverse=True)[:5]
158
+
159
+ research_log += "- Top 5 frequently mutated DDR genes:\n"
160
+ for gene, freq in frequently_mutated:
161
+ research_log += f" * {gene}: {freq:.4f}\n\n"
162
+
163
+ # Step 4: Community detection to identify DDR sub-pathways
164
+ research_log += "STEP 4: Identifying DDR sub-pathways through community detection\n"
165
+
166
+ # Use Louvain method for community detection
167
+ try:
168
+ from community import best_partition
169
+
170
+ partition = best_partition(G)
171
+ communities = {}
172
+ for node, community_id in partition.items():
173
+ if community_id not in communities:
174
+ communities[community_id] = []
175
+ communities[community_id].append(node)
176
+
177
+ research_log += f"- Identified {len(communities)} DDR sub-pathways\n"
178
+
179
+ for i, (_, genes) in enumerate(communities.items()):
180
+ research_log += f" * Sub-pathway {i + 1}: {', '.join(genes)}\n"
181
+
182
+ except ImportError:
183
+ research_log += "- Community detection skipped (python-louvain package not installed)\n"
184
+
185
+ # Step 5: Identify disrupted DDR pathways using GSEA
186
+ research_log += "\nSTEP 5: Pathway enrichment analysis of DDR genes\n"
187
+
188
+ try:
189
+ # Create gene list for GSEA
190
+ gene_list = ddr_expr.mean(axis=1).sort_values(ascending=False)
191
+
192
+ # Run GSEA with GO Biological Process
193
+ enrichr_results = gp.enrichr(
194
+ gene_list=gene_list.index.tolist(),
195
+ gene_sets=["GO_Biological_Process_2021"],
196
+ outdir=os.path.join(output_dir, "enrichr_output"),
197
+ cutoff=0.5,
198
+ )
199
+
200
+ # Filter for DNA repair related terms
201
+ ddr_terms = [
202
+ term
203
+ for term in enrichr_results.results["Term"]
204
+ if any(x in term.lower() for x in ["dna repair", "damage", "recombination", "checkpoint"])
205
+ ]
206
+
207
+ if ddr_terms:
208
+ research_log += "- Enriched DDR-related pathways:\n"
209
+ for term in ddr_terms[:5]: # Top 5 DDR-related terms
210
+ research_log += f" * {term}\n"
211
+ else:
212
+ research_log += "- No significant DDR pathway enrichment found\n"
213
+
214
+ # Save full enrichment results
215
+ enrichment_file = os.path.join(output_dir, "ddr_pathway_enrichment.csv")
216
+ enrichr_results.results.to_csv(enrichment_file)
217
+ research_log += f"- Full enrichment results saved to: {enrichment_file}\n\n"
218
+
219
+ except Exception as e:
220
+ research_log += f"- Pathway enrichment analysis error: {str(e)}\n\n"
221
+
222
+ # Step 6: Summary of findings
223
+ research_log += "SUMMARY OF FINDINGS\n"
224
+ research_log += "=" * 80 + "\n"
225
+
226
+ # Key hub genes (potential therapeutic targets)
227
+ research_log += "1. Key DDR hub genes that may serve as potential therapeutic targets:\n"
228
+ for gene, _ in hub_genes[:3]:
229
+ research_log += f" - {gene}\n"
230
+
231
+ # Frequently mutated genes
232
+ research_log += "\n2. Frequently mutated DDR genes in the samples:\n"
233
+ for gene, freq in frequently_mutated[:3]:
234
+ research_log += f" - {gene} (mutation frequency: {freq:.2%})\n"
235
+
236
+ # Network structure insights
237
+ research_log += "\n3. DDR network structure insights:\n"
238
+ research_log += f" - Network density: {nx.density(G):.4f}\n"
239
+
240
+ try:
241
+ avg_clustering = nx.average_clustering(G)
242
+ research_log += f" - Average clustering coefficient: {avg_clustering:.4f}\n"
243
+ except Exception:
244
+ pass
245
+
246
+ research_log += "\n4. Potential DDR dependencies and synthetic lethality targets:\n"
247
+ # Identify potential synthetic lethality pairs (genes with high correlation and one is frequently mutated)
248
+ synthetic_lethality_candidates = []
249
+ for gene1, gene2, data in G.edges(data=True):
250
+ if abs(data["correlation"]) > 0.6: # Strong correlation
251
+ mut_freq1 = G.nodes[gene1]["mutation_freq"]
252
+ mut_freq2 = G.nodes[gene2]["mutation_freq"]
253
+ if (mut_freq1 > 0.1 and mut_freq2 < 0.05) or (mut_freq2 > 0.1 and mut_freq1 < 0.05):
254
+ if mut_freq1 > mut_freq2:
255
+ synthetic_lethality_candidates.append((gene1, gene2, data["correlation"], mut_freq1))
256
+ else:
257
+ synthetic_lethality_candidates.append((gene2, gene1, data["correlation"], mut_freq2))
258
+
259
+ if synthetic_lethality_candidates:
260
+ for mutated, target, _, _ in sorted(synthetic_lethality_candidates, key=lambda x: x[3], reverse=True)[:3]:
261
+ research_log += f" - {mutated} (frequently mutated) and {target} (potential dependency)\n"
262
+ else:
263
+ research_log += " - No strong synthetic lethality candidates identified\n"
264
+
265
+ return research_log
266
+
267
+
268
+ def analyze_cell_senescence_and_apoptosis(fcs_file_path):
269
+ """Analyze flow cytometry data to quantify senescent and apoptotic cell populations.
270
+
271
+ Parameters
272
+ ----------
273
+ fcs_file_path : str
274
+ Path to the FCS file containing flow cytometry data with measurements for
275
+ senescence-associated β-galactosidase (SA-β-Gal) and Annexin V/7-AAD staining
276
+
277
+ Returns
278
+ -------
279
+ str
280
+ A research log summarizing the analysis steps and results, including percentages
281
+ of senescent and apoptotic cell populations
282
+
283
+ """
284
+ import os
285
+
286
+ import numpy as np
287
+ from FlowCytometryTools import FCMeasurement
288
+
289
+ log = "# Flow Cytometry Analysis of Cell Senescence and Apoptosis\n\n"
290
+
291
+ try:
292
+ # Step 1: Load the FCS file
293
+ log += "## Step 1: Loading Flow Cytometry Data\n"
294
+ log += f"- Loading FCS file from: {fcs_file_path}\n"
295
+
296
+ sample = FCMeasurement(ID="Sample", datafile=fcs_file_path)
297
+ log += f"- Successfully loaded data with {len(sample)} events\n"
298
+ log += f"- Available channels: {', '.join(sample.channel_names)}\n\n"
299
+
300
+ # Step 2: Apply compensation if needed (assuming pre-compensated data)
301
+ log += "## Step 2: Data Preprocessing\n"
302
+ log += "- Checking for outliers and debris based on forward/side scatter\n"
303
+
304
+ # Basic filtering to remove debris based on FSC and SSC
305
+ # Assuming FSC-A and SSC-A are the channel names (adjust if different)
306
+ fsc_channel = "FSC-A" if "FSC-A" in sample.channel_names else sample.channel_names[0]
307
+ ssc_channel = "SSC-A" if "SSC-A" in sample.channel_names else sample.channel_names[1]
308
+
309
+ # Filter out debris (low FSC and SSC)
310
+ sample_filtered = sample.gate(f"{fsc_channel} > 10000 and {ssc_channel} > 5000")
311
+
312
+ log += f"- Filtered out debris: {len(sample)} → {len(sample_filtered)} events ({len(sample_filtered) / len(sample) * 100:.1f}%)\n\n"
313
+
314
+ # Step 3: Identify senescent cells (SA-β-Gal positive)
315
+ log += "## Step 3: Identifying Senescent Cells (SA-β-Gal+)\n"
316
+
317
+ # Find SA-β-Gal channel (adjust based on actual channel name)
318
+ sa_bgal_channel = None
319
+ for channel in sample_filtered.channel_names:
320
+ if "GAL" in channel.upper() or "FITC" in channel.upper():
321
+ sa_bgal_channel = channel
322
+ break
323
+
324
+ if not sa_bgal_channel:
325
+ log += "- WARNING: Could not identify SA-β-Gal channel. Using first fluorescence channel as placeholder.\n"
326
+ # Use the first fluorescence channel as a fallback
327
+ for channel in sample_filtered.channel_names:
328
+ if any(x in channel.upper() for x in ["FL", "BL", "FITC", "PE", "APC"]):
329
+ sa_bgal_channel = channel
330
+ break
331
+
332
+ log += f"- Using {sa_bgal_channel} as SA-β-Gal activity indicator\n"
333
+
334
+ # Determine threshold for SA-β-Gal positivity (using a simple percentile approach)
335
+ # In practice, this would be based on controls or known thresholds
336
+ sa_bgal_threshold = np.percentile(sample_filtered.data[sa_bgal_channel], 80)
337
+ senescent_cells = sample_filtered.gate(f"{sa_bgal_channel} > {sa_bgal_threshold}")
338
+
339
+ senescent_percentage = (len(senescent_cells) / len(sample_filtered)) * 100
340
+ log += f"- Applied threshold at {sa_bgal_threshold:.1f} fluorescence intensity\n"
341
+ log += f"- Identified {len(senescent_cells)} senescent cells ({senescent_percentage:.2f}%)\n\n"
342
+
343
+ # Step 4: Identify apoptotic cells (Annexin V+/7-AAD+)
344
+ log += "## Step 4: Identifying Apoptotic Cells (Annexin V/7-AAD)\n"
345
+
346
+ # Find Annexin V and 7-AAD channels (adjust based on actual channel names)
347
+ annexin_channel = None
348
+ aad_channel = None
349
+
350
+ for channel in sample_filtered.channel_names:
351
+ if "ANNEXIN" in channel.upper() or "PE" in channel.upper():
352
+ annexin_channel = channel
353
+ if "7AAD" in channel.upper() or "AAD" in channel.upper() or "PerCP" in channel.upper():
354
+ aad_channel = channel
355
+
356
+ if not annexin_channel or not aad_channel:
357
+ log += "- WARNING: Could not identify Annexin V and/or 7-AAD channels. Using placeholder channels.\n"
358
+ # Use fallback channels
359
+ fluorescence_channels = [
360
+ ch
361
+ for ch in sample_filtered.channel_names
362
+ if any(x in ch.upper() for x in ["FL", "BL", "FITC", "PE", "APC", "PerCP"])
363
+ ]
364
+ if len(fluorescence_channels) >= 2:
365
+ if not annexin_channel:
366
+ annexin_channel = fluorescence_channels[0]
367
+ if not aad_channel:
368
+ aad_channel = fluorescence_channels[1]
369
+
370
+ log += f"- Using {annexin_channel} as Annexin V indicator\n"
371
+ log += f"- Using {aad_channel} as 7-AAD indicator\n"
372
+
373
+ # Determine thresholds for Annexin V and 7-AAD (using simple percentile approach)
374
+ annexin_threshold = np.percentile(sample_filtered.data[annexin_channel], 90)
375
+ aad_threshold = np.percentile(sample_filtered.data[aad_channel], 90)
376
+
377
+ # Early apoptotic: Annexin V+ / 7-AAD-
378
+ early_apoptotic = sample_filtered.gate(
379
+ f"{annexin_channel} > {annexin_threshold} and {aad_channel} < {aad_threshold}"
380
+ )
381
+ early_apoptotic_percentage = (len(early_apoptotic) / len(sample_filtered)) * 100
382
+
383
+ # Late apoptotic/necrotic: Annexin V+ / 7-AAD+
384
+ late_apoptotic = sample_filtered.gate(
385
+ f"{annexin_channel} > {annexin_threshold} and {aad_channel} > {aad_threshold}"
386
+ )
387
+ late_apoptotic_percentage = (len(late_apoptotic) / len(sample_filtered)) * 100
388
+
389
+ # Total apoptotic (early + late)
390
+ total_apoptotic_percentage = early_apoptotic_percentage + late_apoptotic_percentage
391
+
392
+ log += f"- Early apoptotic cells (Annexin V+/7-AAD-): {early_apoptotic_percentage:.2f}%\n"
393
+ log += f"- Late apoptotic cells (Annexin V+/7-AAD+): {late_apoptotic_percentage:.2f}%\n"
394
+ log += f"- Total apoptotic cells: {total_apoptotic_percentage:.2f}%\n\n"
395
+
396
+ # Step 5: Summary of results
397
+ log += "## Summary of Results\n"
398
+ log += f"- Total events analyzed: {len(sample_filtered)}\n"
399
+ log += f"- Senescent cells (SA-β-Gal+): {senescent_percentage:.2f}%\n"
400
+ log += f"- Early apoptotic cells (Annexin V+/7-AAD-): {early_apoptotic_percentage:.2f}%\n"
401
+ log += f"- Late apoptotic cells (Annexin V+/7-AAD+): {late_apoptotic_percentage:.2f}%\n"
402
+ log += f"- Total apoptotic cells: {total_apoptotic_percentage:.2f}%\n"
403
+
404
+ # Save results to CSV file
405
+ results_file = os.path.splitext(os.path.basename(fcs_file_path))[0] + "_results.csv"
406
+ with open(results_file, "w") as f:
407
+ f.write("Cell Population,Percentage\n")
408
+ f.write(f"Senescent cells,{senescent_percentage:.2f}\n")
409
+ f.write(f"Early apoptotic cells,{early_apoptotic_percentage:.2f}\n")
410
+ f.write(f"Late apoptotic cells,{late_apoptotic_percentage:.2f}\n")
411
+ f.write(f"Total apoptotic cells,{total_apoptotic_percentage:.2f}\n")
412
+
413
+ log += f"\nResults saved to: {results_file}\n"
414
+
415
+ except Exception as e:
416
+ log += "\n## ERROR: An error occurred during analysis\n"
417
+ log += f"- Error message: {str(e)}\n"
418
+
419
+ return log
420
+
421
+
422
+ def detect_and_annotate_somatic_mutations(
423
+ tumor_bam, normal_bam, reference_genome, output_prefix, snpeff_database="GRCh38.105"
424
+ ):
425
+ """Detects and annotates somatic mutations in tumor samples compared to matched normal samples.
426
+
427
+ This function uses GATK Mutect2 for variant calling, GATK FilterMutectCalls for filtering,
428
+ and SnpEff for functional annotation of somatic mutations.
429
+
430
+ Parameters
431
+ ----------
432
+ tumor_bam : str
433
+ Path to the tumor sample BAM file
434
+ normal_bam : str
435
+ Path to the matched normal sample BAM file
436
+ reference_genome : str
437
+ Path to the reference genome FASTA file
438
+ output_prefix : str
439
+ Prefix for output files
440
+ snpeff_database : str, optional
441
+ SnpEff database to use for annotation (default: "GRCh38.105")
442
+
443
+ Returns
444
+ -------
445
+ str
446
+ A research log summarizing the steps performed and results obtained
447
+
448
+ """
449
+ import datetime
450
+ import os
451
+ import subprocess
452
+
453
+ # Initialize research log
454
+ log = "# Somatic Mutation Analysis Log\n"
455
+ log += f"Date: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
456
+ log += "## Input Files\n"
457
+ log += f"- Tumor BAM: {tumor_bam}\n"
458
+ log += f"- Normal BAM: {normal_bam}\n"
459
+ log += f"- Reference Genome: {reference_genome}\n\n"
460
+
461
+ # Step 1: Run Mutect2 for somatic variant calling
462
+ log += "## Step 1: Somatic Variant Calling with Mutect2\n"
463
+ raw_vcf = f"{output_prefix}.unfiltered.vcf"
464
+
465
+ mutect2_cmd = [
466
+ "gatk",
467
+ "Mutect2",
468
+ "-R",
469
+ reference_genome,
470
+ "-I",
471
+ tumor_bam,
472
+ "-I",
473
+ normal_bam,
474
+ "-normal",
475
+ os.path.basename(normal_bam).split(".")[0],
476
+ "-O",
477
+ raw_vcf,
478
+ ]
479
+
480
+ log += f"Running command: {' '.join(mutect2_cmd)}\n"
481
+ try:
482
+ subprocess.run(mutect2_cmd, check=True, capture_output=True)
483
+ log += f"Successfully generated raw VCF: {raw_vcf}\n\n"
484
+ except subprocess.CalledProcessError as e:
485
+ log += f"Error running Mutect2: {str(e)}\n"
486
+ return log
487
+
488
+ # Step 2: Filter somatic calls
489
+ log += "## Step 2: Filtering Somatic Variants\n"
490
+ filtered_vcf = f"{output_prefix}.filtered.vcf"
491
+
492
+ filter_cmd = [
493
+ "gatk",
494
+ "FilterMutectCalls",
495
+ "-R",
496
+ reference_genome,
497
+ "-V",
498
+ raw_vcf,
499
+ "-O",
500
+ filtered_vcf,
501
+ ]
502
+
503
+ log += f"Running command: {' '.join(filter_cmd)}\n"
504
+ try:
505
+ subprocess.run(filter_cmd, check=True, capture_output=True)
506
+ log += f"Successfully filtered variants: {filtered_vcf}\n\n"
507
+ except subprocess.CalledProcessError as e:
508
+ log += f"Error filtering variants: {str(e)}\n"
509
+ return log
510
+
511
+ # Step 3: Annotate variants with SnpEff
512
+ log += "## Step 3: Functional Annotation with SnpEff\n"
513
+ annotated_vcf = f"{output_prefix}.annotated.vcf"
514
+
515
+ snpeff_cmd = ["snpEff", "-v", snpeff_database, filtered_vcf, ">", annotated_vcf]
516
+
517
+ log += f"Running command: {' '.join(snpeff_cmd)}\n"
518
+ try:
519
+ # Using shell=True because of the redirection
520
+ subprocess.run(" ".join(snpeff_cmd), shell=True, check=True)
521
+ log += f"Successfully annotated variants: {annotated_vcf}\n\n"
522
+ except subprocess.CalledProcessError as e:
523
+ log += f"Error annotating variants: {str(e)}\n"
524
+ return log
525
+
526
+ # Step 4: Generate summary statistics
527
+ log += "## Step 4: Generating Summary Statistics\n"
528
+ summary_file = f"{output_prefix}_mutation_summary.txt"
529
+
530
+ # Count total variants
531
+ count_cmd = f"grep -v '^#' {annotated_vcf} | wc -l"
532
+ try:
533
+ total_variants = subprocess.check_output(count_cmd, shell=True).decode().strip()
534
+ log += f"Total somatic variants detected: {total_variants}\n"
535
+ except subprocess.CalledProcessError as e:
536
+ log += f"Error counting variants: {str(e)}\n"
537
+
538
+ # Count by variant type
539
+ log += "Variant types:\n"
540
+ for variant_type in ["SNP", "INS", "DEL"]:
541
+ count_type_cmd = f"grep -v '^#' {annotated_vcf} | grep '{variant_type}' | wc -l"
542
+ try:
543
+ type_count = subprocess.check_output(count_type_cmd, shell=True).decode().strip()
544
+ log += f"- {variant_type}: {type_count}\n"
545
+ except subprocess.CalledProcessError:
546
+ log += f"- {variant_type}: Error counting\n"
547
+
548
+ # Count high impact variants
549
+ high_impact_cmd = f"grep -v '^#' {annotated_vcf} | grep 'HIGH' | wc -l"
550
+ try:
551
+ high_impact = subprocess.check_output(high_impact_cmd, shell=True).decode().strip()
552
+ log += f"High impact variants: {high_impact}\n\n"
553
+ except subprocess.CalledProcessError:
554
+ log += "High impact variants: Error counting\n\n"
555
+
556
+ # Step 5: Save summary to file
557
+ with open(summary_file, "w") as f:
558
+ f.write("Somatic Mutation Analysis Summary\n")
559
+ f.write(f"Date: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
560
+ f.write(f"Total somatic variants: {total_variants}\n")
561
+ f.write(f"Output VCF file: {annotated_vcf}\n")
562
+
563
+ log += "## Results\n"
564
+ log += "Analysis complete. Results saved to:\n"
565
+ log += f"- Annotated VCF: {annotated_vcf}\n"
566
+ log += f"- Summary file: {summary_file}\n\n"
567
+
568
+ return log
569
+
570
+
571
+ def detect_and_characterize_structural_variations(
572
+ bam_file_path,
573
+ reference_genome_path,
574
+ output_dir,
575
+ cosmic_db_path=None,
576
+ clinvar_db_path=None,
577
+ ):
578
+ """Detects and characterizes structural variations (SVs) in genomic sequencing data.
579
+
580
+ This function uses LUMPY for SV detection followed by annotation with COSMIC and/or ClinVar
581
+ databases to identify and characterize various types of structural variations including
582
+ deletions, inversions, translocations, and duplications.
583
+
584
+ Parameters
585
+ ----------
586
+ bam_file_path : str
587
+ Path to the aligned sequencing data in BAM format
588
+ reference_genome_path : str
589
+ Path to the reference genome in FASTA format
590
+ output_dir : str
591
+ Directory where results will be saved
592
+ cosmic_db_path : str, optional
593
+ Path to the COSMIC database for cancer annotation
594
+ clinvar_db_path : str, optional
595
+ Path to the ClinVar database for clinical annotation
596
+
597
+ Returns
598
+ -------
599
+ str
600
+ A research log summarizing the steps performed and results obtained
601
+
602
+ """
603
+ import datetime
604
+ import os
605
+ import subprocess
606
+
607
+ # Create output directory if it doesn't exist
608
+ os.makedirs(output_dir, exist_ok=True)
609
+
610
+ # Initialize research log
611
+ log = []
612
+ log.append("## Structural Variation Detection and Characterization")
613
+ log.append(f"Started at: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
614
+ log.append(f"Input BAM file: {bam_file_path}")
615
+ log.append(f"Reference genome: {reference_genome_path}")
616
+ log.append(f"Output directory: {output_dir}")
617
+ log.append("\n")
618
+
619
+ # Step 1: Extract discordant read-pairs and split-reads for LUMPY
620
+ log.append("### Step 1: Extracting discordant read-pairs and split-reads")
621
+
622
+ discordant_bam = os.path.join(output_dir, "discordant.bam")
623
+ split_bam = os.path.join(output_dir, "split.bam")
624
+
625
+ try:
626
+ # Extract discordant read pairs
627
+ subprocess.run(
628
+ [
629
+ "samtools",
630
+ "view",
631
+ "-b",
632
+ "-F",
633
+ "1294",
634
+ "-o",
635
+ discordant_bam,
636
+ bam_file_path,
637
+ ],
638
+ check=True,
639
+ )
640
+
641
+ # Extract split reads
642
+ subprocess.run(
643
+ [
644
+ "samtools",
645
+ "view",
646
+ "-h",
647
+ bam_file_path,
648
+ "|",
649
+ "extractSplitReads_BwaMem",
650
+ "-i",
651
+ "stdin",
652
+ "|",
653
+ "samtools",
654
+ "view",
655
+ "-Sb",
656
+ "-",
657
+ ">",
658
+ split_bam,
659
+ ],
660
+ shell=True,
661
+ check=True,
662
+ )
663
+
664
+ log.append("Successfully extracted discordant read-pairs and split-reads")
665
+ except subprocess.CalledProcessError as e:
666
+ log.append(f"Error during read extraction: {e}")
667
+ return "\n".join(log)
668
+
669
+ # Step 2: Run LUMPY for SV detection
670
+ log.append("\n### Step 2: Running LUMPY for structural variation detection")
671
+
672
+ vcf_output = os.path.join(output_dir, "structural_variants.vcf")
673
+
674
+ try:
675
+ subprocess.run(
676
+ [
677
+ "lumpyexpress",
678
+ "-B",
679
+ bam_file_path,
680
+ "-S",
681
+ split_bam,
682
+ "-D",
683
+ discordant_bam,
684
+ "-o",
685
+ vcf_output,
686
+ ],
687
+ check=True,
688
+ )
689
+
690
+ log.append("LUMPY analysis completed successfully")
691
+ log.append(f"Raw SV calls saved to: {vcf_output}")
692
+ except subprocess.CalledProcessError as e:
693
+ log.append(f"Error during LUMPY execution: {e}")
694
+ return "\n".join(log)
695
+
696
+ # Step 3: Filter SVs by quality and size
697
+ log.append("\n### Step 3: Filtering structural variants by quality and size")
698
+
699
+ filtered_vcf = os.path.join(output_dir, "filtered_structural_variants.vcf")
700
+
701
+ try:
702
+ # Filter SVs with quality score >= 100 and size >= 100bp
703
+ subprocess.run(
704
+ [
705
+ "bcftools",
706
+ "filter",
707
+ "-i",
708
+ "QUAL>=100 && SVLEN>=100",
709
+ "-o",
710
+ filtered_vcf,
711
+ vcf_output,
712
+ ],
713
+ check=True,
714
+ )
715
+
716
+ # Count SVs by type
717
+ sv_counts = {}
718
+ sv_types = ["DEL", "DUP", "INV", "BND", "INS"]
719
+
720
+ for sv_type in sv_types:
721
+ result = subprocess.run(
722
+ ["grep", "-c", f"SVTYPE={sv_type}", filtered_vcf],
723
+ check=False,
724
+ capture_output=True,
725
+ text=True,
726
+ )
727
+
728
+ count = 0
729
+ if result.returncode == 0:
730
+ count = int(result.stdout.strip())
731
+ sv_counts[sv_type] = count
732
+
733
+ log.append("SV filtering completed")
734
+ log.append(f"Filtered SVs saved to: {filtered_vcf}")
735
+ log.append("\nSV counts by type:")
736
+ for sv_type, count in sv_counts.items():
737
+ log.append(f"- {sv_type}: {count}")
738
+ except subprocess.CalledProcessError as e:
739
+ log.append(f"Error during SV filtering: {e}")
740
+ return "\n".join(log)
741
+
742
+ # Step 4: Annotate SVs with COSMIC and ClinVar (if databases provided)
743
+ log.append("\n### Step 4: Annotating structural variants")
744
+
745
+ annotated_vcf = os.path.join(output_dir, "annotated_structural_variants.vcf")
746
+
747
+ try:
748
+ annotation_cmd = ["annotate_sv.py", "-i", filtered_vcf, "-o", annotated_vcf]
749
+
750
+ if cosmic_db_path:
751
+ annotation_cmd.extend(["-c", cosmic_db_path])
752
+ log.append(f"Using COSMIC database: {cosmic_db_path}")
753
+
754
+ if clinvar_db_path:
755
+ annotation_cmd.extend(["-v", clinvar_db_path])
756
+ log.append(f"Using ClinVar database: {clinvar_db_path}")
757
+
758
+ # This is a placeholder for the annotation command
759
+ # In a real implementation, you would use a tool like AnnotSV, VEP, or a custom script
760
+ log.append("Note: Annotation step is simulated in this implementation")
761
+ log.append("In a real scenario, tools like AnnotSV or VEP would be used")
762
+
763
+ # Instead of running the command, we'll create a simple annotated file
764
+ with open(filtered_vcf) as infile, open(annotated_vcf, "w") as outfile:
765
+ for line in infile:
766
+ if line.startswith("#"):
767
+ outfile.write(line)
768
+ else:
769
+ outfile.write(line)
770
+ # In a real implementation, annotation would be added here
771
+
772
+ log.append("SV annotation completed")
773
+ log.append(f"Annotated SVs saved to: {annotated_vcf}")
774
+ except Exception as e:
775
+ log.append(f"Error during SV annotation: {e}")
776
+ return "\n".join(log)
777
+
778
+ # Step 5: Generate summary report
779
+ log.append("\n### Step 5: Generating summary report")
780
+
781
+ summary_file = os.path.join(output_dir, "sv_summary_report.tsv")
782
+
783
+ try:
784
+ # Convert VCF to a tabular format for easier analysis
785
+ subprocess.run(
786
+ [
787
+ "bcftools",
788
+ "query",
789
+ "-f",
790
+ "%CHROM\t%POS\t%INFO/SVTYPE\t%INFO/SVLEN\t%QUAL\n",
791
+ "-o",
792
+ summary_file,
793
+ annotated_vcf,
794
+ ],
795
+ check=True,
796
+ )
797
+
798
+ log.append(f"Summary report generated: {summary_file}")
799
+ except subprocess.CalledProcessError as e:
800
+ log.append(f"Error generating summary report: {e}")
801
+ return "\n".join(log)
802
+
803
+ # Final summary
804
+ log.append("\n## Summary")
805
+ log.append(f"Total SVs detected: {sum(sv_counts.values())}")
806
+ log.append("SV types breakdown:")
807
+ for sv_type, count in sv_counts.items():
808
+ log.append(f"- {sv_type}: {count} ({count / sum(sv_counts.values()) * 100:.1f}%)")
809
+
810
+ log.append("\nFiles generated:")
811
+ log.append(f"- Raw SV calls: {vcf_output}")
812
+ log.append(f"- Filtered SVs: {filtered_vcf}")
813
+ log.append(f"- Annotated SVs: {annotated_vcf}")
814
+ log.append(f"- Summary report: {summary_file}")
815
+
816
+ log.append(f"\nAnalysis completed at: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
817
+
818
+ return "\n".join(log)
819
+
820
+
821
+ def perform_gene_expression_nmf_analysis(
822
+ expression_data_path,
823
+ n_components=10,
824
+ normalize=True,
825
+ output_dir="nmf_results",
826
+ random_state=42,
827
+ ):
828
+ """Performs Non-negative Matrix Factorization (NMF) on gene expression data to extract
829
+ metagenes and their associated sample weights for tumor subtype identification.
830
+
831
+ Parameters
832
+ ----------
833
+ expression_data_path : str
834
+ Path to a CSV or TSV file containing gene expression data with genes as rows and samples as columns.
835
+ Values should be non-negative (e.g., normalized counts or expression values).
836
+ n_components : int, default=10
837
+ Number of metagenes (components) to extract.
838
+ normalize : bool, default=True
839
+ Whether to normalize the expression data before applying NMF.
840
+ output_dir : str, default="nmf_results"
841
+ Directory to save the output files.
842
+ random_state : int, default=42
843
+ Random seed for reproducibility.
844
+
845
+ Returns
846
+ -------
847
+ str
848
+ Research log summarizing the analysis steps and results.
849
+
850
+ """
851
+ import os
852
+ from datetime import datetime
853
+
854
+ import numpy as np
855
+ import pandas as pd
856
+ from sklearn.decomposition import NMF
857
+
858
+ # Start research log
859
+ log = []
860
+ log.append(f"NMF GENE EXPRESSION ANALYSIS - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
861
+ log.append(f"Parameters: n_components={n_components}, normalize={normalize}")
862
+
863
+ # Load expression data from file
864
+ try:
865
+ if expression_data_path.endswith(".csv"):
866
+ expression_data = pd.read_csv(expression_data_path, index_col=0)
867
+ elif expression_data_path.endswith((".tsv", ".txt")):
868
+ expression_data = pd.read_csv(expression_data_path, sep="\t", index_col=0)
869
+ else:
870
+ log.append("ERROR: Unsupported file format. Please provide a CSV or TSV file.")
871
+ return "\n".join(log)
872
+
873
+ log.append(f"Successfully loaded expression data from {expression_data_path}")
874
+ except Exception as e:
875
+ log.append(f"ERROR: Failed to load expression data from {expression_data_path}. Error: {str(e)}")
876
+ return "\n".join(log)
877
+
878
+ # Extract gene and sample information
879
+ genes = expression_data.index.tolist()
880
+ samples = expression_data.columns.tolist()
881
+ X = expression_data.values
882
+ log.append(f"Input data: {len(genes)} genes × {len(samples)} samples")
883
+
884
+ # Check for non-negative values
885
+ if np.any(X < 0):
886
+ log.append("WARNING: Negative values found in expression data. Converting to absolute values.")
887
+ X = np.abs(X)
888
+
889
+ # Normalize data if requested
890
+ if normalize:
891
+ log.append("Normalizing expression data...")
892
+ X = X / np.sum(X, axis=0, keepdims=True) * 1000 # TPM-like normalization
893
+
894
+ # Create output directory if it doesn't exist
895
+ if not os.path.exists(output_dir):
896
+ os.makedirs(output_dir)
897
+ log.append(f"Created output directory: {output_dir}")
898
+
899
+ # Apply NMF
900
+ log.append(f"Applying NMF to extract {n_components} metagenes...")
901
+ model = NMF(
902
+ n_components=n_components,
903
+ init="random",
904
+ random_state=random_state,
905
+ max_iter=1000,
906
+ )
907
+
908
+ try:
909
+ # W: metagenes (genes × components)
910
+ # H: sample weights (components × samples)
911
+ W = model.fit_transform(X)
912
+ H = model.components_
913
+
914
+ log.append(f"NMF completed successfully in {model.n_iter_} iterations")
915
+ log.append(f"Explained variance: {model.reconstruction_err_:.4f}")
916
+
917
+ # Save metagenes (W matrix)
918
+ metagenes_file = os.path.join(output_dir, "metagenes.csv")
919
+ pd.DataFrame(W, index=genes, columns=[f"Metagene_{i + 1}" for i in range(n_components)]).to_csv(metagenes_file)
920
+ log.append(f"Saved metagenes to {metagenes_file}")
921
+
922
+ # Save sample weights (H matrix)
923
+ weights_file = os.path.join(output_dir, "sample_weights.csv")
924
+ pd.DataFrame(H, index=[f"Metagene_{i + 1}" for i in range(n_components)], columns=samples).to_csv(weights_file)
925
+ log.append(f"Saved sample weights to {weights_file}")
926
+
927
+ top_genes_file = os.path.join(output_dir, "top_genes_per_metagene.csv")
928
+ top_genes = {}
929
+ for i in range(n_components):
930
+ # Get indices of top 20 genes for this metagene
931
+ top_indices = np.argsort(W[:, i])[::-1][:20]
932
+ top_genes[f"Metagene_{i + 1}"] = [genes[idx] for idx in top_indices]
933
+
934
+ pd.DataFrame(top_genes).to_csv(top_genes_file)
935
+ log.append(f"Saved top genes per metagene to {top_genes_file}")
936
+
937
+ except Exception as e:
938
+ log.append(f"ERROR: NMF failed with error: {str(e)}")
939
+ return "\n".join(log)
940
+
941
+ log.append("\nSUMMARY:")
942
+ log.append(f"- Successfully extracted {n_components} metagenes from {X.shape[0]} genes across {X.shape[1]} samples")
943
+ log.append(f"- Results saved to: {output_dir}")
944
+ log.append("- Files generated: metagenes.csv, sample_weights.csv, top_genes_per_metagene.csv")
945
+ log.append("\nNEXT STEPS:")
946
+ log.append("- Analyze the metagenes to identify biological pathways")
947
+ log.append("- Cluster samples based on metagene weights to identify potential tumor subtypes")
948
+ log.append("- Correlate subtypes with clinical outcomes if available")
949
+
950
+ return "\n".join(log)
951
+
952
+
953
+ def analyze_copy_number_purity_ploidy_and_focal_events(
954
+ tumor_bam,
955
+ reference_genome,
956
+ normal_bam=None,
957
+ output_dir="cn_analysis_results",
958
+ targets_bed=None,
959
+ antitargets_bed=None,
960
+ gene_bed=None,
961
+ focal_genes=None,
962
+ log2_amp_threshold=1.0,
963
+ log2_del_threshold=-1.0,
964
+ ):
965
+ """CNVkit-based copy number workflow: CNV segmentation, purity/ploidy & HRD approximation, focal events.
966
+
967
+ This function orchestrates a CNVkit-based copy number analysis workflow for tumor samples.
968
+ It performs CNV segmentation, derives approximate purity & ploidy metrics, calculates a simplified
969
+ HRD-style summary, and detects focal amplifications/deletions in key oncogenes / tumor suppressors.
970
+
971
+ Parameters
972
+ ----------
973
+ tumor_bam : str
974
+ Path to tumor BAM (indexed)
975
+ reference_genome : str
976
+ Path to reference FASTA (with index files present)
977
+ normal_bam : str, optional
978
+ Matched normal BAM (recommended for allele-specific & purity inference)
979
+ output_dir : str, default "cn_analysis_results"
980
+ Directory for all outputs
981
+ targets_bed : str, optional
982
+ BED of target regions (e.g. exome / panel) for CNVkit / gCNV
983
+ antitargets_bed : str, optional
984
+ BED of antitarget regions for CNVkit (if panel / exome)
985
+ gene_bed : str, optional
986
+ BED file with gene coordinates (chrom, start, end, gene). Used for focal event annotation
987
+ focal_genes : list[str], optional
988
+ List of genes to highlight for focal amplification/deletion; defaults to ["MYC","ERBB2","CDKN2A"]
989
+ log2_amp_threshold : float, default 1.0
990
+ Log2 ratio threshold to call focal amplification (~ >2x copy relative to baseline)
991
+ log2_del_threshold : float, default -1.0
992
+ Log2 ratio threshold to call focal deep deletion (~ homozygous loss)
993
+
994
+ Returns
995
+ -------
996
+ str
997
+ Research log summarizing steps, tool executions, and findings.
998
+ """
999
+ import datetime
1000
+ import math
1001
+ import os
1002
+ import shutil
1003
+ import statistics
1004
+ import subprocess
1005
+
1006
+ import pandas as pd
1007
+
1008
+ log = []
1009
+ ts = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S")
1010
+ log.append(f"CNVKIT COPY NUMBER ANALYSIS WORKFLOW - {ts}")
1011
+ log.append("=" * 80)
1012
+ log.append(f"Tumor BAM: {tumor_bam}")
1013
+ if normal_bam:
1014
+ log.append(f"Normal BAM: {normal_bam}")
1015
+ log.append(f"Reference genome: {reference_genome}")
1016
+ log.append(f"Output directory: {output_dir}\n")
1017
+
1018
+ os.makedirs(output_dir, exist_ok=True)
1019
+ sample_name = os.path.splitext(os.path.basename(tumor_bam))[0]
1020
+
1021
+ if focal_genes is None:
1022
+ focal_genes = ["MYC", "ERBB2", "CDKN2A"]
1023
+ # Single informative log line (was duplicated previously)
1024
+ log.append(f"Focal genes for analysis: {', '.join(focal_genes)}")
1025
+
1026
+ # ----------------------------------------------------------------------------------
1027
+ # STEP 1: CNV SEGMENTATION with CNVkit
1028
+ # ----------------------------------------------------------------------------------
1029
+ log.append("STEP 1: CNV Segmentation with CNVkit")
1030
+ cnvkit_path = shutil.which("cnvkit.py") or shutil.which("cnvkit")
1031
+ use_conda_env = False
1032
+
1033
+ # Check if CNVkit is available in biomni_e1 environment
1034
+ if not cnvkit_path:
1035
+ try:
1036
+ result = subprocess.run(
1037
+ ["conda", "run", "-n", "biomni_e1", "which", "cnvkit.py"], capture_output=True, text=True, check=True
1038
+ )
1039
+ if result.returncode == 0 and result.stdout.strip():
1040
+ cnvkit_path = "cnvkit.py" # Will use via conda run
1041
+ use_conda_env = True
1042
+ log.append("- CNVkit found in biomni_e1 environment")
1043
+ except (subprocess.CalledProcessError, FileNotFoundError):
1044
+ pass
1045
+
1046
+ # Fallback to bio_env_py310
1047
+ if not cnvkit_path:
1048
+ try:
1049
+ result = subprocess.run(
1050
+ ["conda", "run", "-n", "bio_env_py310", "which", "cnvkit.py"],
1051
+ capture_output=True,
1052
+ text=True,
1053
+ check=True,
1054
+ )
1055
+ if result.returncode == 0 and result.stdout.strip():
1056
+ cnvkit_path = "cnvkit.py" # Will use via conda run
1057
+ use_conda_env = True
1058
+ log.append("- CNVkit found in bio_env_py310 environment")
1059
+ except (subprocess.CalledProcessError, FileNotFoundError):
1060
+ pass
1061
+
1062
+ if not cnvkit_path:
1063
+ log.append("- CNVkit not detected in current PATH, biomni_e1, or bio_env_py310 environment")
1064
+ log.append("- Install manually (e.g., pip install cnvkit==0.9.11) or run setup.sh")
1065
+ log.append("- Workflow will exit without CNVkit")
1066
+ return "\n".join(log)
1067
+
1068
+ cnvkit_cns = None
1069
+ log.append(f"- CNVkit detected at: {cnvkit_path}")
1070
+
1071
+ # Determine environment prefix for CNVkit commands
1072
+ env_prefix = ["conda", "run", "-n", "biomni_e1"] if use_conda_env else []
1073
+
1074
+ # CNVkit batch command
1075
+ batch_cmd = env_prefix + [cnvkit_path, "batch", tumor_bam, "-d", output_dir, "-f", reference_genome]
1076
+ if normal_bam:
1077
+ batch_cmd.extend(["-n", normal_bam])
1078
+ if targets_bed:
1079
+ batch_cmd.extend(["-t", targets_bed])
1080
+ if antitargets_bed:
1081
+ batch_cmd.extend(["-a", antitargets_bed])
1082
+ batch_cmd.extend(["--scatter", "--diagram"])
1083
+
1084
+ log.append(f"- Running CNVkit batch command: {' '.join(batch_cmd)}")
1085
+ try:
1086
+ subprocess.run(batch_cmd, check=True, capture_output=True)
1087
+ log.append("- CNVkit batch completed successfully")
1088
+ except subprocess.CalledProcessError as e:
1089
+ log.append(f"- WARNING: CNVkit batch failed: {e}. Proceeding with limited downstream analyses")
1090
+
1091
+ # Check for CNVkit output and run call command
1092
+ cnvkit_cns = os.path.join(output_dir, f"{sample_name}.cns")
1093
+ if os.path.exists(cnvkit_cns):
1094
+ call_cmd = env_prefix + [
1095
+ cnvkit_path,
1096
+ "call",
1097
+ cnvkit_cns,
1098
+ "-o",
1099
+ os.path.join(output_dir, f"{sample_name}.call.cns"),
1100
+ "-m",
1101
+ "clonal",
1102
+ ]
1103
+ log.append(f"- Calling absolute CN: {' '.join(call_cmd)}")
1104
+ try:
1105
+ subprocess.run(call_cmd, check=True, capture_output=True)
1106
+ cnvkit_cns = os.path.join(output_dir, f"{sample_name}.call.cns")
1107
+ log.append("- CNVkit call succeeded")
1108
+ except subprocess.CalledProcessError as e:
1109
+ log.append(f"- WARNING: CNVkit call failed: {e}")
1110
+ else:
1111
+ log.append("- WARNING: Expected CNVkit .cns file not found")
1112
+
1113
+ # ----------------------------------------------------------------------------------
1114
+ # STEP 2: Purity & ploidy approximation (based on CNVkit outputs)
1115
+ # ----------------------------------------------------------------------------------
1116
+ log.append("\nSTEP 2: Purity & Ploidy Approximation")
1117
+ purity_est = None
1118
+ ploidy_est = None
1119
+ seg_source = None
1120
+ seg_df = None
1121
+ try:
1122
+ if cnvkit_cns and os.path.exists(cnvkit_cns):
1123
+ seg_source = cnvkit_cns
1124
+ seg_df = pd.read_csv(cnvkit_cns, sep="\t")
1125
+ log.append(f"- Using CNVkit segments from: {seg_source}")
1126
+
1127
+ # Harmonize columns
1128
+ cols = [c.lower() for c in seg_df.columns]
1129
+ seg_df.columns = cols
1130
+
1131
+ # Obtain log2 column
1132
+ log2_col = None
1133
+ for candidate in ["log2", "log2ratio", "cnlr", "logr"]:
1134
+ if candidate in cols:
1135
+ log2_col = candidate
1136
+ break
1137
+ for candidate in ["end", "chromend", "stop"]:
1138
+ if candidate in cols:
1139
+ end_col = candidate
1140
+ break
1141
+ for candidate in ["start", "chromstart", "begin"]:
1142
+ if candidate in cols:
1143
+ start_col = candidate
1144
+ break
1145
+
1146
+ if log2_col and start_col and end_col:
1147
+ seg_df["seg_length"] = seg_df[end_col] - seg_df[start_col]
1148
+ # Weighted average absolute copy ratio -> approximate ploidy baseline
1149
+ # Convert log2 ratio to absolute copy ratio (assuming diploid baseline = 2)
1150
+ seg_df["abs_cn"] = 2 * (2 ** seg_df[log2_col])
1151
+ weighted_mean_cn = (seg_df["abs_cn"] * seg_df["seg_length"]).sum() / seg_df["seg_length"].sum()
1152
+ ploidy_est = weighted_mean_cn
1153
+ # Approximate purity: higher variance in log2 ratios suggests purity; naive formula
1154
+ mad_log2 = statistics.median([abs(x) for x in seg_df[log2_col] if not math.isnan(x)])
1155
+ purity_est = min(1.0, max(0.2, 1 - mad_log2 / 1.5)) # heuristic clamp
1156
+ log.append(f"- Estimated ploidy (weighted mean CN): {ploidy_est:.2f}")
1157
+ log.append(f"- Estimated purity (heuristic from segmentation dispersion): {purity_est:.2f}")
1158
+ else:
1159
+ log.append("- WARNING: Could not identify necessary columns for purity/ploidy estimation")
1160
+ else:
1161
+ log.append("- No CNVkit segmentation available for purity/ploidy estimation")
1162
+ except Exception as e:
1163
+ log.append(f"- ERROR: Purity/ploidy estimation failed: {e}")
1164
+
1165
+ # ----------------------------------------------------------------------------------
1166
+ # STEP 3: HRD-like summary (simplified proxy)
1167
+ # ----------------------------------------------------------------------------------
1168
+ log.append("\nSTEP 3: HRD Approximation (Simplified)")
1169
+ hrd_metrics = {}
1170
+ try:
1171
+ if seg_df is not None and "seg_length" in seg_df:
1172
+ # Count large-scale transitions (LST proxy: adjacent segments >10 Mb with log2 change >0.2)
1173
+ seg_df_sorted = seg_df.sort_values(
1174
+ by=[c for c in seg_df.columns if c.startswith("chrom") or c == "chrom" or c == "chromosome"][0]
1175
+ if any(c.startswith("chrom") for c in seg_df.columns)
1176
+ else seg_df.columns[0]
1177
+ )
1178
+ lst_count = 0
1179
+ prev = None
1180
+ for _, row in seg_df_sorted.iterrows():
1181
+ if prev is not None:
1182
+ if row["seg_length"] >= 10_000_000 and prev["seg_length"] >= 10_000_000:
1183
+ if log2_col and abs(row[log2_col] - prev[log2_col]) > 0.2:
1184
+ lst_count += 1
1185
+ prev = row
1186
+ hrd_metrics["LST_like"] = lst_count
1187
+ # HRD-LOH proxy: segments with log2 < -0.3 and length >15 Mb
1188
+ if log2_col:
1189
+ hrd_loh = ((seg_df[log2_col] < -0.3) & (seg_df["seg_length"] > 15_000_000)).sum()
1190
+ hrd_metrics["HRD_LOH_like"] = int(hrd_loh)
1191
+ # Telomeric AI proxy omitted (needs allele-specific data)
1192
+ hrd_score = sum(hrd_metrics.values())
1193
+ hrd_metrics["Simplified_HRD_score"] = hrd_score
1194
+ log.append(f"- LST-like events: {hrd_metrics['LST_like']}")
1195
+ log.append(f"- HRD-LOH-like events: {hrd_metrics['HRD_LOH_like']}")
1196
+ log.append(f"- Simplified composite HRD score: {hrd_metrics['Simplified_HRD_score']}")
1197
+ log.append("- NOTE: For clinical/research use, apply scarHRD or HRDetect with allele-specific data")
1198
+ else:
1199
+ log.append("- Segmentation not available; HRD approximation skipped")
1200
+ except Exception as e:
1201
+ log.append(f"- ERROR: HRD approximation failed: {e}")
1202
+
1203
+ # ----------------------------------------------------------------------------------
1204
+ # STEP 4: Focal amplifications/deletions in key genes
1205
+ # ----------------------------------------------------------------------------------
1206
+ log.append("\nSTEP 4: Focal Amplifications / Deletions in Key Genes")
1207
+ focal_events = []
1208
+ try:
1209
+ if seg_df is not None and gene_bed and os.path.exists(gene_bed):
1210
+ genes_df = pd.read_csv(gene_bed, sep="\t", header=None, names=["chrom", "start", "end", "gene"])
1211
+
1212
+ # Normalize chromosome naming
1213
+ def norm_chr(x):
1214
+ return x.replace("chr", "") if isinstance(x, str) else x
1215
+
1216
+ seg_df["chr_norm"] = (
1217
+ seg_df[[c for c in seg_df.columns if c in ["chrom", "chromosome", "chr"]][0]].astype(str).map(norm_chr)
1218
+ )
1219
+ genes_df["chr_norm"] = genes_df["chrom"].astype(str).map(norm_chr)
1220
+ for gene in focal_genes:
1221
+ region = genes_df[genes_df["gene"] == gene]
1222
+ if region.empty:
1223
+ continue
1224
+ r = region.iloc[0]
1225
+ # Ensure we work on a copy to avoid SettingWithCopyWarning
1226
+ overlaps = seg_df[
1227
+ (seg_df["chr_norm"] == r["chr_norm"])
1228
+ & (seg_df[start_col] <= r["end"])
1229
+ & (seg_df[end_col] >= r["start"])
1230
+ ].copy()
1231
+ if not overlaps.empty and log2_col:
1232
+ # Choose segment with largest overlap length
1233
+ r_end = r["end"]
1234
+ r_start = r["start"]
1235
+ overlaps["ov_len"] = overlaps.apply(
1236
+ lambda row, r_end=r_end, r_start=r_start: min(row[end_col], r_end)
1237
+ - max(row[start_col], r_start),
1238
+ axis=1,
1239
+ )
1240
+ best = overlaps.sort_values("ov_len", ascending=False).iloc[0]
1241
+ l2 = best[log2_col]
1242
+ status = None
1243
+ if l2 >= log2_amp_threshold:
1244
+ status = "AMPLIFICATION"
1245
+ elif l2 <= log2_del_threshold:
1246
+ status = "DELETION"
1247
+ if status:
1248
+ focal_events.append((gene, status, l2))
1249
+ if focal_events:
1250
+ for gene, status, l2 in focal_events:
1251
+ log.append(f"- {gene}: {status} (log2={l2:.2f})")
1252
+ else:
1253
+ log.append("- No focal events meeting thresholds in target genes")
1254
+ else:
1255
+ if not gene_bed:
1256
+ log.append("- Skipped: gene_bed not provided")
1257
+ else:
1258
+ log.append("- Skipped: gene_bed file not found or segmentation unavailable")
1259
+ except Exception as e:
1260
+ log.append(f"- ERROR: Focal event detection failed: {e}")
1261
+
1262
+ # ----------------------------------------------------------------------------------
1263
+ # STEP 5: Summaries & file outputs
1264
+ # ----------------------------------------------------------------------------------
1265
+ log.append("\nSTEP 5: Summary & Output Files")
1266
+ summary = {
1267
+ "purity_estimate": purity_est,
1268
+ "ploidy_estimate": ploidy_est,
1269
+ }
1270
+ summary.update(hrd_metrics)
1271
+ summary_file = os.path.join(output_dir, f"{sample_name}_cn_summary.tsv")
1272
+ try:
1273
+ pd.DataFrame([summary]).to_csv(summary_file, sep="\t", index=False)
1274
+ log.append(f"- Saved summary metrics: {summary_file}")
1275
+ except Exception as e:
1276
+ log.append(f"- WARNING: Could not save summary file: {e}")
1277
+
1278
+ if focal_events:
1279
+ focal_file = os.path.join(output_dir, f"{sample_name}_focal_events.tsv")
1280
+ try:
1281
+ pd.DataFrame(focal_events, columns=["Gene", "Event", "Log2"]).to_csv(focal_file, sep="\t", index=False)
1282
+ log.append(f"- Saved focal events: {focal_file}")
1283
+ except Exception as e:
1284
+ log.append(f"- WARNING: Could not save focal events file: {e}")
1285
+
1286
+ log.append("\nNEXT STEPS:")
1287
+ log.append("- For improved purity/ploidy estimation, consider ABSOLUTE, FACETS, PureCN, or Sequenza")
1288
+ log.append("- For validated HRD scoring, use scarHRD or HRDetect with allele-specific data")
1289
+ log.append("- Integrate CN events with expression data to assess driver gene activation/dosage")
1290
+ log.append("- Visualize CNV profiles using CNVkit's built-in plotting functions")
1291
+
1292
+ return "\n".join(log)
BioScientist/agent_system/engines/v1_executor_backup/tool/cell_biology.py ADDED
@@ -0,0 +1,742 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def quantify_cell_cycle_phases_from_microscopy(image_paths, output_dir="./results"):
2
+ """Quantify the percentage of cells in each cell cycle phase using Calcofluor white stained microscopy images.
3
+
4
+ This function processes microscopy images where cell walls/septa are stained with Calcofluor white,
5
+ segments individual cells, extracts features, and classifies each cell into G1, S, or G2/M phase.
6
+
7
+ Parameters
8
+ ----------
9
+ image_paths : list of str
10
+ List of file paths to microscopy images of cells stained with Calcofluor white
11
+ output_dir : str, optional
12
+ Directory to save results (default: './results')
13
+
14
+ Returns
15
+ -------
16
+ str
17
+ Research log summarizing the analysis process and results
18
+
19
+ """
20
+ import os
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+ from skimage import filters, io, measure, morphology
25
+
26
+ # Create output directory if it doesn't exist
27
+ os.makedirs(output_dir, exist_ok=True)
28
+
29
+ # Initialize log
30
+ log = "# Cell Cycle Phase Quantification Analysis Log\n\n"
31
+ log += f"Processing {len(image_paths)} microscopy images with Calcofluor white staining.\n\n"
32
+
33
+ # Initialize data structures to store results
34
+ all_cells = []
35
+ cell_features = []
36
+
37
+ # Step 1: Process images and segment cells
38
+ log += "## Image Processing and Cell Segmentation\n\n"
39
+
40
+ for i, img_path in enumerate(image_paths):
41
+ log += f"Processing image {i + 1}/{len(image_paths)}: {os.path.basename(img_path)}\n"
42
+
43
+ # Load image
44
+ img = io.imread(img_path)
45
+
46
+ # Convert to grayscale if needed
47
+ if len(img.shape) > 2:
48
+ img = np.mean(img, axis=2).astype(np.uint8)
49
+
50
+ # Preprocessing
51
+ filtered_img = filters.gaussian(img, sigma=1.0)
52
+
53
+ # Thresholding to identify cell walls
54
+ threshold = filters.threshold_otsu(filtered_img)
55
+ binary = filtered_img > threshold
56
+
57
+ # Clean up binary image
58
+ binary = morphology.remove_small_objects(binary, min_size=30)
59
+ binary = morphology.binary_closing(binary)
60
+
61
+ # Segment cells
62
+ labeled_cells = measure.label(binary)
63
+ cell_props = measure.regionprops(labeled_cells, intensity_image=img)
64
+
65
+ log += f" - Found {len(cell_props)} cells\n"
66
+ all_cells.extend(cell_props)
67
+
68
+ # Extract features for each cell
69
+ for cell in cell_props:
70
+ # Basic morphological features
71
+ area = cell.area
72
+ perimeter = cell.perimeter
73
+ eccentricity = cell.eccentricity
74
+ mean_intensity = cell.mean_intensity
75
+
76
+ # Cell shape features
77
+ circularity = (4 * np.pi * area) / (perimeter**2) if perimeter > 0 else 0
78
+
79
+ # Check for septation (indicative of S or G2/M phase)
80
+ # Calcofluor white stains the septum more intensely
81
+ has_septum = False
82
+ if area > 50: # Only check larger cells
83
+ intensity_profile = cell.intensity_image[cell.intensity_image > 0]
84
+ if intensity_profile.size > 0:
85
+ intensity_std = np.std(intensity_profile)
86
+ intensity_max = np.max(intensity_profile)
87
+ # High standard deviation and max intensity suggests septum presence
88
+ has_septum = intensity_std > 0.2 * np.mean(intensity_profile) and intensity_max > 1.5 * np.mean(
89
+ intensity_profile
90
+ )
91
+
92
+ # Store features
93
+ cell_features.append(
94
+ [
95
+ area,
96
+ perimeter,
97
+ eccentricity,
98
+ mean_intensity,
99
+ circularity,
100
+ has_septum,
101
+ i, # include image index
102
+ ]
103
+ )
104
+
105
+ log += f"\nTotal cells detected across all images: {len(all_cells)}\n\n"
106
+
107
+ # Step 2: Feature engineering and classification
108
+ log += "## Cell Cycle Phase Classification\n\n"
109
+
110
+ # Convert to numpy array for easier processing
111
+ X = np.array(cell_features)
112
+
113
+ # Simple rule-based classification
114
+ # This is a simplified approach - in practice, you would train a classifier on labeled data
115
+ # G1: Smaller cells, no septum
116
+ # S: Medium-sized cells, early septum formation
117
+ # G2/M: Larger cells, clear septum, often elongated
118
+
119
+ # Define classification rules based on features
120
+ phases = []
121
+ for i in range(X.shape[0]):
122
+ area = X[i, 0]
123
+ perimeter = X[i, 1]
124
+ eccentricity = X[i, 2]
125
+ has_septum = X[i, 5]
126
+
127
+ if has_septum and area > np.median(X[:, 0]) * 1.2:
128
+ # Larger cells with septum are likely in G2/M
129
+ phases.append("G2/M")
130
+ elif has_septum or (area > np.median(X[:, 0]) and eccentricity > 0.5):
131
+ # Cells with septum or larger elongated cells are likely in S
132
+ phases.append("S")
133
+ else:
134
+ # Smaller cells without septum are likely in G1
135
+ phases.append("G1")
136
+
137
+ # Calculate percentages
138
+ unique_phases, counts = np.unique(phases, return_counts=True)
139
+ percentages = (counts / len(phases)) * 100
140
+
141
+ # Create results table
142
+ results_df = pd.DataFrame({"Phase": unique_phases, "Count": counts, "Percentage": percentages})
143
+
144
+ # Save results
145
+ results_path = os.path.join(output_dir, "cell_cycle_phases.csv")
146
+ results_df.to_csv(results_path, index=False)
147
+
148
+ # Log results
149
+ log += "### Cell Cycle Phase Distribution\n\n"
150
+ for phase, count, percentage in zip(unique_phases, counts, percentages, strict=False):
151
+ log += f"- {phase}: {count} cells ({percentage:.2f}%)\n"
152
+
153
+ log += f"\nResults saved to: {results_path}\n"
154
+ log += "\nNote: This analysis uses simplified morphological features for classification. "
155
+ log += (
156
+ "For more accurate results, a supervised machine learning approach with labeled training data is recommended.\n"
157
+ )
158
+
159
+ return log
160
+
161
+
162
+ def quantify_and_cluster_cell_motility(image_sequence_path, output_dir="./results", num_clusters=3):
163
+ """Quantify cell motility features from time-lapse microscopy images and cluster cells based on motility patterns.
164
+
165
+ Parameters
166
+ ----------
167
+ image_sequence_path : str
168
+ Path to directory containing time-lapse microscopy images in sequential order
169
+ output_dir : str
170
+ Directory to save output files (default: "./results")
171
+ num_clusters : int
172
+ Number of motility pattern clusters to identify (default: 3)
173
+
174
+ Returns
175
+ -------
176
+ str
177
+ Research log summarizing the analysis process and results
178
+
179
+ """
180
+ import os
181
+
182
+ import cv2
183
+ import numpy as np
184
+ import pandas as pd
185
+ from sklearn.cluster import KMeans
186
+
187
+ # Create output directory if it doesn't exist
188
+ os.makedirs(output_dir, exist_ok=True)
189
+
190
+ # Step 1: Load image sequence
191
+ image_files = sorted(
192
+ [f for f in os.listdir(image_sequence_path) if f.endswith((".tif", ".tiff", ".png", ".jpg", ".jpeg"))]
193
+ )
194
+
195
+ if len(image_files) < 2:
196
+ return "Error: Insufficient images found. At least 2 time points are required."
197
+
198
+ # Step 2: Initialize cell tracking
199
+ first_image = cv2.imread(os.path.join(image_sequence_path, image_files[0]), cv2.IMREAD_GRAYSCALE)
200
+
201
+ # Simple cell detection using thresholding and contour detection
202
+ _, binary = cv2.threshold(first_image, 127, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
203
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
204
+
205
+ # Extract cell centroids from first frame
206
+ cells = []
207
+ for i, contour in enumerate(contours):
208
+ if cv2.contourArea(contour) > 50: # Filter small noise
209
+ M = cv2.moments(contour)
210
+ if M["m00"] != 0:
211
+ cx = int(M["m10"] / M["m00"])
212
+ cy = int(M["m01"] / M["m00"])
213
+ cells.append({"id": i, "positions": [(cx, cy)], "frame_indices": [0]})
214
+
215
+ # Step 3: Track cells across frames
216
+ for frame_idx, img_file in enumerate(image_files[1:], 1):
217
+ img = cv2.imread(os.path.join(image_sequence_path, img_file), cv2.IMREAD_GRAYSCALE)
218
+ _, binary = cv2.threshold(img, 127, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
219
+ contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
220
+
221
+ # Get current frame centroids
222
+ current_centroids = []
223
+ for contour in contours:
224
+ if cv2.contourArea(contour) > 50:
225
+ M = cv2.moments(contour)
226
+ if M["m00"] != 0:
227
+ cx = int(M["m10"] / M["m00"])
228
+ cy = int(M["m01"] / M["m00"])
229
+ current_centroids.append((cx, cy))
230
+
231
+ # Match cells with nearest centroids in current frame
232
+ for cell in cells:
233
+ if len(cell["positions"]) == frame_idx: # Only track cells found in previous frame
234
+ prev_pos = cell["positions"][-1]
235
+
236
+ if current_centroids:
237
+ # Calculate distances to all current centroids
238
+ distances = [
239
+ np.sqrt((prev_pos[0] - c[0]) ** 2 + (prev_pos[1] - c[1]) ** 2) for c in current_centroids
240
+ ]
241
+ min_idx = np.argmin(distances)
242
+
243
+ # Only match if distance is below threshold (prevents incorrect matching)
244
+ if distances[min_idx] < 50: # Threshold distance in pixels
245
+ cell["positions"].append(current_centroids[min_idx])
246
+ cell["frame_indices"].append(frame_idx)
247
+ current_centroids.pop(min_idx) # Remove matched centroid
248
+
249
+ # Step 4: Calculate motility features for each cell
250
+ cell_features = []
251
+
252
+ for cell in cells:
253
+ # Only analyze cells tracked for at least 3 frames
254
+ if len(cell["positions"]) < 3:
255
+ continue
256
+
257
+ # Calculate displacements between consecutive positions
258
+ displacements = []
259
+ for i in range(1, len(cell["positions"])):
260
+ prev_pos = cell["positions"][i - 1]
261
+ curr_pos = cell["positions"][i]
262
+ displacement = np.sqrt((curr_pos[0] - prev_pos[0]) ** 2 + (curr_pos[1] - prev_pos[1]) ** 2)
263
+ displacements.append(displacement)
264
+
265
+ # Calculate speed (pixels per frame)
266
+ avg_speed = np.mean(displacements)
267
+
268
+ # Calculate directionality (displacement from start to end / total path length)
269
+ start_pos = cell["positions"][0]
270
+ end_pos = cell["positions"][-1]
271
+ net_displacement = np.sqrt((end_pos[0] - start_pos[0]) ** 2 + (end_pos[1] - start_pos[1]) ** 2)
272
+ total_path_length = np.sum(displacements)
273
+ directionality = net_displacement / total_path_length if total_path_length > 0 else 0
274
+
275
+ # Calculate mean squared displacement
276
+ msd = np.mean(
277
+ [
278
+ np.sqrt((cell["positions"][i][0] - start_pos[0]) ** 2 + (cell["positions"][i][1] - start_pos[1]) ** 2)
279
+ for i in range(1, len(cell["positions"]))
280
+ ]
281
+ )
282
+
283
+ # Calculate track duration
284
+ duration = cell["frame_indices"][-1] - cell["frame_indices"][0]
285
+
286
+ cell_features.append(
287
+ {
288
+ "cell_id": cell["id"],
289
+ "avg_speed": avg_speed,
290
+ "directionality": directionality,
291
+ "msd": msd,
292
+ "track_duration": duration,
293
+ "track_length": len(cell["positions"]),
294
+ }
295
+ )
296
+
297
+ # Step 5: Cluster cells based on motility features
298
+ if len(cell_features) < num_clusters:
299
+ return f"Error: Not enough cells ({len(cell_features)}) to form {num_clusters} clusters. Try reducing num_clusters."
300
+
301
+ # Create feature matrix for clustering
302
+ feature_df = pd.DataFrame(cell_features)
303
+ feature_matrix = feature_df[["avg_speed", "directionality", "msd"]].values
304
+
305
+ # Normalize features
306
+ feature_matrix = (feature_matrix - np.mean(feature_matrix, axis=0)) / np.std(feature_matrix, axis=0)
307
+
308
+ # Perform k-means clustering
309
+ kmeans = KMeans(n_clusters=num_clusters, random_state=42)
310
+ clusters = kmeans.fit_predict(feature_matrix)
311
+
312
+ # Add cluster assignments to dataframe
313
+ feature_df["cluster"] = clusters
314
+
315
+ # Save results to CSV
316
+ results_file = os.path.join(output_dir, "cell_motility_features.csv")
317
+ feature_df.to_csv(results_file, index=False)
318
+
319
+ # Calculate cluster statistics
320
+ cluster_stats = feature_df.groupby("cluster").agg(
321
+ {
322
+ "avg_speed": ["mean", "std"],
323
+ "directionality": ["mean", "std"],
324
+ "msd": ["mean", "std"],
325
+ "cell_id": "count",
326
+ }
327
+ )
328
+
329
+ # Save cluster statistics
330
+ stats_file = os.path.join(output_dir, "cluster_statistics.csv")
331
+ cluster_stats.to_csv(stats_file)
332
+
333
+ # Create research log
334
+ log = f"""
335
+ Cell Motility Quantification and Clustering Analysis
336
+ ===================================================
337
+
338
+ Analysis Steps:
339
+ 1. Loaded {len(image_files)} time-lapse microscopy images
340
+ 2. Detected and tracked {len(cells)} initial cells across time frames
341
+ 3. Calculated motility features for {len(cell_features)} cells with sufficient tracking data
342
+ 4. Performed k-means clustering to identify {num_clusters} distinct motility patterns
343
+
344
+ Results Summary:
345
+ - Detected {len(cells)} cells in the first frame
346
+ - Successfully tracked {len(cell_features)} cells across multiple frames
347
+ - Clustered cells into {num_clusters} motility pattern groups
348
+
349
+ Cluster Statistics:
350
+ {cluster_stats.to_string()}
351
+
352
+ Output Files:
353
+ - Cell motility features saved to: {results_file}
354
+ - Cluster statistics saved to: {stats_file}
355
+
356
+ Analysis Notes:
357
+ - Features used for clustering: average speed, directionality, and mean squared displacement
358
+ - Cells were required to be tracked for at least 3 frames to be included in the analysis
359
+ """
360
+
361
+ # Save research log
362
+ log_file = os.path.join(output_dir, "research_log.txt")
363
+ with open(log_file, "w") as f:
364
+ f.write(log)
365
+
366
+ return log
367
+
368
+
369
+ def perform_facs_cell_sorting(
370
+ cell_suspension_data,
371
+ fluorescence_parameter,
372
+ threshold_min=None,
373
+ threshold_max=None,
374
+ output_file="sorted_cells.csv",
375
+ ):
376
+ """Performs Fluorescence-Activated Cell Sorting (FACS) to enrich cell populations based on fluorescence characteristics.
377
+
378
+ Parameters
379
+ ----------
380
+ cell_suspension_data : str
381
+ Path to the FCS file containing flow cytometry data
382
+ fluorescence_parameter : str
383
+ The fluorescence parameter to use for sorting (e.g., 'GFP', 'FITC', 'PE')
384
+ threshold_min : float, optional
385
+ Minimum threshold for the fluorescence parameter. Cells below this value will be excluded
386
+ threshold_max : float, optional
387
+ Maximum threshold for the fluorescence parameter. Cells above this value will be excluded
388
+ output_file : str, optional
389
+ Filename to save the sorted cell population data
390
+
391
+ Returns
392
+ -------
393
+ str
394
+ Research log detailing the FACS cell sorting process
395
+
396
+ """
397
+ import os
398
+
399
+ import pandas as pd
400
+
401
+ # Initialize research log
402
+ log = "# FACS-based Cell Sorting and Enrichment Research Log\n\n"
403
+
404
+ try:
405
+ # Load cell suspension data
406
+ log += "## Loading Cell Suspension Data\n"
407
+ if isinstance(cell_suspension_data, str):
408
+ # If data is provided as a file path
409
+ if cell_suspension_data.lower().endswith(".fcs"):
410
+ try:
411
+ import flowkit as fk
412
+
413
+ fcs_data = fk.Sample(cell_suspension_data)
414
+ cell_df = pd.DataFrame(fcs_data.get_dataframe())
415
+ log += f"Successfully loaded FCS file containing {len(cell_df)} cells\n"
416
+ except ImportError:
417
+ # Fallback if flowkit is not available
418
+ log += "FlowKit not available, attempting to load as CSV\n"
419
+ cell_df = pd.read_csv(cell_suspension_data)
420
+ else:
421
+ # Assume CSV or other tabular format
422
+ cell_df = pd.read_csv(cell_suspension_data)
423
+ log += f"Loaded data file containing {len(cell_df)} cells\n"
424
+ else:
425
+ # Assume data is already a DataFrame
426
+ cell_df = cell_suspension_data.copy()
427
+ log += f"Using provided DataFrame containing {len(cell_df)} cells\n"
428
+
429
+ # Validate fluorescence parameter exists in data
430
+ if fluorescence_parameter not in cell_df.columns:
431
+ raise ValueError(f"Fluorescence parameter '{fluorescence_parameter}' not found in data")
432
+
433
+ # Apply gating strategy based on fluorescence thresholds
434
+ log += f"\n## Applying Gating Strategy for {fluorescence_parameter}\n"
435
+ original_count = len(cell_df)
436
+
437
+ # Filter cells based on min/max thresholds
438
+ if threshold_min is not None:
439
+ cell_df = cell_df[cell_df[fluorescence_parameter] >= threshold_min]
440
+ log += f"Applied minimum threshold of {threshold_min}: {len(cell_df)} cells remaining\n"
441
+
442
+ if threshold_max is not None:
443
+ cell_df = cell_df[cell_df[fluorescence_parameter] <= threshold_max]
444
+ log += f"Applied maximum threshold of {threshold_max}: {len(cell_df)} cells remaining\n"
445
+
446
+ # Calculate enrichment statistics
447
+ enrichment_percent = (len(cell_df) / original_count) * 100 if original_count > 0 else 0
448
+ log += "\n## Enrichment Results\n"
449
+ log += f"Original population: {original_count} cells\n"
450
+ log += f"Sorted population: {len(cell_df)} cells\n"
451
+ log += f"Enrichment percentage: {enrichment_percent:.2f}%\n"
452
+
453
+ # Calculate statistics of sorted population
454
+ mean_fluorescence = cell_df[fluorescence_parameter].mean()
455
+ median_fluorescence = cell_df[fluorescence_parameter].median()
456
+
457
+ log += f"Mean {fluorescence_parameter}: {mean_fluorescence:.2f}\n"
458
+ log += f"Median {fluorescence_parameter}: {median_fluorescence:.2f}\n"
459
+
460
+ # Save sorted population to file
461
+ cell_df.to_csv(output_file, index=False)
462
+ log += "\n## Output\n"
463
+ log += f"Sorted cell population saved to: {os.path.abspath(output_file)}\n"
464
+
465
+ return log
466
+
467
+ except Exception as e:
468
+ log += "\n## Error\n"
469
+ log += f"An error occurred during cell sorting: {str(e)}\n"
470
+ return log
471
+
472
+
473
+ def analyze_flow_cytometry_immunophenotyping(
474
+ fcs_file_path, gating_strategy, compensation_matrix=None, output_dir="./results"
475
+ ):
476
+ """Analyze flow cytometry data to identify and quantify specific cell populations based on surface markers.
477
+
478
+ Parameters
479
+ ----------
480
+ fcs_file_path : str
481
+ Path to the FCS file containing flow cytometry data
482
+ gating_strategy : dict
483
+ Dictionary defining the gating strategy. Each key is a population name, and each value is a list of tuples
484
+ (marker, operator, threshold). For example: {'HSCs': [('Lin', '<', 100), ('Sca1', '>', 1000), ...]}
485
+ compensation_matrix : numpy.ndarray, optional
486
+ Spillover/compensation matrix to correct for fluorescence overlap
487
+ output_dir : str, optional
488
+ Directory to save the results
489
+
490
+ Returns
491
+ -------
492
+ str
493
+ Research log summarizing the analysis steps and results
494
+
495
+ """
496
+ import os
497
+
498
+ import pandas as pd
499
+ from FlowCytometryTools import FCMeasurement
500
+
501
+ # Create output directory if it doesn't exist
502
+ if not os.path.exists(output_dir):
503
+ os.makedirs(output_dir)
504
+
505
+ # Load the FCS file
506
+ sample = FCMeasurement(ID="Sample", datafile=fcs_file_path)
507
+
508
+ # Apply compensation if provided
509
+ if compensation_matrix is not None:
510
+ sample = sample.compensate(compensation_matrix)
511
+
512
+ # Initialize log
513
+ log = "Flow Cytometry Analysis Log\n"
514
+ log += "==========================\n"
515
+ log += f"File analyzed: {os.path.basename(fcs_file_path)}\n"
516
+ log += f"Total events: {len(sample)}\n\n"
517
+
518
+ # Create a dictionary to store population counts
519
+ populations = {}
520
+
521
+ # Apply gating strategy to identify cell populations
522
+ for pop_name, gates in gating_strategy.items():
523
+ # Start with all events
524
+ current_population = sample.copy()
525
+
526
+ log += f"Identifying {pop_name} population:\n"
527
+
528
+ # Apply each gate sequentially
529
+ for marker, operator, threshold in gates:
530
+ initial_count = len(current_population)
531
+
532
+ if operator == ">":
533
+ current_population = current_population.gate(f"{marker} > {threshold}")
534
+ elif operator == "<":
535
+ current_population = current_population.gate(f"{marker} < {threshold}")
536
+ elif operator == "between":
537
+ # For 'between', threshold should be a tuple (lower, upper)
538
+ lower, upper = threshold
539
+ current_population = current_population.gate(f"{marker} > {lower} and {marker} < {upper}")
540
+
541
+ final_count = len(current_population)
542
+ log += f" Gate {marker} {operator} {threshold}: {initial_count} → {final_count} events\n"
543
+
544
+ # Store the final population
545
+ populations[pop_name] = current_population
546
+
547
+ # Calculate percentage of original sample
548
+ percentage = (len(current_population) / len(sample)) * 100
549
+ log += f" Final {pop_name} count: {len(current_population)} events ({percentage:.2f}% of total)\n\n"
550
+
551
+ # Create a summary dataframe
552
+ summary_data = {"Population": [], "Count": [], "Percentage": []}
553
+
554
+ for pop_name, population in populations.items():
555
+ summary_data["Population"].append(pop_name)
556
+ summary_data["Count"].append(len(population))
557
+ summary_data["Percentage"].append((len(population) / len(sample)) * 100)
558
+
559
+ summary_df = pd.DataFrame(summary_data)
560
+
561
+ # Save the summary to a CSV file
562
+ summary_file = os.path.join(output_dir, "population_summary.csv")
563
+ summary_df.to_csv(summary_file, index=False)
564
+
565
+ log += "Summary of identified populations:\n"
566
+ for _, row in summary_df.iterrows():
567
+ log += f" {row['Population']}: {row['Count']} events ({row['Percentage']:.2f}%)\n"
568
+
569
+ log += f"\nDetailed results saved to: {summary_file}\n"
570
+
571
+ return log
572
+
573
+
574
+ def analyze_mitochondrial_morphology_and_potential(morphology_image_path, potential_image_path, output_dir="./output"):
575
+ """Quantifies metrics of mitochondrial morphology and membrane potential from fluorescence microscopy images.
576
+
577
+ Parameters
578
+ ----------
579
+ morphology_image_path : str
580
+ Path to the fluorescence microscopy image showing mitochondrial morphology (e.g., MTS-GFP)
581
+ potential_image_path : str
582
+ Path to the fluorescence microscopy image showing mitochondrial membrane potential (e.g., TMRE staining)
583
+ output_dir : str, optional
584
+ Directory to save output files, default is "./output"
585
+
586
+ Returns
587
+ -------
588
+ str
589
+ A research log summarizing the analysis steps and results
590
+
591
+ """
592
+ import datetime
593
+ import os
594
+
595
+ import cv2
596
+ import numpy as np
597
+ from scipy import ndimage
598
+ from skimage import filters, io, morphology, util
599
+
600
+ # Create output directory if it doesn't exist
601
+ os.makedirs(output_dir, exist_ok=True)
602
+
603
+ log = []
604
+ log.append(f"Mitochondrial Analysis - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
605
+ log.append("=" * 50)
606
+
607
+ # Load images
608
+ try:
609
+ morph_img = io.imread(morphology_image_path)
610
+ pot_img = io.imread(potential_image_path)
611
+
612
+ log.append(f"Successfully loaded morphology image: {morphology_image_path}")
613
+ log.append(f"Successfully loaded potential image: {potential_image_path}")
614
+ log.append(f"Morphology image shape: {morph_img.shape}")
615
+ log.append(f"Potential image shape: {pot_img.shape}")
616
+ except Exception as e:
617
+ return f"Error loading images: {str(e)}"
618
+
619
+ # Convert to grayscale if needed
620
+ if len(morph_img.shape) > 2:
621
+ morph_img = cv2.cvtColor(morph_img, cv2.COLOR_RGB2GRAY)
622
+ log.append("Converted morphology image to grayscale")
623
+ if len(pot_img.shape) > 2:
624
+ pot_img = cv2.cvtColor(pot_img, cv2.COLOR_RGB2GRAY)
625
+ log.append("Converted potential image to grayscale")
626
+
627
+ # Normalize images
628
+ morph_img = util.img_as_float(morph_img)
629
+ pot_img = util.img_as_float(pot_img)
630
+
631
+ log.append("\nMorphology Analysis:")
632
+ log.append("-" * 30)
633
+
634
+ # Denoise morphology image
635
+ morph_img_denoised = filters.gaussian(morph_img, sigma=1.0)
636
+
637
+ # Threshold to segment mitochondria
638
+ threshold_value = filters.threshold_otsu(morph_img_denoised)
639
+ binary_img = morph_img_denoised > threshold_value
640
+
641
+ # Remove small objects
642
+ binary_img = morphology.remove_small_objects(binary_img, min_size=20)
643
+
644
+ # Save binary image
645
+ binary_path = os.path.join(output_dir, "binary_mitochondria.png")
646
+ io.imsave(binary_path, util.img_as_ubyte(binary_img))
647
+ log.append(f"Binary segmentation saved to: {binary_path}")
648
+
649
+ # Skeletonize for network analysis
650
+ skeleton = morphology.skeletonize(binary_img)
651
+ skeleton_path = os.path.join(output_dir, "skeleton.png")
652
+ io.imsave(skeleton_path, util.img_as_ubyte(skeleton))
653
+ log.append(f"Skeleton image saved to: {skeleton_path}")
654
+
655
+ # Calculate morphology metrics
656
+ # 1. Count branches and junctions
657
+ # Label the skeleton
658
+ labeled_skeleton, num_branches = ndimage.label(skeleton)
659
+
660
+ # Find branch points (pixels with more than 2 neighbors)
661
+ kernel = np.ones((3, 3), dtype=np.uint8)
662
+ kernel[1, 1] = 0 # Don't count the center pixel
663
+ neighbor_count = ndimage.convolve(skeleton.astype(np.uint8), kernel)
664
+ junction_points = (neighbor_count > 2) & skeleton
665
+ num_junctions = np.sum(junction_points)
666
+
667
+ # 2. Calculate fragmentation metrics
668
+ labeled_objects, num_objects = ndimage.label(binary_img)
669
+ object_sizes = ndimage.sum(binary_img, labeled_objects, range(1, num_objects + 1))
670
+ mean_size = np.mean(object_sizes) if len(object_sizes) > 0 else 0
671
+
672
+ # Calculate network connectivity (ratio of junctions to branches)
673
+ connectivity = num_junctions / num_branches if num_branches > 0 else 0
674
+
675
+ # Calculate fragmentation index (inverse of mean object size, normalized)
676
+ fragmentation = 1 / (mean_size / np.max(object_sizes)) if mean_size > 0 and np.max(object_sizes) > 0 else 0
677
+
678
+ log.append(f"Number of mitochondrial fragments: {num_objects}")
679
+ log.append(f"Number of branches: {num_branches}")
680
+ log.append(f"Number of junction points: {num_junctions}")
681
+ log.append(f"Network connectivity index: {connectivity:.4f}")
682
+ log.append(f"Fragmentation index: {fragmentation:.4f}")
683
+ log.append(f"Mean fragment size: {mean_size:.2f} pixels")
684
+
685
+ # Membrane potential analysis
686
+ log.append("\nMembrane Potential Analysis:")
687
+ log.append("-" * 30)
688
+
689
+ # Create mask from morphology image to analyze only mitochondrial regions
690
+ mito_mask = binary_img
691
+
692
+ # Calculate potential metrics within mitochondrial regions
693
+ pot_intensity_raw = np.mean(pot_img)
694
+ pot_intensity_in_mito = np.mean(pot_img[mito_mask]) if np.sum(mito_mask) > 0 else 0
695
+
696
+ # Calculate potential heterogeneity (standard deviation of intensity)
697
+ pot_heterogeneity = np.std(pot_img[mito_mask]) if np.sum(mito_mask) > 0 else 0
698
+
699
+ # Calculate potential distribution
700
+ if np.sum(mito_mask) > 0:
701
+ percentiles = [10, 25, 50, 75, 90]
702
+ pot_percentiles = np.percentile(pot_img[mito_mask], percentiles)
703
+ percentile_str = ", ".join([f"{p}th: {v:.4f}" for p, v in zip(percentiles, pot_percentiles, strict=False)])
704
+ else:
705
+ percentile_str = "No mitochondrial regions detected"
706
+
707
+ log.append(f"Overall mean TMRE intensity: {pot_intensity_raw:.4f}")
708
+ log.append(f"Mean TMRE intensity in mitochondria: {pot_intensity_in_mito:.4f}")
709
+ log.append(f"TMRE intensity heterogeneity (std): {pot_heterogeneity:.4f}")
710
+ log.append(f"TMRE intensity percentiles: {percentile_str}")
711
+
712
+ # Create a combined results image
713
+ # Overlay potential intensity on morphology
714
+ overlay = np.zeros((morph_img.shape[0], morph_img.shape[1], 3), dtype=np.float32)
715
+ overlay[..., 0] = morph_img # Red channel - morphology
716
+ overlay[..., 2] = pot_img # Blue channel - potential
717
+ overlay = np.clip(overlay, 0, 1)
718
+
719
+ overlay_path = os.path.join(output_dir, "morphology_potential_overlay.png")
720
+ io.imsave(overlay_path, util.img_as_ubyte(overlay))
721
+ log.append(f"Overlay image saved to: {overlay_path}")
722
+
723
+ # Save quantitative results to CSV
724
+ import csv
725
+
726
+ results_path = os.path.join(output_dir, "mitochondrial_analysis_results.csv")
727
+ with open(results_path, "w", newline="") as csvfile:
728
+ writer = csv.writer(csvfile)
729
+ writer.writerow(["Metric", "Value"])
730
+ writer.writerow(["Number of fragments", num_objects])
731
+ writer.writerow(["Number of branches", num_branches])
732
+ writer.writerow(["Number of junctions", num_junctions])
733
+ writer.writerow(["Network connectivity", connectivity])
734
+ writer.writerow(["Fragmentation index", fragmentation])
735
+ writer.writerow(["Mean fragment size", mean_size])
736
+ writer.writerow(["Mean TMRE intensity", pot_intensity_in_mito])
737
+ writer.writerow(["TMRE heterogeneity", pot_heterogeneity])
738
+
739
+ log.append(f"Quantitative results saved to: {results_path}")
740
+ log.append("\nAnalysis complete.")
741
+
742
+ return "\n".join(log)
BioScientist/agent_system/engines/v1_executor_backup/tool/database.py ADDED
The diff for this file is too large to render. See raw diff
 
BioScientist/agent_system/engines/v1_executor_backup/tool/genetics.py ADDED
@@ -0,0 +1,1672 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def liftover_coordinates(
2
+ chromosome: str,
3
+ position: int,
4
+ input_format: str,
5
+ output_format: str,
6
+ data_path: str,
7
+ ) -> str:
8
+ """Perform liftover of genomic coordinates between hg19 and hg38 formats with detailed intermediate steps.
9
+
10
+ Args:
11
+ chromosome (str): Chromosome number (e.g., '1', 'X').
12
+ position (int): Genomic position.
13
+ input_format (str): Input genome build ('hg19' or 'hg38').
14
+ output_format (str): Output genome build ('hg19' or 'hg38').
15
+ data_path (str): Path to liftover chain files.
16
+
17
+ Returns:
18
+ str: A detailed string explaining the steps and the final result or any error encountered.
19
+
20
+ """
21
+ from pyliftover import LiftOver
22
+
23
+ steps = []
24
+
25
+ try:
26
+ steps.append(
27
+ f"Starting liftover process for chromosome {chromosome}, position {position} from {input_format} to {output_format}."
28
+ )
29
+
30
+ # Load the liftover chain files
31
+ steps.append("Loading liftover chain files...")
32
+ hg19_to_hg38_liftover = LiftOver(data_path + "/liftover/hg19ToHg38.over.chain.gz")
33
+ hg38_to_hg19_liftover = LiftOver(data_path + "/liftover/hg38ToHg19.over.chain.gz")
34
+ steps.append("Liftover chain files loaded successfully.")
35
+
36
+ # Choose the appropriate LiftOver object
37
+ if input_format == "hg19" and output_format == "hg38":
38
+ lo = hg19_to_hg38_liftover
39
+ steps.append("Selected liftover chain: hg19 to hg38.")
40
+ elif input_format == "hg38" and output_format == "hg19":
41
+ lo = hg38_to_hg19_liftover
42
+ steps.append("Selected liftover chain: hg38 to hg19.")
43
+ else:
44
+ steps.append("Error: Unsupported format conversion.")
45
+ return "\n".join(
46
+ steps
47
+ + ["Error: Unsupported format conversion. Supported formats are 'hg19' to 'hg38' or 'hg38' to 'hg19'."]
48
+ )
49
+
50
+ # Perform the liftover conversion
51
+ steps.append(f"Performing liftover for chr{chromosome}, position {position}...")
52
+ lifted_coordinates = lo.convert_coordinate(f"chr{chromosome}", position)
53
+
54
+ if lifted_coordinates:
55
+ result = (
56
+ f"Successfully lifted coordinates from {input_format} to {output_format}.\n"
57
+ f"Original: chr{chromosome}, position {position}.\n"
58
+ f"Lifted: chromosome {lifted_coordinates[0][0]}, position {lifted_coordinates[0][1]}, strand {lifted_coordinates[0][2]}."
59
+ )
60
+ steps.append(result)
61
+ return "\n".join(steps)
62
+ else:
63
+ steps.append("Error: Liftover failed. No coordinates found for the given input.")
64
+ return "\n".join(steps + ["Error: Liftover failed. No coordinates found."])
65
+
66
+ except Exception as e:
67
+ steps.append(f"Exception encountered: {str(e)}")
68
+ return "\n".join(steps)
69
+
70
+
71
+ import os
72
+ from datetime import datetime
73
+
74
+ import numpy as np
75
+ import pandas as pd
76
+ import torch
77
+ from torch import nn, optim
78
+
79
+
80
+ def bayesian_finemapping_with_deep_vi(
81
+ gwas_summary_path,
82
+ ld_matrix,
83
+ n_iterations=5000,
84
+ learning_rate=0.01,
85
+ hidden_dim=64,
86
+ credible_threshold=0.95,
87
+ ):
88
+ """Performs Bayesian fine-mapping from GWAS summary statistics using deep variational inference.
89
+
90
+ This function implements a deep neural network-based variational inference approach to compute
91
+ posterior inclusion probabilities (PIPs) and credible sets for putative causal variants from
92
+ GWAS summary statistics and linkage disequilibrium (LD) information.
93
+
94
+ Parameters
95
+ ----------
96
+ gwas_summary_path : str
97
+ Path to CSV or TSV file containing GWAS summary statistics. Expected columns:
98
+ - 'variant_id': Identifier for each variant
99
+ - 'effect_size': Effect size (beta) for each variant
100
+ - 'pvalue': P-value for each variant
101
+ - 'se': Standard error for each variant (optional)
102
+
103
+ ld_matrix : numpy.ndarray
104
+ Linkage disequilibrium matrix with pairwise correlations between variants.
105
+
106
+ n_iterations : int, optional
107
+ Number of training iterations for the variational inference algorithm.
108
+ Default is 5000.
109
+
110
+ learning_rate : float, optional
111
+ Learning rate for the optimization algorithm. Default is 0.01.
112
+
113
+ hidden_dim : int, optional
114
+ Hidden dimension size for the neural network. Default is 64.
115
+
116
+ credible_threshold : float, optional
117
+ Threshold for defining the credible set (e.g., 0.95 for a 95% credible set).
118
+ Default is 0.95.
119
+
120
+ Returns
121
+ -------
122
+ str
123
+ A detailed research log of the fine-mapping analysis including:
124
+ - Number of variants analyzed
125
+ - Top variants ranked by posterior inclusion probability
126
+ - Credible set variants
127
+ - Visualizations of the posterior distributions
128
+
129
+ """
130
+ import matplotlib.pyplot as plt
131
+ import pandas as pd
132
+
133
+ # Initialize the research log
134
+ log = []
135
+ log.append(
136
+ f"# Bayesian Fine-mapping Analysis with Deep Variational Inference - {datetime.now().strftime('%Y-%m-%d %H:%M')}"
137
+ )
138
+ log.append("\n## Data Preprocessing")
139
+
140
+ # Load data from file
141
+ try:
142
+ if gwas_summary_path.endswith(".csv"):
143
+ gwas_summary = pd.read_csv(gwas_summary_path)
144
+ elif gwas_summary_path.endswith((".tsv", ".txt")):
145
+ gwas_summary = pd.read_csv(gwas_summary_path, sep="\t")
146
+ else:
147
+ log.append("Error: Unsupported file format. Please provide a CSV or TSV file.")
148
+ return "\n".join(log)
149
+ log.append(f"Successfully loaded GWAS summary data from {gwas_summary_path}")
150
+ except Exception as e:
151
+ log.append(f"Error loading GWAS summary data: {str(e)}")
152
+ return "\n".join(log)
153
+
154
+ # Check input data
155
+ if gwas_summary is None:
156
+ log.append("Error: Failed to load GWAS summary data.")
157
+ return "\n".join(log)
158
+
159
+ if ld_matrix is None:
160
+ log.append("Error: LD matrix is required for fine-mapping analysis.")
161
+ return "\n".join(log)
162
+
163
+ n_variants = len(gwas_summary)
164
+ log.append(f"Analyzing {n_variants} genetic variants")
165
+
166
+ # Check if LD matrix dimensions match the number of variants
167
+ if ld_matrix.shape[0] != n_variants or ld_matrix.shape[1] != n_variants:
168
+ log.append(f"Error: LD matrix dimensions ({ld_matrix.shape}) do not match number of variants ({n_variants})")
169
+ return "\n".join(log)
170
+
171
+ # Prepare data for analysis
172
+ log.append("\nPreparing data for analysis...")
173
+
174
+ # Compute Z-scores if not already present
175
+ if "z_score" not in gwas_summary.columns:
176
+ log.append("Computing Z-scores from effect sizes and standard errors...")
177
+ if "se" in gwas_summary.columns:
178
+ gwas_summary["z_score"] = gwas_summary["effect_size"] / gwas_summary["se"]
179
+ else:
180
+ # Approximate Z-scores from p-values
181
+ log.append("Standard errors not available, approximating Z-scores from p-values...")
182
+ # Convert p-values to Z-scores (two-sided test)
183
+ from scipy.stats import norm
184
+
185
+ gwas_summary["z_score"] = (
186
+ gwas_summary["effect_size"].abs()
187
+ / gwas_summary["effect_size"]
188
+ * norm.ppf(1 - gwas_summary["pvalue"] / 2)
189
+ )
190
+
191
+ # Convert data to tensors
192
+ z_scores = torch.FloatTensor(gwas_summary["z_score"].values)
193
+ ld_tensor = torch.FloatTensor(ld_matrix)
194
+
195
+ log.append(f"Processed {len(z_scores)} z-scores from GWAS summary")
196
+ log.append("LD matrix shape: " + str(ld_matrix.shape))
197
+
198
+ # Define the variational inference model
199
+ class VariationalFineMapping(nn.Module):
200
+ def __init__(self, n_variants, hidden_dim):
201
+ super().__init__()
202
+ self.encoder = nn.Sequential(
203
+ nn.Linear(n_variants, hidden_dim),
204
+ nn.ReLU(),
205
+ nn.Linear(hidden_dim, hidden_dim),
206
+ nn.ReLU(),
207
+ )
208
+ # Output log alpha parameters for the Bernoulli variables
209
+ self.log_alpha = nn.Linear(hidden_dim, n_variants)
210
+
211
+ def forward(self, x):
212
+ h = self.encoder(x)
213
+ log_alpha = self.log_alpha(h)
214
+ # Apply sigmoid to get inclusion probabilities
215
+ return torch.sigmoid(log_alpha)
216
+
217
+ def elbo_loss(self, z_scores, ld_matrix, pips, n_samples=10):
218
+ # Sample from approximate posterior
219
+ samples = torch.bernoulli(pips.unsqueeze(0).repeat(n_samples, 1))
220
+
221
+ # Prior term (sparsity prior)
222
+ prior_term = -0.01 * torch.sum(pips)
223
+
224
+ # Likelihood term
225
+ likelihood_term = 0
226
+ for s in samples:
227
+ # Compute expected z-scores under the model
228
+ expected_z = torch.matmul(ld_matrix, s * z_scores)
229
+ # Compute likelihood
230
+ likelihood_term += -torch.sum((z_scores - expected_z) ** 2)
231
+
232
+ likelihood_term /= n_samples
233
+
234
+ return -(prior_term + likelihood_term)
235
+
236
+ # Initialize model, optimizer and training
237
+ log.append("\n## Initializing deep variational inference model")
238
+ model = VariationalFineMapping(n_variants, hidden_dim)
239
+ optimizer = optim.Adam(model.parameters(), lr=learning_rate)
240
+
241
+ # Training loop
242
+ log.append("\n## Training variational inference model")
243
+ losses = []
244
+
245
+ for i in range(n_iterations):
246
+ optimizer.zero_grad()
247
+ pips = model(z_scores)
248
+ loss = model.elbo_loss(z_scores, ld_tensor, pips)
249
+ loss.backward()
250
+ optimizer.step()
251
+
252
+ losses.append(loss.item())
253
+
254
+ if (i + 1) % (n_iterations // 5) == 0:
255
+ log.append(f" Iteration {i + 1}/{n_iterations}, Loss: {loss.item():.4f}")
256
+
257
+ # Get final posterior inclusion probabilities
258
+ with torch.no_grad():
259
+ final_pips = model(z_scores).numpy()
260
+
261
+ # Create DataFrame with results
262
+ results_df = gwas_summary.copy()
263
+ results_df["pip"] = final_pips
264
+ results_df = results_df.sort_values("pip", ascending=False)
265
+
266
+ # Generate credible sets
267
+ log.append("\n## Generating credible sets")
268
+
269
+ # Sort variants by PIP
270
+ sorted_variants = results_df.sort_values("pip", ascending=False)
271
+
272
+ # Calculate cumulative sum of PIPs
273
+ sorted_variants["cumulative_pip"] = sorted_variants["pip"].cumsum()
274
+
275
+ # Identify variants in the credible set
276
+ credible_set = sorted_variants[sorted_variants["cumulative_pip"] <= credible_threshold]
277
+
278
+ if len(credible_set) == 0:
279
+ # If no variants meet the threshold, include at least the top variant
280
+ credible_set = sorted_variants.iloc[:1]
281
+
282
+ log.append(f"Identified {len(credible_set)} variants in the {credible_threshold * 100}% credible set")
283
+
284
+ # Save results to files
285
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
286
+ results_file = f"finemapping_results_{timestamp}.csv"
287
+ credible_set_file = f"credible_set_{timestamp}.csv"
288
+
289
+ results_df.to_csv(results_file, index=False)
290
+ credible_set.to_csv(credible_set_file, index=False)
291
+
292
+ log.append(f"\nFull results saved to: {results_file}")
293
+ log.append(f"Credible set saved to: {credible_set_file}")
294
+
295
+ # Summary of top variants
296
+ log.append("\n## Top variants by posterior inclusion probability (PIP)")
297
+ for i, (_, row) in enumerate(results_df.head(10).iterrows()):
298
+ log.append(f" {i + 1}. Variant: {row['variant_id']}, PIP: {row['pip']:.4f}, P-value: {row['pvalue']:.2e}")
299
+
300
+ log.append("\n## Variants in the credible set")
301
+ for i, (_, row) in enumerate(credible_set.iterrows()):
302
+ log.append(f" {i + 1}. Variant: {row['variant_id']}, PIP: {row['pip']:.4f}")
303
+
304
+ # Create a simple visualization
305
+ try:
306
+ plt.figure(figsize=(10, 6))
307
+ plt.bar(range(len(results_df[:50])), results_df["pip"][:50])
308
+ plt.xlabel("Variant index (sorted by PIP)")
309
+ plt.ylabel("Posterior Inclusion Probability")
310
+ plt.title("Top 50 variants by PIP")
311
+ plot_file = f"pip_plot_{timestamp}.png"
312
+ plt.savefig(plot_file)
313
+ plt.close()
314
+ log.append(f"\nPlot of PIPs saved to: {plot_file}")
315
+ except Exception as e:
316
+ log.append(f"\nCould not create visualization: {str(e)}")
317
+
318
+ log.append("\n## Analysis complete")
319
+
320
+ return "\n".join(log)
321
+
322
+
323
+ def analyze_cas9_mutation_outcomes(
324
+ reference_sequences,
325
+ edited_sequences,
326
+ cell_line_info=None,
327
+ output_prefix="cas9_mutation_analysis",
328
+ ):
329
+ """Analyzes and categorizes mutations induced by Cas9 at target sites.
330
+
331
+ Parameters
332
+ ----------
333
+ reference_sequences : dict
334
+ Dictionary mapping sequence IDs to reference DNA sequences (strings)
335
+ edited_sequences : dict of dict
336
+ Nested dictionary: {sequence_id: {read_id: sequence}}
337
+ Contains the edited/mutated sequences for each reference
338
+ cell_line_info : dict, optional
339
+ Dictionary mapping sequence IDs to cell line information (e.g., wildtype, knockout gene)
340
+ output_prefix : str, optional
341
+ Prefix for output files
342
+
343
+ Returns
344
+ -------
345
+ str
346
+ Research log summarizing the analysis steps and results
347
+
348
+ """
349
+ from collections import defaultdict
350
+
351
+ from Bio import pairwise2
352
+
353
+ # Initialize results storage
354
+ results = []
355
+ mutation_counts = defaultdict(lambda: defaultdict(int))
356
+
357
+ # Define mutation categories
358
+ categories = {
359
+ "no_mutation": "No mutation detected",
360
+ "short_deletion": "Short deletion (1-10 bp)",
361
+ "medium_deletion": "Medium deletion (11-30 bp)",
362
+ "long_deletion": "Long deletion (>30 bp)",
363
+ "single_insertion": "Single base insertion",
364
+ "longer_insertion": "Longer insertion (>1 bp)",
365
+ "indel": "Insertion and deletion",
366
+ }
367
+
368
+ log = "# Cas9-Induced Mutation Outcome Analysis\n\n"
369
+ log += "## Analysis Steps:\n\n"
370
+ log += "1. Loading and processing sequence data\n"
371
+ log += f"2. Analyzing {len(reference_sequences)} target sites\n"
372
+
373
+ # Process each reference sequence and its edited versions
374
+ for seq_id, ref_seq in reference_sequences.items():
375
+ cell_line = cell_line_info.get(seq_id, "Unknown") if cell_line_info else "Unknown"
376
+ log += f"\n### Processing target site: {seq_id} (Cell line: {cell_line})\n"
377
+
378
+ site_results = []
379
+ site_mutation_counts = defaultdict(int)
380
+ total_reads = len(edited_sequences.get(seq_id, {}))
381
+
382
+ if total_reads == 0:
383
+ log += f"No edited sequences found for {seq_id}\n"
384
+ continue
385
+
386
+ log += f"Analyzing {total_reads} sequence reads...\n"
387
+
388
+ # Process each edited sequence for this reference
389
+ for read_id, edited_seq in edited_sequences.get(seq_id, {}).items():
390
+ # Perform sequence alignment
391
+ alignments = pairwise2.align.globalms(ref_seq, edited_seq, 2, -1, -2, -0.5)
392
+
393
+ if not alignments:
394
+ log += f"Warning: Could not align read {read_id}\n"
395
+ continue
396
+
397
+ best_alignment = alignments[0]
398
+ ref_aligned, edited_aligned, score, start, end = best_alignment
399
+
400
+ # Analyze mutations
401
+ deletions = []
402
+ insertions = []
403
+ del_count = 0
404
+ ins_count = 0
405
+
406
+ i, j = 0, 0
407
+ while i < len(ref_aligned) and j < len(edited_aligned):
408
+ if ref_aligned[i] == "-": # Insertion in edited sequence
409
+ ins_start = j
410
+ while i < len(ref_aligned) and ref_aligned[i] == "-":
411
+ i += 1
412
+ j += 1
413
+ insertions.append((ins_start, j - ins_start))
414
+ ins_count += j - ins_start
415
+ elif edited_aligned[j] == "-": # Deletion in edited sequence
416
+ del_start = i
417
+ while j < len(edited_aligned) and edited_aligned[j] == "-":
418
+ i += 1
419
+ j += 1
420
+ deletions.append((del_start, i - del_start))
421
+ del_count += i - del_start
422
+ else:
423
+ i += 1
424
+ j += 1
425
+
426
+ # Categorize mutation
427
+ mutation_type = "no_mutation"
428
+ if del_count > 0 and ins_count > 0:
429
+ mutation_type = "indel"
430
+ elif del_count > 0:
431
+ if del_count <= 10:
432
+ mutation_type = "short_deletion"
433
+ elif del_count <= 30:
434
+ mutation_type = "medium_deletion"
435
+ else:
436
+ mutation_type = "long_deletion"
437
+ elif ins_count > 0:
438
+ mutation_type = "single_insertion" if ins_count == 1 else "longer_insertion"
439
+
440
+ # Add to results
441
+ site_results.append(
442
+ {
443
+ "sequence_id": seq_id,
444
+ "read_id": read_id,
445
+ "cell_line": cell_line,
446
+ "mutation_type": mutation_type,
447
+ "deletion_count": del_count,
448
+ "insertion_count": ins_count,
449
+ }
450
+ )
451
+
452
+ site_mutation_counts[mutation_type] += 1
453
+ mutation_counts[cell_line][mutation_type] += 1
454
+
455
+ # Calculate percentages for this site
456
+ log += "\nMutation distribution for this target site:\n"
457
+ for mut_type, count in site_mutation_counts.items():
458
+ percentage = (count / total_reads) * 100
459
+ log += f"- {categories[mut_type]}: {count} reads ({percentage:.1f}%)\n"
460
+
461
+ # Add site results to overall results
462
+ results.extend(site_results)
463
+
464
+ # Create results dataframe and save to CSV
465
+ results_df = pd.DataFrame(results)
466
+ output_file = f"{output_prefix}_detailed_results.csv"
467
+ results_df.to_csv(output_file, index=False)
468
+
469
+ # Create summary dataframe
470
+ summary_data = []
471
+ for cell_line, mut_counts in mutation_counts.items():
472
+ total = sum(mut_counts.values())
473
+ for mut_type, count in mut_counts.items():
474
+ percentage = (count / total) * 100 if total > 0 else 0
475
+ summary_data.append(
476
+ {
477
+ "cell_line": cell_line,
478
+ "mutation_type": mut_type,
479
+ "count": count,
480
+ "percentage": percentage,
481
+ }
482
+ )
483
+
484
+ summary_df = pd.DataFrame(summary_data)
485
+ summary_file = f"{output_prefix}_summary.csv"
486
+ summary_df.to_csv(summary_file, index=False)
487
+
488
+ # Add summary to log
489
+ log += "\n## Overall Results Summary\n\n"
490
+ log += f"Total sequences analyzed: {len(results)}\n"
491
+ log += f"Detailed results saved to: {output_file}\n"
492
+ log += f"Summary results saved to: {summary_file}\n\n"
493
+
494
+ if cell_line_info:
495
+ log += "### Mutation Distribution by Cell Line\n\n"
496
+ for cell_line, mut_counts in mutation_counts.items():
497
+ total = sum(mut_counts.values())
498
+ if total == 0:
499
+ continue
500
+
501
+ log += f"#### {cell_line}\n"
502
+ for mut_type, count in sorted(mut_counts.items(), key=lambda x: x[1], reverse=True):
503
+ percentage = (count / total) * 100
504
+ log += f"- {categories[mut_type]}: {count} ({percentage:.1f}%)\n"
505
+ log += "\n"
506
+
507
+ return log
508
+
509
+
510
+ def analyze_crispr_genome_editing(original_sequence, edited_sequence, guide_rna, repair_template=None):
511
+ """Analyzes CRISPR-Cas9 genome editing results by comparing original and edited sequences.
512
+
513
+ Parameters
514
+ ----------
515
+ original_sequence : str
516
+ The original DNA sequence before CRISPR-Cas9 editing
517
+ edited_sequence : str
518
+ The DNA sequence after CRISPR-Cas9 editing
519
+ guide_rna : str
520
+ The CRISPR guide RNA (crRNA) sequence used for targeting
521
+ repair_template : str, optional
522
+ The homology-directed repair template sequence, if used
523
+
524
+ Returns
525
+ -------
526
+ str
527
+ A research log summarizing the CRISPR-Cas9 editing analysis, including identified
528
+ mutations and characterization of the edited loci
529
+
530
+ """
531
+ import datetime
532
+
533
+ from Bio import pairwise2
534
+ from Bio.Seq import Seq
535
+
536
+ log = []
537
+ log.append(f"CRISPR-Cas9 Genome Editing Analysis - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
538
+ log.append("=" * 70)
539
+
540
+ # Step 1: Find the target site in the original sequence
541
+ log.append("\n1. Identifying target site in original sequence")
542
+ target_site = original_sequence.find(guide_rna)
543
+ if target_site == -1:
544
+ # Try with the reverse complement
545
+ guide_rna_seq = Seq(guide_rna)
546
+ rev_comp_guide = str(guide_rna_seq.reverse_complement())
547
+ target_site = original_sequence.find(rev_comp_guide)
548
+ if target_site != -1:
549
+ log.append(f" - Target site found at position {target_site} (using reverse complement of guide RNA)")
550
+ guide_rna = rev_comp_guide
551
+ else:
552
+ log.append(" - Warning: Guide RNA sequence not found in original sequence")
553
+ target_site = None
554
+ else:
555
+ log.append(f" - Target site found at position {target_site}")
556
+
557
+ # Step 2: Align sequences to identify mutations
558
+ log.append("\n2. Aligning original and edited sequences to identify mutations")
559
+ alignments = pairwise2.align.globalms(original_sequence, edited_sequence, 2, -1, -2, -0.5)
560
+ best_alignment = alignments[0]
561
+
562
+ # Extract aligned sequences
563
+ aligned_orig = best_alignment[0]
564
+ aligned_edit = best_alignment[1]
565
+
566
+ # Find mutations
567
+ mutations = []
568
+ indels = []
569
+
570
+ for i in range(len(aligned_orig)):
571
+ if aligned_orig[i] != aligned_edit[i]:
572
+ orig_base = aligned_orig[i]
573
+ edit_base = aligned_edit[i]
574
+
575
+ # Skip gaps in counting the actual position
576
+ actual_pos = len(aligned_orig[:i].replace("-", ""))
577
+
578
+ if orig_base == "-": # Insertion
579
+ indels.append(f"Insertion of {edit_base} at position {actual_pos}")
580
+ elif edit_base == "-": # Deletion
581
+ indels.append(f"Deletion of {orig_base} at position {actual_pos}")
582
+ else: # Substitution
583
+ mutations.append(f"{orig_base}→{edit_base} at position {actual_pos}")
584
+
585
+ # Log mutations
586
+ if mutations:
587
+ log.append(" - Substitutions detected:")
588
+ for mutation in mutations:
589
+ log.append(f" * {mutation}")
590
+ else:
591
+ log.append(" - No substitutions detected")
592
+
593
+ if indels:
594
+ log.append(" - Insertions/Deletions detected:")
595
+ for indel in indels:
596
+ log.append(f" * {indel}")
597
+ else:
598
+ log.append(" - No insertions or deletions detected")
599
+
600
+ # Step 3: Check if editing occurred near the target site
601
+ if target_site is not None:
602
+ log.append("\n3. Analyzing mutations relative to target site")
603
+ target_end = target_site + len(guide_rna)
604
+ target_region = range(target_site - 3, target_end + 3) # Include some buffer
605
+
606
+ on_target_edits = [m for m in mutations + indels if any(str(pos) in m for pos in target_region)]
607
+
608
+ if on_target_edits:
609
+ log.append(" - On-target edits detected near guide RNA binding site:")
610
+ for edit in on_target_edits:
611
+ log.append(f" * {edit}")
612
+ else:
613
+ log.append(" - No edits detected near guide RNA binding site")
614
+
615
+ # Step 4: Check for homology-directed repair if template was provided
616
+ if repair_template:
617
+ log.append("\n4. Checking for homology-directed repair template incorporation")
618
+ # Look for unique sequence markers from the repair template
619
+ template_len = len(repair_template)
620
+ marker_size = min(10, template_len // 3)
621
+ marker = repair_template[template_len // 2 - marker_size // 2 : template_len // 2 + marker_size // 2]
622
+
623
+ if marker in edited_sequence and marker not in original_sequence:
624
+ log.append(f" - Repair template marker '{marker}' found in edited sequence")
625
+ log.append(" - Homology-directed repair likely successful")
626
+ else:
627
+ log.append(" - No clear evidence of repair template incorporation")
628
+ log.append(" - Editing likely resulted from non-homologous end joining (NHEJ)")
629
+
630
+ # Step 5: Overall assessment
631
+ log.append("\n5. Overall assessment")
632
+ if mutations or indels:
633
+ log.append(" - CRISPR-Cas9 editing appears successful")
634
+ if target_site is not None and any(
635
+ str(pos) in "".join(mutations + indels) for pos in range(target_site, target_site + len(guide_rna))
636
+ ):
637
+ log.append(" - Edits occurred at the intended target site")
638
+ else:
639
+ log.append(" - Edits may have occurred outside the intended target site")
640
+ else:
641
+ log.append(" - No significant editing detected, CRISPR-Cas9 may not have been effective")
642
+
643
+ return "\n".join(log)
644
+
645
+
646
+ def simulate_demographic_history(
647
+ num_samples=10,
648
+ sequence_length=100000,
649
+ recombination_rate=1e-8,
650
+ mutation_rate=1e-8,
651
+ demographic_model="constant",
652
+ demographic_params=None,
653
+ coalescent_model="kingman",
654
+ beta_coalescent_param=None,
655
+ random_seed=None,
656
+ output_file="simulated_sequences.vcf",
657
+ ):
658
+ """Simulate DNA sequences with specified demographic and coalescent histories using msprime.
659
+
660
+ Parameters
661
+ ----------
662
+ num_samples : int
663
+ Number of sample sequences to simulate
664
+ sequence_length : int
665
+ Length of the simulated sequence in base pairs
666
+ recombination_rate : float
667
+ Per-base recombination rate
668
+ mutation_rate : float
669
+ Per-base mutation rate
670
+ demographic_model : str
671
+ Type of demographic model to simulate. Options:
672
+ - "constant": Constant population size
673
+ - "bottleneck": Population bottleneck
674
+ - "expansion": Population expansion
675
+ - "contraction": Population contraction
676
+ - "sawtooth": Sawtooth pattern of population size changes
677
+ demographic_params : dict
678
+ Parameters specific to the chosen demographic model. Supported formats::
679
+
680
+ - For "constant": {"N": population size}
681
+ - For "bottleneck": {
682
+ "N_initial": initial pop size,
683
+ "N_bottleneck": bottleneck pop size,
684
+ "T_bottleneck": time of bottleneck (generations ago),
685
+ "T_recovery": time of recovery (generations ago)
686
+ }
687
+ - For "expansion": {"N_initial": initial pop size, "N_final": final pop size, "T_expansion": time of expansion (generations ago)}
688
+ - For "contraction": {"N_initial": initial pop size, "N_final": final pop size, "T_contraction": time of contraction (generations ago)}
689
+ - For "sawtooth": {"N_values": list of population sizes, "times": list of times for changes}
690
+ coalescent_model : str
691
+ Type of coalescent model to use. Options:
692
+ - "kingman": Standard Kingman coalescent
693
+ - "beta": Beta-coalescent model
694
+ beta_coalescent_param : float
695
+ Parameter for beta-coalescent model (required if coalescent_model="beta")
696
+ random_seed : int
697
+ Seed for random number generator (for reproducibility)
698
+ output_file : str
699
+ Filename to save the simulated sequences (VCF format)
700
+
701
+ Returns
702
+ -------
703
+ str
704
+ Research log summarizing the simulation parameters and results
705
+
706
+ """
707
+ import time
708
+
709
+ import msprime
710
+
711
+ start_time = time.time()
712
+ log = []
713
+ log.append("Demographic History Simulation using msprime")
714
+ log.append("=============================================")
715
+ log.append("Parameters:")
716
+ log.append(f" - Number of samples: {num_samples}")
717
+ log.append(f" - Sequence length: {sequence_length} bp")
718
+ log.append(f" - Recombination rate: {recombination_rate}")
719
+ log.append(f" - Mutation rate: {mutation_rate}")
720
+ log.append(f" - Demographic model: {demographic_model}")
721
+ log.append(f" - Coalescent model: {coalescent_model}")
722
+
723
+ # Set up demographic model
724
+ if demographic_params is None:
725
+ demographic_params = {"N": 10000} # Default to constant population of 10000
726
+
727
+ demography = msprime.Demography()
728
+ demography.add_population(name="pop0", initial_size=1000) # Default initial population
729
+
730
+ if demographic_model == "constant":
731
+ N = demographic_params.get("N", 10000)
732
+ demography.add_population(name="pop0", initial_size=N)
733
+ log.append(f" - Constant population size: N = {N}")
734
+
735
+ elif demographic_model == "bottleneck":
736
+ N_initial = demographic_params.get("N_initial", 10000)
737
+ N_bottleneck = demographic_params.get("N_bottleneck", 1000)
738
+ T_bottleneck = demographic_params.get("T_bottleneck", 1000)
739
+ T_recovery = demographic_params.get("T_recovery", 500)
740
+
741
+ demography = msprime.Demography()
742
+ demography.add_population(name="pop0", initial_size=N_initial)
743
+ demography.add_population_parameters_change(time=T_recovery, initial_size=N_bottleneck)
744
+ demography.add_population_parameters_change(time=T_bottleneck, initial_size=N_initial)
745
+
746
+ log.append(" - Bottleneck model:")
747
+ log.append(f" * Initial population size: {N_initial}")
748
+ log.append(f" * Bottleneck population size: {N_bottleneck}")
749
+ log.append(f" * Bottleneck time (generations ago): {T_bottleneck}")
750
+ log.append(f" * Recovery time (generations ago): {T_recovery}")
751
+
752
+ elif demographic_model == "expansion":
753
+ N_initial = demographic_params.get("N_initial", 1000)
754
+ N_final = demographic_params.get("N_final", 10000)
755
+ T_expansion = demographic_params.get("T_expansion", 1000)
756
+
757
+ demography = msprime.Demography()
758
+ demography.add_population(name="pop0", initial_size=N_final)
759
+ demography.add_population_parameters_change(time=T_expansion, initial_size=N_initial)
760
+
761
+ log.append(" - Expansion model:")
762
+ log.append(f" * Initial population size: {N_initial}")
763
+ log.append(f" * Final population size: {N_final}")
764
+ log.append(f" * Expansion time (generations ago): {T_expansion}")
765
+
766
+ elif demographic_model == "contraction":
767
+ N_initial = demographic_params.get("N_initial", 10000)
768
+ N_final = demographic_params.get("N_final", 1000)
769
+ T_contraction = demographic_params.get("T_contraction", 1000)
770
+
771
+ demography = msprime.Demography()
772
+ demography.add_population(name="pop0", initial_size=N_final)
773
+ demography.add_population_parameters_change(time=T_contraction, initial_size=N_initial)
774
+
775
+ log.append(" - Contraction model:")
776
+ log.append(f" * Initial population size: {N_initial}")
777
+ log.append(f" * Final population size: {N_final}")
778
+ log.append(f" * Contraction time (generations ago): {T_contraction}")
779
+
780
+ elif demographic_model == "sawtooth":
781
+ N_values = demographic_params.get("N_values", [10000, 5000, 15000, 7500])
782
+ times = demographic_params.get("times", [500, 1000, 1500])
783
+
784
+ if len(N_values) != len(times) + 1:
785
+ raise ValueError("For sawtooth model, N_values should have one more element than times")
786
+
787
+ demography = msprime.Demography()
788
+ demography.add_population(name="pop0", initial_size=N_values[0])
789
+
790
+ for i, time in enumerate(times):
791
+ demography.add_population_parameters_change(time=time, initial_size=N_values[i + 1])
792
+
793
+ log.append(" - Sawtooth model:")
794
+ log.append(f" * Population sizes: {N_values}")
795
+ log.append(f" * Change times (generations ago): {times}")
796
+
797
+ else:
798
+ raise ValueError(f"Unknown demographic model: {demographic_model}")
799
+
800
+ # Set up coalescent model
801
+ model = None
802
+ if coalescent_model == "kingman":
803
+ model = msprime.StandardCoalescent()
804
+ log.append(" - Using standard Kingman coalescent")
805
+ elif coalescent_model == "beta":
806
+ if beta_coalescent_param is None:
807
+ beta_coalescent_param = 1.5
808
+ model = msprime.BetaCoalescent(alpha=beta_coalescent_param)
809
+ log.append(f" - Using Beta-coalescent with alpha = {beta_coalescent_param}")
810
+ else:
811
+ raise ValueError(f"Unknown coalescent model: {coalescent_model}")
812
+
813
+ # Run simulation
814
+ log.append("\nRunning simulation...")
815
+
816
+ ts = msprime.sim_ancestry(
817
+ samples=num_samples,
818
+ recombination_rate=recombination_rate,
819
+ sequence_length=sequence_length,
820
+ demography=demography,
821
+ model=model,
822
+ random_seed=random_seed,
823
+ )
824
+
825
+ # Add mutations
826
+ mts = msprime.sim_mutations(ts, rate=mutation_rate, random_seed=random_seed)
827
+
828
+ # Save to VCF
829
+ with open(output_file, "w") as vcf_file:
830
+ mts.write_vcf(vcf_file)
831
+
832
+ # Calculate some basic statistics
833
+ diversity = mts.diversity()
834
+ num_sites = mts.num_sites
835
+ num_trees = mts.num_trees
836
+
837
+ # Log results
838
+ end_time = time.time()
839
+ runtime = end_time - start_time
840
+
841
+ log.append(f"Simulation completed in {runtime:.2f} seconds")
842
+ log.append("\nResults:")
843
+ log.append(f" - Number of segregating sites: {num_sites}")
844
+ log.append(f" - Number of trees in ARG: {num_trees}")
845
+ log.append(f" - Nucleotide diversity (π): {diversity:.6f}")
846
+ log.append(f" - Output saved to: {os.path.abspath(output_file)}")
847
+
848
+ return "\n".join(log)
849
+
850
+
851
+ def identify_transcription_factor_binding_sites(sequence, tf_name, threshold=0.8, output_file=None):
852
+ """Identifies binding sites for a specific transcription factor in a genomic sequence.
853
+
854
+ Parameters
855
+ ----------
856
+ sequence : str
857
+ The genomic DNA sequence to analyze
858
+ tf_name : str
859
+ Name of the transcription factor to search for (e.g., 'Hsf1', 'GATA1')
860
+ threshold : float, optional
861
+ Minimum score threshold for reporting binding sites (0.0-1.0, default: 0.8)
862
+ output_file : str, optional
863
+ Path to save the results (default: None, results only in log)
864
+
865
+ Returns
866
+ -------
867
+ str
868
+ Research log detailing the binding site identification process and results
869
+
870
+ """
871
+ import datetime
872
+ import io
873
+
874
+ import requests
875
+ from Bio import motifs
876
+ from Bio.Seq import Seq
877
+
878
+ log = f"# Transcription Factor Binding Site Analysis: {tf_name}\n"
879
+ log += f"Date: {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
880
+
881
+ # Step 1: Get the PWM for the transcription factor from JASPAR database
882
+ log += "## Step 1: Retrieving transcription factor PWM\n"
883
+
884
+ try:
885
+ # Search for the TF in JASPAR database
886
+ jaspar_url = f"https://jaspar.genereg.net/api/v1/matrix/?name={tf_name}"
887
+ response = requests.get(jaspar_url)
888
+ tf_data = response.json()
889
+
890
+ if not tf_data["results"] or len(tf_data["results"]) == 0:
891
+ log += f"No PWM found for {tf_name} in JASPAR database.\n"
892
+ return log
893
+
894
+ # Get the first match's ID
895
+ matrix_id = tf_data["results"][0]["matrix_id"]
896
+ log += f"Found PWM with ID: {matrix_id}\n"
897
+
898
+ # Retrieve the PWM
899
+ pwm_url = f"https://jaspar.genereg.net/api/v1/matrix/{matrix_id}.pfm"
900
+ pwm_response = requests.get(pwm_url)
901
+
902
+ # Parse the PWM
903
+ handle = io.StringIO(pwm_response.text)
904
+ motif = motifs.read(handle, "jaspar")
905
+ log += f"Successfully retrieved PWM for {tf_name}\n"
906
+
907
+ # Calculate position-specific scoring matrix (PSSM)
908
+ pssm = motif.pssm
909
+
910
+ # Step 2: Scan the sequence for binding sites
911
+ log += "\n## Step 2: Scanning sequence for binding sites\n"
912
+ log += f"Sequence length: {len(sequence)} bp\n"
913
+ log += f"Using score threshold: {threshold}\n\n"
914
+
915
+ # Find binding sites
916
+ binding_sites = []
917
+ max_score = pssm.max
918
+ min_score = pssm.min
919
+
920
+ for position, score in pssm.search(Seq(sequence), threshold=threshold):
921
+ relative_score = (score - min_score) / (max_score - min_score)
922
+ if position >= 0:
923
+ strand = "+"
924
+ site_seq = sequence[position : position + len(pssm)]
925
+ else:
926
+ strand = "-"
927
+ site_seq = sequence[len(sequence) + position - len(pssm) : len(sequence) + position]
928
+
929
+ binding_sites.append(
930
+ {
931
+ "position": abs(position),
932
+ "strand": strand,
933
+ "score": score,
934
+ "relative_score": relative_score,
935
+ "sequence": site_seq,
936
+ }
937
+ )
938
+
939
+ # Step 3: Summarize results
940
+ log += f"## Step 3: Results - Found {len(binding_sites)} potential binding sites\n\n"
941
+
942
+ if binding_sites:
943
+ # Sort by position
944
+ binding_sites.sort(key=lambda x: x["position"])
945
+
946
+ # Create a table of results
947
+ log += "| Position | Strand | Sequence | Score | Relative Score |\n"
948
+ log += "|----------|--------|----------|-------|---------------|\n"
949
+
950
+ for site in binding_sites:
951
+ log += f"| {site['position']} | {site['strand']} | {site['sequence']} | {site['score']:.2f} | {site['relative_score']:.2f} |\n"
952
+ else:
953
+ log += "No binding sites found meeting the threshold criteria.\n"
954
+
955
+ # Save results to file if specified
956
+ if output_file:
957
+ with open(output_file, "w") as f:
958
+ f.write(f"# {tf_name} binding sites in sequence\n")
959
+ f.write("Position\tStrand\tSequence\tScore\tRelative Score\n")
960
+
961
+ for site in binding_sites:
962
+ f.write(
963
+ f"{site['position']}\t{site['strand']}\t{site['sequence']}\t{site['score']:.2f}\t{site['relative_score']:.2f}\n"
964
+ )
965
+
966
+ log += f"\nResults saved to file: {output_file}\n"
967
+
968
+ except Exception as e:
969
+ log += f"\n## Error occurred during analysis: {str(e)}\n"
970
+
971
+ log += "\n## Analysis complete\n"
972
+ return log
973
+
974
+
975
+ def fit_genomic_prediction_model(
976
+ genotypes,
977
+ phenotypes,
978
+ fixed_effects=None,
979
+ model_type="additive",
980
+ output_file="genomic_prediction_results.csv",
981
+ ):
982
+ """Fit a linear mixed model for genomic prediction using genotype and phenotype data.
983
+
984
+ Parameters
985
+ ----------
986
+ genotypes : numpy.ndarray
987
+ Matrix of genotype data, with individuals in rows and markers in columns.
988
+ Values are typically coded as 0, 1, 2 for additive models or with specific
989
+ encoding for dominance effects.
990
+ phenotypes : numpy.ndarray
991
+ Vector or matrix of phenotype data, with individuals in rows and traits in columns.
992
+ fixed_effects : numpy.ndarray, optional
993
+ Matrix of fixed effects (e.g., environment, management), with individuals in rows
994
+ and effects in columns.
995
+ model_type : str, optional
996
+ Type of genetic model to fit: "additive" or "additive_dominance".
997
+ output_file : str, optional
998
+ File name to save the results.
999
+
1000
+ Returns
1001
+ -------
1002
+ str
1003
+ Research log summarizing the genomic prediction analysis, including model parameters,
1004
+ variance components, breeding values, and prediction accuracy metrics.
1005
+
1006
+ """
1007
+ import pandas as pd
1008
+ from scipy import linalg
1009
+
1010
+ # Initialize research log
1011
+ log = "# Multi-trait Genomic Prediction Analysis\n\n"
1012
+ log += f"Model type: {model_type}\n"
1013
+
1014
+ # Basic validation
1015
+ n_individuals, n_markers = genotypes.shape
1016
+ n_pheno, n_traits = (phenotypes.shape[0], 1) if phenotypes.ndim == 1 else phenotypes.shape
1017
+
1018
+ if n_individuals != n_pheno:
1019
+ raise ValueError(f"Number of individuals in genotypes ({n_individuals}) and phenotypes ({n_pheno}) don't match")
1020
+
1021
+ log += f"Number of individuals: {n_individuals}\n"
1022
+ log += f"Number of markers: {n_markers}\n"
1023
+ log += f"Number of traits: {n_traits}\n\n"
1024
+
1025
+ # Ensure phenotypes is 2D
1026
+ if phenotypes.ndim == 1:
1027
+ phenotypes = phenotypes.reshape(-1, 1)
1028
+
1029
+ # Center genotypes (common preprocessing step)
1030
+ genotypes_centered = genotypes - np.mean(genotypes, axis=0)
1031
+
1032
+ # Create genomic relationship matrix (G)
1033
+ if model_type == "additive":
1034
+ # Additive genomic relationship matrix
1035
+ G = np.dot(genotypes_centered, genotypes_centered.T) / n_markers
1036
+ log += "Constructed additive genomic relationship matrix (G)\n\n"
1037
+ elif model_type == "additive_dominance":
1038
+ # For additive-dominance model, we need both A (additive) and D (dominance) matrices
1039
+ # Assuming genotypes are coded as {0,1,2} for {aa,Aa,AA}
1040
+ # Create dominance matrix - simple implementation assuming standard coding
1041
+ dom_genotypes = np.zeros_like(genotypes)
1042
+ # Code heterozygotes (1) as 1, homozygotes (0,2) as 0 for dominance effects
1043
+ dom_genotypes[genotypes == 1] = 1
1044
+ dom_genotypes_centered = dom_genotypes - np.mean(dom_genotypes, axis=0)
1045
+
1046
+ # Additive and dominance matrices
1047
+ G_a = np.dot(genotypes_centered, genotypes_centered.T) / n_markers
1048
+ G_d = np.dot(dom_genotypes_centered, dom_genotypes_centered.T) / n_markers
1049
+ log += "Constructed additive (G_a) and dominance (G_d) genomic relationship matrices\n\n"
1050
+ else:
1051
+ raise ValueError(f"Unknown model type: {model_type}")
1052
+
1053
+ # Initialize results storage
1054
+ trait_results = []
1055
+
1056
+ # Fit model for each trait
1057
+ for trait_idx in range(n_traits):
1058
+ trait_phenotypes = phenotypes[:, trait_idx]
1059
+ log += f"## Trait {trait_idx + 1} Analysis\n\n"
1060
+
1061
+ # Handle fixed effects if provided
1062
+ if fixed_effects is not None:
1063
+ # Simple fixed effects adjustment - more complex models would use proper mixed model fitting
1064
+ X = fixed_effects
1065
+ # Fit fixed effects model
1066
+ beta = np.linalg.lstsq(X, trait_phenotypes, rcond=None)[0]
1067
+ # Adjust phenotypes for fixed effects
1068
+ y_adj = trait_phenotypes - X @ beta
1069
+ log += f"Applied adjustment for {X.shape[1]} fixed effects\n"
1070
+ else:
1071
+ y_adj = trait_phenotypes
1072
+ log += "No fixed effects provided\n"
1073
+
1074
+ # Fit mixed model
1075
+ if model_type == "additive":
1076
+ # Simplified REML estimation for variance components
1077
+ # In practice, specialized libraries like pyGWAS, GCTA or R's ASReml would be used
1078
+
1079
+ # Initial variance component estimates
1080
+ var_g_init = np.var(y_adj) * 0.5 # genetic variance
1081
+ var_e_init = np.var(y_adj) * 0.5 # residual variance
1082
+
1083
+ # Simple EM-like algorithm for variance component estimation
1084
+ # (In practice, use dedicated software for proper REML)
1085
+ for _ in range(5): # Few iterations for demonstration
1086
+ # Construct mixed model equations
1087
+ V = var_g_init * G + var_e_init * np.eye(n_individuals)
1088
+ V_inv = linalg.inv(V)
1089
+
1090
+ # Update variance components
1091
+ P = V_inv - V_inv @ np.ones((n_individuals, 1)) @ np.ones((1, n_individuals)) @ V_inv / (
1092
+ np.ones((1, n_individuals)) @ V_inv @ np.ones((n_individuals, 1))
1093
+ )
1094
+ var_g_new = (y_adj.T @ P @ G @ P @ y_adj) / np.trace(P @ G)
1095
+ var_e_new = (y_adj.T @ P @ P @ y_adj) / np.trace(P)
1096
+
1097
+ # Update estimates
1098
+ var_g_init = max(0.01, var_g_new)
1099
+ var_e_init = max(0.01, var_e_new)
1100
+
1101
+ # Final variance components
1102
+ var_g = var_g_init
1103
+ var_e = var_e_init
1104
+
1105
+ # Calculate heritability
1106
+ heritability = var_g / (var_g + var_e)
1107
+
1108
+ # BLUP solutions for breeding values
1109
+ V = var_g * G + var_e * np.eye(n_individuals)
1110
+ V_inv = linalg.inv(V)
1111
+ breeding_values = var_g * G @ V_inv @ y_adj
1112
+
1113
+ # Predicted phenotypes
1114
+ predicted_phenotypes = breeding_values
1115
+
1116
+ # Calculate accuracy
1117
+ accuracy = np.corrcoef(trait_phenotypes, predicted_phenotypes)[0, 1]
1118
+
1119
+ # Log results
1120
+ log += f"Estimated additive genetic variance: {var_g:.4f}\n"
1121
+ log += f"Estimated residual variance: {var_e:.4f}\n"
1122
+ log += f"Estimated heritability: {heritability:.4f}\n"
1123
+ log += f"Prediction accuracy (correlation): {accuracy:.4f}\n\n"
1124
+
1125
+ # Store results
1126
+ trait_result = {
1127
+ "trait": trait_idx + 1,
1128
+ "var_g": var_g,
1129
+ "var_e": var_e,
1130
+ "heritability": heritability,
1131
+ "accuracy": accuracy,
1132
+ "breeding_values": breeding_values,
1133
+ "predicted_phenotypes": predicted_phenotypes,
1134
+ }
1135
+ trait_results.append(trait_result)
1136
+
1137
+ elif model_type == "additive_dominance":
1138
+ # Similar approach but with both additive and dominance effects
1139
+ # Initial variance component estimates
1140
+ var_a_init = np.var(y_adj) * 0.4 # additive variance
1141
+ var_d_init = np.var(y_adj) * 0.1 # dominance variance
1142
+ var_e_init = np.var(y_adj) * 0.5 # residual variance
1143
+
1144
+ # Simple estimation iterations
1145
+ for _ in range(5): # Few iterations for demonstration
1146
+ # Construct mixed model equations
1147
+ V = var_a_init * G_a + var_d_init * G_d + var_e_init * np.eye(n_individuals)
1148
+ V_inv = linalg.inv(V)
1149
+
1150
+ # Update variance components (simplified)
1151
+ P = V_inv - V_inv @ np.ones((n_individuals, 1)) @ np.ones((1, n_individuals)) @ V_inv / (
1152
+ np.ones((1, n_individuals)) @ V_inv @ np.ones((n_individuals, 1))
1153
+ )
1154
+ var_a_new = (y_adj.T @ P @ G_a @ P @ y_adj) / np.trace(P @ G_a)
1155
+ var_d_new = (y_adj.T @ P @ G_d @ P @ y_adj) / np.trace(P @ G_d)
1156
+ var_e_new = (y_adj.T @ P @ P @ y_adj) / np.trace(P)
1157
+
1158
+ # Update estimates
1159
+ var_a_init = max(0.01, var_a_new)
1160
+ var_d_init = max(0.01, var_d_new)
1161
+ var_e_init = max(0.01, var_e_new)
1162
+
1163
+ # Final variance components
1164
+ var_a = var_a_init
1165
+ var_d = var_d_init
1166
+ var_e = var_e_init
1167
+
1168
+ # Calculate heritabilities
1169
+ narrow_heritability = var_a / (var_a + var_d + var_e)
1170
+ broad_heritability = (var_a + var_d) / (var_a + var_d + var_e)
1171
+
1172
+ # BLUP solutions for breeding values and dominance deviations
1173
+ V = var_a * G_a + var_d * G_d + var_e * np.eye(n_individuals)
1174
+ V_inv = linalg.inv(V)
1175
+ breeding_values = var_a * G_a @ V_inv @ y_adj
1176
+ dominance_deviations = var_d * G_d @ V_inv @ y_adj
1177
+
1178
+ # Predicted phenotypes
1179
+ predicted_phenotypes = breeding_values + dominance_deviations
1180
+
1181
+ # Calculate accuracy
1182
+ accuracy = np.corrcoef(trait_phenotypes, predicted_phenotypes)[0, 1]
1183
+
1184
+ # Log results
1185
+ log += f"Estimated additive genetic variance: {var_a:.4f}\n"
1186
+ log += f"Estimated dominance genetic variance: {var_d:.4f}\n"
1187
+ log += f"Estimated residual variance: {var_e:.4f}\n"
1188
+ log += f"Estimated narrow-sense heritability: {narrow_heritability:.4f}\n"
1189
+ log += f"Estimated broad-sense heritability: {broad_heritability:.4f}\n"
1190
+ log += f"Prediction accuracy (correlation): {accuracy:.4f}\n\n"
1191
+
1192
+ # Store results
1193
+ trait_result = {
1194
+ "trait": trait_idx + 1,
1195
+ "var_a": var_a,
1196
+ "var_d": var_d,
1197
+ "var_e": var_e,
1198
+ "narrow_heritability": narrow_heritability,
1199
+ "broad_heritability": broad_heritability,
1200
+ "accuracy": accuracy,
1201
+ "breeding_values": breeding_values,
1202
+ "dominance_deviations": dominance_deviations,
1203
+ "predicted_phenotypes": predicted_phenotypes,
1204
+ }
1205
+ trait_results.append(trait_result)
1206
+
1207
+ # Save results to file
1208
+ results_df = pd.DataFrame()
1209
+
1210
+ for i, trait_result in enumerate(trait_results):
1211
+ # Create individual-level results
1212
+ ind_data = {
1213
+ "individual": np.arange(1, n_individuals + 1),
1214
+ f"trait_{i + 1}_observed": phenotypes[:, i],
1215
+ f"trait_{i + 1}_predicted": trait_result["predicted_phenotypes"],
1216
+ f"trait_{i + 1}_breeding_value": trait_result["breeding_values"],
1217
+ }
1218
+
1219
+ if model_type == "additive_dominance":
1220
+ ind_data[f"trait_{i + 1}_dominance_deviation"] = trait_result["dominance_deviations"]
1221
+
1222
+ # Create or append to dataframe
1223
+ if i == 0:
1224
+ results_df = pd.DataFrame(ind_data)
1225
+ else:
1226
+ for key, value in ind_data.items():
1227
+ if key != "individual": # Skip duplicating individual column
1228
+ results_df[key] = value
1229
+
1230
+ # Save to CSV
1231
+ results_df.to_csv(output_file, index=False)
1232
+ log += f"Results saved to {output_file}\n"
1233
+
1234
+ return log
1235
+
1236
+
1237
+ def perform_pcr_and_gel_electrophoresis(
1238
+ genomic_dna,
1239
+ forward_primer=None,
1240
+ reverse_primer=None,
1241
+ target_region=None,
1242
+ annealing_temp=58,
1243
+ extension_time=30,
1244
+ cycles=35,
1245
+ gel_percentage=2.0,
1246
+ output_prefix="pcr_result",
1247
+ ):
1248
+ """Performs PCR amplification of a target transgene and visualizes results using agarose gel electrophoresis.
1249
+
1250
+ Parameters
1251
+ ----------
1252
+ genomic_dna : str
1253
+ Path to file containing genomic DNA sequence in FASTA format or the sequence itself
1254
+ forward_primer : str, optional
1255
+ Forward primer sequence. If not provided, will be designed based on target_region
1256
+ reverse_primer : str, optional
1257
+ Reverse primer sequence. If not provided, will be designed based on target_region
1258
+ target_region : tuple, optional
1259
+ Tuple of (start, end) positions for the target region in the genomic DNA
1260
+ annealing_temp : float, default=58
1261
+ Annealing temperature for PCR in °C
1262
+ extension_time : int, default=30
1263
+ Extension time in seconds
1264
+ cycles : int, default=35
1265
+ Number of PCR cycles
1266
+ gel_percentage : float, default=2.0
1267
+ Percentage of agarose gel
1268
+ output_prefix : str, default="pcr_result"
1269
+ Prefix for output files
1270
+
1271
+ Returns
1272
+ -------
1273
+ str
1274
+ Research log summarizing the PCR and gel electrophoresis procedures and results
1275
+
1276
+ """
1277
+ import datetime
1278
+
1279
+ import matplotlib.pyplot as plt
1280
+ import numpy as np
1281
+ from Bio import SeqIO
1282
+ from Bio.Seq import Seq
1283
+
1284
+ log = f"PCR AMPLIFICATION AND GEL ELECTROPHORESIS LOG - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M')}\n"
1285
+ log += "=" * 80 + "\n\n"
1286
+
1287
+ # Step 1: Load the genomic DNA
1288
+ log += "STEP 1: PREPARING GENOMIC DNA\n"
1289
+ if os.path.isfile(genomic_dna):
1290
+ try:
1291
+ record = SeqIO.read(genomic_dna, "fasta")
1292
+ dna_sequence = str(record.seq)
1293
+ log += f"- Loaded genomic DNA from file: {genomic_dna}\n"
1294
+ log += f"- Sequence length: {len(dna_sequence)} bp\n"
1295
+ except Exception as e:
1296
+ log += f"- Error loading DNA file: {str(e)}\n"
1297
+ return log
1298
+ else:
1299
+ dna_sequence = genomic_dna
1300
+ log += "- Using provided DNA sequence\n"
1301
+ log += f"- Sequence length: {len(dna_sequence)} bp\n"
1302
+
1303
+ log += "\n"
1304
+
1305
+ # Step 2: Design or validate primers
1306
+ log += "STEP 2: PCR PRIMER PREPARATION\n"
1307
+
1308
+ if forward_primer is None or reverse_primer is None:
1309
+ if target_region is None:
1310
+ log += "- Error: Either primers or target region must be provided\n"
1311
+ return log
1312
+
1313
+ # Simple primer design based on target region
1314
+ start, end = target_region
1315
+
1316
+ if forward_primer is None:
1317
+ # Take 20bp from the start of the target region for forward primer
1318
+ forward_primer = dna_sequence[start : start + 20]
1319
+ log += "- Designed forward primer based on target region\n"
1320
+
1321
+ if reverse_primer is None:
1322
+ # Take 20bp from the end of the target region for reverse primer (reverse complement)
1323
+ reverse_seq = Seq(dna_sequence[end - 20 : end])
1324
+ reverse_primer = str(reverse_seq.reverse_complement())
1325
+ log += "- Designed reverse primer based on target region\n"
1326
+
1327
+ log += f"- Forward primer: 5'-{forward_primer}-3' ({len(forward_primer)} bp)\n"
1328
+ log += f"- Reverse primer: 5'-{reverse_primer}-3' ({len(reverse_primer)} bp)\n"
1329
+
1330
+ # Step 3: PCR Setup and Amplification
1331
+ log += "\nSTEP 3: PCR AMPLIFICATION\n"
1332
+ log += "- PCR reaction setup:\n"
1333
+ log += " * Template DNA: Genomic DNA\n"
1334
+ log += f" * Forward primer: 5'-{forward_primer}-3'\n"
1335
+ log += f" * Reverse primer: 5'-{reverse_primer}-3'\n"
1336
+ log += f" * Annealing temperature: {annealing_temp}°C\n"
1337
+ log += f" * Extension time: {extension_time} seconds\n"
1338
+ log += f" * Number of cycles: {cycles}\n"
1339
+
1340
+ # Simulate PCR by finding binding sites and determining amplicon size
1341
+ amplicon_size = None
1342
+ amplicon_sequence = None
1343
+
1344
+ # Find forward primer binding site
1345
+ fwd_pos = dna_sequence.find(forward_primer)
1346
+ if fwd_pos == -1:
1347
+ log += "- Warning: Forward primer binding site not found in sequence\n"
1348
+
1349
+ # Find reverse primer binding site (need to search for reverse complement)
1350
+ rev_primer_seq = Seq(reverse_primer)
1351
+ rev_primer_rc = str(rev_primer_seq.reverse_complement())
1352
+ rev_pos = dna_sequence.find(rev_primer_rc)
1353
+ if rev_pos == -1:
1354
+ log += "- Warning: Reverse primer binding site not found in sequence\n"
1355
+
1356
+ # If we found both binding sites, calculate amplicon size
1357
+ if fwd_pos != -1 and rev_pos != -1:
1358
+ if fwd_pos < rev_pos:
1359
+ amplicon_size = rev_pos + len(reverse_primer) - fwd_pos
1360
+ amplicon_sequence = dna_sequence[fwd_pos : rev_pos + len(reverse_primer)]
1361
+ log += "- PCR amplification successful\n"
1362
+ log += f"- Amplicon size: {amplicon_size} bp\n"
1363
+ else:
1364
+ log += "- Error: Primer binding sites are in incorrect orientation\n"
1365
+ # Simulate based on target region if provided
1366
+ elif target_region is not None:
1367
+ start, end = target_region
1368
+ amplicon_size = end - start + len(forward_primer) + len(reverse_primer)
1369
+ log += "- PCR amplification simulated based on target region\n"
1370
+ log += f"- Expected amplicon size: {amplicon_size} bp\n"
1371
+ else:
1372
+ log += "- PCR amplification failed: could not determine amplicon size\n"
1373
+ return log
1374
+
1375
+ # Step 4: Gel Electrophoresis
1376
+ log += "\nSTEP 4: AGAROSE GEL ELECTROPHORESIS\n"
1377
+ log += f"- Prepared {gel_percentage}% agarose gel\n"
1378
+ log += "- Loaded PCR product alongside DNA ladder\n"
1379
+ log += "- Ran electrophoresis at 100V for 45 minutes\n"
1380
+
1381
+ # Create a simulated gel image
1382
+ fig, ax = plt.subplots(figsize=(6, 8))
1383
+
1384
+ # Draw gel lanes
1385
+ ax.add_patch(plt.Rectangle((0, 0), 6, 10, color="lightgray", alpha=0.5))
1386
+
1387
+ # DNA Ladder (100bp increments)
1388
+ ladder_sizes = [100, 200, 300, 500, 700, 1000, 1500, 2000]
1389
+ ladder_positions = [10 - (np.log(size) / np.log(2000) * 8) for size in ladder_sizes]
1390
+
1391
+ # Plot ladder
1392
+ for pos, size in zip(ladder_positions, ladder_sizes, strict=False):
1393
+ ax.add_patch(plt.Rectangle((0.5, pos - 0.1), 1, 0.2, color="black", alpha=0.8))
1394
+ ax.text(0.2, pos, f"{size}bp", fontsize=8, ha="right", va="center")
1395
+
1396
+ # Plot sample band
1397
+ if amplicon_size:
1398
+ sample_position = 10 - (np.log(amplicon_size) / np.log(2000) * 8)
1399
+ ax.add_patch(plt.Rectangle((3.5, sample_position - 0.15), 1, 0.3, color="black", alpha=0.8))
1400
+ ax.text(
1401
+ 4.5,
1402
+ sample_position,
1403
+ f"{amplicon_size}bp",
1404
+ fontsize=8,
1405
+ ha="left",
1406
+ va="center",
1407
+ )
1408
+
1409
+ # Set up the plot
1410
+ ax.set_xlim(0, 6)
1411
+ ax.set_ylim(0, 10)
1412
+ ax.set_xticks([0.5, 3.5])
1413
+ ax.set_xticklabels(["Ladder", "Sample"])
1414
+ ax.set_yticks([])
1415
+ ax.spines["top"].set_visible(False)
1416
+ ax.spines["right"].set_visible(False)
1417
+ ax.spines["left"].set_visible(False)
1418
+ ax.set_title(f"{gel_percentage}% Agarose Gel")
1419
+
1420
+ # Save the gel image
1421
+ gel_image_path = f"{output_prefix}_gel.png"
1422
+ plt.savefig(gel_image_path, dpi=300, bbox_inches="tight")
1423
+ plt.close()
1424
+
1425
+ log += f"- Gel image saved as: {gel_image_path}\n"
1426
+
1427
+ # Results interpretation
1428
+ log += "\nRESULTS INTERPRETATION:\n"
1429
+ if amplicon_size:
1430
+ log += f"- Detected band at approximately {amplicon_size} bp\n"
1431
+
1432
+ # Save amplicon sequence if available
1433
+ if amplicon_sequence:
1434
+ seq_file = f"{output_prefix}_amplicon.fasta"
1435
+ with open(seq_file, "w") as f:
1436
+ f.write(f">PCR_Amplicon_{amplicon_size}bp\n")
1437
+ f.write(amplicon_sequence)
1438
+ log += f"- Amplicon sequence saved as: {seq_file}\n"
1439
+ else:
1440
+ log += "- No bands detected\n"
1441
+
1442
+ return log
1443
+
1444
+
1445
+ def analyze_protein_phylogeny(
1446
+ fasta_sequences,
1447
+ output_dir="./",
1448
+ alignment_method="clustalw",
1449
+ tree_method="fasttree",
1450
+ ):
1451
+ """Perform phylogenetic analysis on a set of protein sequences.
1452
+
1453
+ This function takes protein sequences in FASTA format, performs multiple sequence alignment,
1454
+ constructs a phylogenetic tree, and visualizes the evolutionary relationships.
1455
+
1456
+ Parameters
1457
+ ----------
1458
+ fasta_sequences : str
1459
+ Path to a FASTA file containing protein sequences or a string with FASTA-formatted sequences
1460
+ output_dir : str, optional
1461
+ Directory to save output files (default: current directory)
1462
+ alignment_method : str, optional
1463
+ Method for sequence alignment: "clustalw", "muscle", or "pre-aligned" (default: "clustalw")
1464
+ tree_method : str, optional
1465
+ Method for tree construction: "iqtree" (default: "iqtree")
1466
+
1467
+ Returns
1468
+ -------
1469
+ str
1470
+ Research log summarizing the phylogenetic analysis process
1471
+
1472
+ """
1473
+ import datetime
1474
+ import subprocess
1475
+ import tempfile
1476
+
1477
+ from Bio import AlignIO, Phylo, SeqIO
1478
+ from Bio.Align.Applications import ClustalwCommandline, MuscleCommandline
1479
+
1480
+ # Create output directory if it doesn't exist
1481
+ if not os.path.exists(output_dir):
1482
+ os.makedirs(output_dir)
1483
+
1484
+ # Initialize log
1485
+ log = []
1486
+ log.append(f"Phylogenetic Analysis - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
1487
+ log.append("=" * 50)
1488
+
1489
+ # Check if input is a file path or string content
1490
+ if os.path.isfile(fasta_sequences):
1491
+ input_file = fasta_sequences
1492
+ log.append(f"Using sequences from file: {input_file}")
1493
+ else:
1494
+ # Create temporary file with the string content
1495
+ temp_fasta = tempfile.NamedTemporaryFile(delete=False, suffix=".fasta", dir=output_dir)
1496
+ temp_fasta.write(fasta_sequences.encode())
1497
+ temp_fasta.close()
1498
+ input_file = temp_fasta.name
1499
+ log.append(f"Created temporary FASTA file from provided sequences: {input_file}")
1500
+
1501
+ # Count sequences
1502
+ try:
1503
+ sequences = list(SeqIO.parse(input_file, "fasta"))
1504
+ log.append(f"Loaded {len(sequences)} protein sequences")
1505
+ except Exception as e:
1506
+ log.append(f"Warning: Could not parse sequences as FASTA: {str(e)}")
1507
+ # This might be pre-aligned data already
1508
+ sequences = []
1509
+
1510
+ # Create filenames for outputs
1511
+ base_name = os.path.splitext(os.path.basename(input_file))[0]
1512
+ alignment_file = os.path.join(output_dir, f"{base_name}_aligned.aln")
1513
+ tree_file = os.path.join(output_dir, f"{base_name}_tree.nwk")
1514
+ tree_image = os.path.join(output_dir, f"{base_name}_phylogeny.png")
1515
+
1516
+ # Perform multiple sequence alignment
1517
+ log.append("\nStep 1: Multiple Sequence Alignment")
1518
+
1519
+ # Special case for pre-aligned input
1520
+ if alignment_method.lower() == "pre-aligned":
1521
+ log.append("Using pre-aligned sequences")
1522
+ try:
1523
+ # If the input is already an alignment file, copy it to the alignment_file path
1524
+ with open(input_file) as src, open(alignment_file, "w") as dst:
1525
+ dst.write(src.read())
1526
+ log.append(f"Copied pre-aligned file to: {alignment_file}")
1527
+ except Exception as e:
1528
+ log.append(f"Error processing pre-aligned sequences: {str(e)}")
1529
+ return "\n".join(log)
1530
+ elif alignment_method.lower() == "clustalw":
1531
+ log.append("Using Clustal Omega for alignment")
1532
+ try:
1533
+ clustalw_cline = ClustalwCommandline("clustalw", infile=input_file, outfile=alignment_file)
1534
+ stdout, stderr = clustalw_cline()
1535
+ log.append("Alignment completed successfully")
1536
+ except Exception as e:
1537
+ log.append(f"Error during alignment: {str(e)}")
1538
+ # Try alternative approach using MUSCLE if ClustalW fails
1539
+ alignment_method = "muscle"
1540
+
1541
+ if alignment_method.lower() == "muscle":
1542
+ log.append("Using MUSCLE for alignment")
1543
+ try:
1544
+ muscle_cline = MuscleCommandline("muscle", input=input_file, out=alignment_file)
1545
+ stdout, stderr = muscle_cline()
1546
+ log.append("Alignment completed successfully")
1547
+ except Exception as e:
1548
+ log.append(f"Error during MUSCLE alignment: {str(e)}")
1549
+ log.append("Attempting to use Biopython's built-in pairwise2 alignment as fallback")
1550
+
1551
+ from Bio import pairwise2
1552
+
1553
+ # Create a simple progressive alignment
1554
+ ref_seq = sequences[0]
1555
+ alignments = []
1556
+
1557
+ for seq in sequences:
1558
+ alignment = pairwise2.align.globalxx(ref_seq.seq, seq.seq)[0]
1559
+ alignments.append(alignment)
1560
+
1561
+ # Write alignment to file in CLUSTAL format
1562
+ with open(alignment_file, "w") as f:
1563
+ f.write("CLUSTAL W (1.83) multiple sequence alignment\n\n")
1564
+ for i, seq in enumerate(sequences):
1565
+ f.write(f"{seq.id.ljust(10)} {alignments[i][1]}\n")
1566
+ # Add consensus line with asterisks
1567
+ f.write(" " * 10 + " " * len(alignments[0][1]) + "\n")
1568
+
1569
+ log.append("Created basic alignment using Biopython's pairwise2")
1570
+
1571
+ # Verify alignment file exists
1572
+ if not os.path.exists(alignment_file):
1573
+ log.append(f"Error: Alignment file {alignment_file} does not exist")
1574
+ return "\n".join(log)
1575
+
1576
+ # Build phylogenetic tree
1577
+ log.append("\nStep 2: Phylogenetic Tree Construction")
1578
+
1579
+ if tree_method.lower() == "iqtree":
1580
+ log.append("Using IQ-TREE for phylogenetic tree construction")
1581
+ try:
1582
+ cmd = f"iqtree -s {alignment_file} -m LG -bb 1000 -pre {os.path.join(output_dir, base_name)}"
1583
+ subprocess.run(
1584
+ cmd,
1585
+ shell=True,
1586
+ check=True,
1587
+ capture_output=True,
1588
+ )
1589
+ # IQ-TREE creates files with .treefile extension
1590
+ iqtree_file = os.path.join(output_dir, f"{base_name}.treefile")
1591
+ if os.path.exists(iqtree_file):
1592
+ # Rename to our standard name
1593
+ os.rename(iqtree_file, tree_file)
1594
+ log.append("Tree construction completed successfully")
1595
+ except Exception as e:
1596
+ log.append(f"Error during IQ-TREE execution: {str(e)}")
1597
+ log.append("Falling back to neighbor-joining method")
1598
+
1599
+ try:
1600
+ from Bio.Phylo.TreeConstruction import (
1601
+ DistanceCalculator,
1602
+ DistanceTreeConstructor,
1603
+ )
1604
+
1605
+ # Try to read the alignment in different formats
1606
+ alignment = None
1607
+ for format in ["clustal", "fasta"]:
1608
+ try:
1609
+ alignment = AlignIO.read(alignment_file, format)
1610
+ break
1611
+ except Exception:
1612
+ continue
1613
+
1614
+ if alignment is None:
1615
+ log.append("Could not parse alignment file in any supported format")
1616
+ # Create a simple text-based tree file as a fallback
1617
+ with open(tree_file, "w") as f:
1618
+ f.write("(protein1:0.1,protein2:0.2,(protein3:0.3,protein4:0.4):0.5);")
1619
+ log.append("Created placeholder tree file")
1620
+ else:
1621
+ # Calculate the distance matrix
1622
+ calculator = DistanceCalculator("identity")
1623
+ dm = calculator.get_distance(alignment)
1624
+
1625
+ # Construct the tree
1626
+ constructor = DistanceTreeConstructor()
1627
+ tree = constructor.nj(dm)
1628
+
1629
+ # Write the tree to file
1630
+ Phylo.write(tree, tree_file, "newick")
1631
+ log.append("Created tree using neighbor-joining method")
1632
+ except Exception as e:
1633
+ log.append(f"Error during fallback tree construction: {str(e)}")
1634
+ # Create a simple text-based tree file as a final fallback
1635
+ with open(tree_file, "w") as f:
1636
+ f.write("(protein1:0.1,protein2:0.2,(protein3:0.3,protein4:0.4):0.5);")
1637
+ log.append("Created placeholder tree file as final fallback")
1638
+ else:
1639
+ log.append(f"Unsupported tree method: {tree_method}")
1640
+ return "\n".join(log)
1641
+
1642
+ # Verify tree file exists
1643
+ if not os.path.exists(tree_file):
1644
+ log.append(f"Error: Tree file {tree_file} does not exist")
1645
+ return "\n".join(log)
1646
+
1647
+ # Visualize the tree
1648
+ log.append("\nStep 3: Phylogenetic Tree Visualization")
1649
+ try:
1650
+ import matplotlib
1651
+
1652
+ matplotlib.use("Agg") # Use non-interactive backend
1653
+ import matplotlib.pyplot as plt
1654
+
1655
+ tree = Phylo.read(tree_file, "newick")
1656
+ fig = plt.figure(figsize=(10, len(sequences) * 0.3 if sequences else 5))
1657
+ axes = fig.add_subplot(1, 1, 1)
1658
+ Phylo.draw(tree, axes=axes, do_show=False)
1659
+ plt.savefig(tree_image, dpi=300, bbox_inches="tight")
1660
+ plt.close()
1661
+ log.append(f"Tree visualization saved to: {tree_image}")
1662
+ except Exception as e:
1663
+ log.append(f"Error during tree visualization: {str(e)}")
1664
+
1665
+ # Summary
1666
+ log.append("\nSummary:")
1667
+ log.append(f"- Input sequences: {len(sequences) if sequences else 'pre-aligned data'}")
1668
+ log.append(f"- Alignment file: {alignment_file}")
1669
+ log.append(f"- Phylogenetic tree file: {tree_file}")
1670
+ log.append(f"- Tree visualization: {tree_image}")
1671
+
1672
+ return "\n".join(log)
BioScientist/agent_system/engines/v1_executor_backup/tool/genomics.py ADDED
The diff for this file is too large to render. See raw diff
 
BioScientist/agent_system/engines/v1_executor_backup/tool/glycoengineering.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Glycoengineering tools: quick, dependency-light utilities for glycosylation analysis
3
+ and curated links to external, specialized software referenced in issue #198.
4
+
5
+ Functions return research-log style strings to match Biomni tool patterns.
6
+ """
7
+
8
+
9
+ def find_n_glycosylation_motifs(sequence: str, allow_overlap: bool = False) -> str:
10
+ """Scan a protein sequence for N-linked glycosylation sequons (N-X-[S/T]).
11
+
12
+ Rules
13
+ - Motif: Asn (N) followed by any residue except Proline (P), followed by Serine (S) or Threonine (T)
14
+ - By default, overlapping matches are not reported twice
15
+
16
+ Parameters
17
+ - sequence: protein sequence (one-letter amino-acid codes)
18
+ - allow_overlap: if True, allow overlapping motif detection
19
+
20
+ Returns
21
+ - Research log string summarizing motif locations and counts
22
+ """
23
+ seq = (sequence or "").upper()
24
+ results: list[dict] = []
25
+
26
+ i = 0
27
+ while i <= len(seq) - 3:
28
+ tri = seq[i : i + 3]
29
+ if tri[0] == "N" and tri[1] != "P" and tri[2] in {"S", "T"}:
30
+ results.append({"position": i + 1, "motif": tri}) # 1-based
31
+ i = i + (1 if allow_overlap else 3)
32
+ else:
33
+ i += 1
34
+
35
+ log = ["# N-linked glycosylation sequon scan (N-X-[S/T], X≠P)"]
36
+ log.append(f"Sequence length: {len(seq)}")
37
+ log.append(f"Total sequons found: {len(results)}")
38
+ if results:
39
+ log.append("\nPositions (1-based):")
40
+ for r in results[:100]: # print at most 100 entries to keep output manageable
41
+ log.append(f"- {r['position']}: {r['motif']}")
42
+ if len(results) > 100:
43
+ log.append(f"... and {len(results) - 100} more")
44
+ else:
45
+ log.append("No canonical N-linked sequons detected.")
46
+ return "\n".join(log)
47
+
48
+
49
+ def predict_o_glycosylation_hotspots(
50
+ sequence: str,
51
+ window: int = 7,
52
+ min_st_fraction: float = 0.4,
53
+ disallow_proline_next: bool = True,
54
+ ) -> str:
55
+ """Heuristic O-glycosylation hotspot scoring.
56
+
57
+ Background
58
+ - O-GalNAc glycosylation frequently occurs on Ser/Thr-rich segments.
59
+ - This lightweight heuristic flags residues in local windows enriched for S/T.
60
+ - Not a substitute for NetOGlyc; provided as a fast, dependency-free baseline.
61
+
62
+ Parameters
63
+ - sequence: protein sequence (one-letter AA codes)
64
+ - window: odd window size for local S/T density (default 7)
65
+ - min_st_fraction: minimum S/T fraction in window to flag sites (0..1)
66
+ - disallow_proline_next: if True, avoid flagging S/T immediately followed by Proline
67
+
68
+ Returns
69
+ - Research log string with candidate sites and scores
70
+ """
71
+ if window < 3 or window % 2 == 0:
72
+ window = 7
73
+ seq = (sequence or "").upper()
74
+ half = window // 2
75
+
76
+ candidates: list[dict] = []
77
+ for i, aa in enumerate(seq):
78
+ if aa not in {"S", "T"}:
79
+ continue
80
+ start = max(0, i - half)
81
+ end = min(len(seq), i + half + 1)
82
+ segment = seq[start:end]
83
+ st_count = sum(1 for c in segment if c in {"S", "T"})
84
+ frac = st_count / max(1, len(segment))
85
+ if disallow_proline_next and i + 1 < len(seq) and seq[i + 1] == "P":
86
+ continue
87
+ if frac >= min_st_fraction:
88
+ candidates.append(
89
+ {
90
+ "position": i + 1,
91
+ "residue": aa,
92
+ "st_fraction": round(frac, 3),
93
+ "window": f"{start + 1}-{end}",
94
+ }
95
+ )
96
+
97
+ log = ["# Heuristic O-glycosylation hotspot prediction (S/T density based)"]
98
+ log.append(f"Sequence length: {len(seq)} | window={window} | threshold={min_st_fraction}")
99
+ log.append(f"Total candidate sites: {len(candidates)}")
100
+ if candidates:
101
+ log.append("\nTop candidates:")
102
+ for c in candidates[:100]:
103
+ log.append(
104
+ f"- pos {c['position']} ({c['residue']}): S/T fraction={c['st_fraction']} in window {c['window']}"
105
+ )
106
+ if len(candidates) > 100:
107
+ log.append(f"... and {len(candidates) - 100} more")
108
+ else:
109
+ log.append("No candidate O-glycosylation hotspots met the heuristic threshold.")
110
+
111
+ log.append("\nNote: For state-of-the-art O-glycosite prediction, use NetOGlyc 4.0 (web service).")
112
+ return "\n".join(log)
113
+
114
+
115
+ def list_glycoengineering_resources() -> str:
116
+ """Curate and summarize external glycoengineering tools and resources.
117
+
118
+ Includes links referenced in issue #198 and brief usage notes.
119
+ Returns a research-log style summary with URLs for further use.
120
+ """
121
+ lines = ["# Glycoengineering tools and resources (curated)"]
122
+ lines.append("")
123
+ lines.append("1) Glycoshield-md")
124
+ lines.append(" - MD workflows for glycan shielding analysis around proteins.")
125
+ lines.append(" - URL: https://gitlab.mpcdf.mpg.de/dioscuri-biophysics/glycoshield-md/")
126
+ lines.append("")
127
+ lines.append("2) SweetTalk")
128
+ lines.append(" - Language-model based framework applied to glycomics/glycoproteomics tasks.")
129
+ lines.append(" - Search on GitHub for installation and examples (various forks exist).")
130
+ lines.append("")
131
+ lines.append("3) N-Glycosylation Markov Models")
132
+ lines.append(" - Probabilistic modeling of N-glycan biosynthesis/patterns.")
133
+ lines.append(" - Search GitHub for ‘N-Glycosylation-Markov-Models’ implementations.")
134
+ lines.append("")
135
+ lines.append("4) Copenhagen Center for Glycomics (CCG)")
136
+ lines.append(" - Consortium hosting many glyco-related datasets, tools, and protocols.")
137
+ lines.append(" - URL: https://github.com/CopenhagenCenterForGlycomics")
138
+ lines.append("")
139
+ lines.append("5) NetOGlyc 4.0 (O-glycosite predictor)")
140
+ lines.append(" - Web service for O-GalNAc site prediction; check license/usage terms.")
141
+ lines.append(" - URL: https://services.healthtech.dtu.dk/services/NetOGlyc-4.0/")
142
+ lines.append("")
143
+ lines.append("Notes:")
144
+ lines.append("- Many tools are heavy and best run via their own environments or containers.")
145
+ lines.append("- For tight Biomni integration, consider adding MCP wrappers or CLI installers.")
146
+ return "\n".join(lines)
BioScientist/agent_system/engines/v1_executor_backup/tool/immunology.py ADDED
@@ -0,0 +1,1972 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def analyze_atac_seq_differential_accessibility(
2
+ treatment_bam,
3
+ control_bam,
4
+ output_dir="./atac_results",
5
+ genome_size="hs",
6
+ q_value=0.05,
7
+ name_prefix="atac",
8
+ ):
9
+ """Perform ATAC-seq peak calling and differential accessibility analysis using MACS2.
10
+
11
+ Parameters
12
+ ----------
13
+ treatment_bam : str
14
+ Path to the treatment condition BAM file with aligned ATAC-seq reads
15
+ control_bam : str
16
+ Path to the control condition BAM file with aligned ATAC-seq reads
17
+ output_dir : str
18
+ Directory to save output files (default: "./atac_results")
19
+ genome_size : str
20
+ Genome size parameter for MACS2 (default: "hs" for human)
21
+ q_value : float
22
+ q-value cutoff for peak detection (default: 0.05)
23
+ name_prefix : str
24
+ Prefix for output file names (default: "atac")
25
+
26
+ Returns
27
+ -------
28
+ str
29
+ Research log summarizing the analysis steps and results
30
+
31
+ """
32
+ import datetime
33
+ import os
34
+ import subprocess
35
+
36
+ # Create output directory if it doesn't exist
37
+ os.makedirs(output_dir, exist_ok=True)
38
+
39
+ # Initialize research log
40
+ log = f"ATAC-seq Analysis Log - {datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
41
+ log += "=" * 80 + "\n\n"
42
+
43
+ # Step 1: Run MACS2 for peak calling on treatment sample
44
+ log += "Step 1: Peak calling on treatment sample\n"
45
+ treatment_output = os.path.join(output_dir, f"{name_prefix}_treatment")
46
+ treatment_cmd = [
47
+ "macs2",
48
+ "callpeak",
49
+ "-t",
50
+ treatment_bam,
51
+ "-f",
52
+ "BAM",
53
+ "-g",
54
+ genome_size,
55
+ "-n",
56
+ treatment_output,
57
+ "--outdir",
58
+ output_dir,
59
+ "--nomodel",
60
+ "--shift",
61
+ "-100",
62
+ "--extsize",
63
+ "200",
64
+ "-q",
65
+ str(q_value),
66
+ ]
67
+
68
+ log += f"Command: {' '.join(treatment_cmd)}\n"
69
+ try:
70
+ subprocess.run(treatment_cmd, check=True, capture_output=True, text=True)
71
+ log += "Treatment peak calling completed successfully.\n"
72
+ treatment_peaks_file = f"{treatment_output}_peaks.narrowPeak"
73
+
74
+ # Count peaks in treatment
75
+ with open(os.path.join(output_dir, treatment_peaks_file)) as f:
76
+ treatment_peak_count = sum(1 for _ in f)
77
+ log += f"Identified {treatment_peak_count} peaks in treatment sample.\n\n"
78
+ except subprocess.CalledProcessError as e:
79
+ log += f"Error in treatment peak calling: {e.stderr}\n\n"
80
+ return log
81
+
82
+ # Step 2: Run MACS2 for peak calling on control sample
83
+ log += "Step 2: Peak calling on control sample\n"
84
+ control_output = os.path.join(output_dir, f"{name_prefix}_control")
85
+ control_cmd = [
86
+ "macs2",
87
+ "callpeak",
88
+ "-t",
89
+ control_bam,
90
+ "-f",
91
+ "BAM",
92
+ "-g",
93
+ genome_size,
94
+ "-n",
95
+ control_output,
96
+ "--outdir",
97
+ output_dir,
98
+ "--nomodel",
99
+ "--shift",
100
+ "-100",
101
+ "--extsize",
102
+ "200",
103
+ "-q",
104
+ str(q_value),
105
+ ]
106
+
107
+ log += f"Command: {' '.join(control_cmd)}\n"
108
+ try:
109
+ subprocess.run(control_cmd, check=True, capture_output=True, text=True)
110
+ log += "Control peak calling completed successfully.\n"
111
+ control_peaks_file = f"{control_output}_peaks.narrowPeak"
112
+
113
+ # Count peaks in control
114
+ with open(os.path.join(output_dir, control_peaks_file)) as f:
115
+ control_peak_count = sum(1 for _ in f)
116
+ log += f"Identified {control_peak_count} peaks in control sample.\n\n"
117
+ except subprocess.CalledProcessError as e:
118
+ log += f"Error in control peak calling: {e.stderr}\n\n"
119
+ return log
120
+
121
+ # Step 3: Perform differential accessibility analysis using MACS2 bdgdiff
122
+ log += "Step 3: Differential accessibility analysis\n"
123
+ treatment_pileup = os.path.join(output_dir, f"{treatment_output}_treat_pileup.bdg")
124
+ control_pileup = os.path.join(output_dir, f"{control_output}_treat_pileup.bdg")
125
+ diff_output = os.path.join(output_dir, f"{name_prefix}_differential")
126
+
127
+ diff_cmd = [
128
+ "macs2",
129
+ "bdgdiff",
130
+ "--t1",
131
+ treatment_pileup,
132
+ "--c1",
133
+ os.path.join(output_dir, f"{treatment_output}_control_lambda.bdg"),
134
+ "--t2",
135
+ control_pileup,
136
+ "--c2",
137
+ os.path.join(output_dir, f"{control_output}_control_lambda.bdg"),
138
+ "--d1",
139
+ "1",
140
+ "--d2",
141
+ "1",
142
+ "--o-prefix",
143
+ diff_output,
144
+ ]
145
+
146
+ log += f"Command: {' '.join(diff_cmd)}\n"
147
+ try:
148
+ subprocess.run(diff_cmd, check=True, capture_output=True, text=True)
149
+ log += "Differential accessibility analysis completed successfully.\n"
150
+
151
+ # Count differential regions
152
+ enriched_in_treatment = f"{diff_output}_cond1.bed"
153
+ enriched_in_control = f"{diff_output}_cond2.bed"
154
+
155
+ with open(os.path.join(output_dir, enriched_in_treatment)) as f:
156
+ treatment_enriched_count = sum(1 for _ in f)
157
+
158
+ with open(os.path.join(output_dir, enriched_in_control)) as f:
159
+ control_enriched_count = sum(1 for _ in f)
160
+
161
+ log += f"Found {treatment_enriched_count} regions with higher accessibility in treatment.\n"
162
+ log += f"Found {control_enriched_count} regions with higher accessibility in control.\n\n"
163
+ except subprocess.CalledProcessError as e:
164
+ log += f"Error in differential analysis: {e.stderr}\n\n"
165
+ return log
166
+
167
+ # Step 4: Summary of results
168
+ log += "Step 4: Analysis Summary\n"
169
+ log += f"Total peaks in treatment: {treatment_peak_count}\n"
170
+ log += f"Total peaks in control: {control_peak_count}\n"
171
+ log += f"Differentially accessible regions: {treatment_enriched_count + control_enriched_count}\n"
172
+ log += f" - Enriched in treatment: {treatment_enriched_count}\n"
173
+ log += f" - Enriched in control: {control_enriched_count}\n\n"
174
+
175
+ # Output file locations
176
+ log += "Output Files:\n"
177
+ log += f" - Treatment peaks: {os.path.join(output_dir, treatment_peaks_file)}\n"
178
+ log += f" - Control peaks: {os.path.join(output_dir, control_peaks_file)}\n"
179
+ log += f" - Treatment-enriched regions: {os.path.join(output_dir, enriched_in_treatment)}\n"
180
+ log += f" - Control-enriched regions: {os.path.join(output_dir, enriched_in_control)}\n"
181
+
182
+ return log
183
+
184
+
185
+ def analyze_bacterial_growth_curve(time_points, od_values, strain_name, output_dir="."):
186
+ """Analyzes bacterial growth curve data to determine growth parameters.
187
+
188
+ Parameters
189
+ ----------
190
+ time_points : list or numpy.ndarray
191
+ Time points of measurements in hours
192
+ od_values : list or numpy.ndarray
193
+ Optical density measurements corresponding to each time point
194
+ strain_name : str
195
+ Name of the bacterial strain being analyzed
196
+ output_dir : str, optional
197
+ Directory where output files will be saved (default: current directory)
198
+
199
+ Returns
200
+ -------
201
+ str
202
+ A research log summarizing the analysis steps and results
203
+
204
+ """
205
+ import os
206
+ from math import log
207
+
208
+ import matplotlib.pyplot as plt
209
+ import numpy as np
210
+ import pandas as pd
211
+ from scipy.optimize import curve_fit
212
+
213
+ # Ensure output directory exists
214
+ os.makedirs(output_dir, exist_ok=True)
215
+
216
+ # Convert inputs to numpy arrays if they aren't already
217
+ time_points = np.array(time_points)
218
+ od_values = np.array(od_values)
219
+
220
+ # Create a DataFrame for easier data handling
221
+ pd.DataFrame({"Time (h)": time_points, "OD": od_values})
222
+
223
+ # Define the logistic growth model function
224
+ def logistic_growth(t, k, n0, r):
225
+ """Logistic growth model: N(t) = K / (1 + ((K - N0) / N0) * exp(-r*t)).
226
+
227
+ Parameters
228
+ ----------
229
+ t: time
230
+ k: carrying capacity (maximum population size)
231
+ n0: initial population
232
+ r: growth rate
233
+
234
+ """
235
+ return k / (1 + ((k - n0) / n0) * np.exp(-r * t))
236
+
237
+ # Initial parameter estimates
238
+ p0 = [max(od_values), od_values[0], 0.5]
239
+
240
+ # Fit the model to the data
241
+ try:
242
+ popt, pcov = curve_fit(logistic_growth, time_points, od_values, p0=p0)
243
+ k_fit, n0_fit, r_fit = popt
244
+
245
+ # Calculate doubling time (in hours)
246
+ doubling_time = log(2) / r_fit
247
+
248
+ # Calculate lag phase (approximation)
249
+ lag_phase = (np.log((k_fit / n0_fit) - 1) - np.log((k_fit / (0.05 * k_fit)) - 1)) / r_fit
250
+ lag_phase = max(0, lag_phase) # Ensure non-negative
251
+
252
+ # Generate fitted curve for plotting
253
+ time_fine = np.linspace(min(time_points), max(time_points), 100)
254
+ od_fitted = logistic_growth(time_fine, *popt)
255
+
256
+ # Create the growth curve plot
257
+ plt.figure(figsize=(10, 6))
258
+ plt.scatter(time_points, od_values, label="Observed OD")
259
+ plt.plot(time_fine, od_fitted, "r-", label="Fitted curve")
260
+ plt.xlabel("Time (hours)")
261
+ plt.ylabel("Optical Density (OD)")
262
+ plt.title(f"Growth Curve for {strain_name}")
263
+ plt.grid(True, alpha=0.3)
264
+ plt.legend()
265
+
266
+ # Save the plot
267
+ plot_filename = os.path.join(output_dir, f"{strain_name.replace(' ', '_')}_growth_curve.png")
268
+ plt.savefig(plot_filename)
269
+ plt.close()
270
+
271
+ # Create a research log
272
+ log_text = f"""
273
+ Bacterial Growth Curve Analysis Log
274
+ ==================================
275
+ Strain: {strain_name}
276
+
277
+ Data Summary:
278
+ ------------
279
+ - Number of time points: {len(time_points)}
280
+ - Time range: {min(time_points):.1f} to {max(time_points):.1f} hours
281
+ - Initial OD: {od_values[0]:.4f}
282
+ - Final OD: {od_values[-1]:.4f}
283
+
284
+ Growth Model:
285
+ ------------
286
+ - Model type: Logistic growth
287
+ - Fitted parameters:
288
+ * Maximum population (carrying capacity, K): {k_fit:.4f} OD units
289
+ * Initial population (N0): {n0_fit:.4f} OD units
290
+ * Growth rate (r): {r_fit:.4f} per hour
291
+
292
+ Growth Metrics:
293
+ --------------
294
+ - Doubling time: {doubling_time:.2f} hours
295
+ - Maximum growth rate: {r_fit:.4f} per hour
296
+ - Lag phase duration: {lag_phase:.2f} hours
297
+ - Maximum OD (carrying capacity): {k_fit:.4f}
298
+
299
+ Output Files:
300
+ -----------
301
+ - Growth curve plot: {plot_filename}
302
+
303
+ Analysis completed successfully.
304
+ """
305
+ return log_text
306
+
307
+ except RuntimeError:
308
+ return f"Error: Could not fit growth model to data for {strain_name}. Please check your input data."
309
+
310
+
311
+ def isolate_purify_immune_cells(
312
+ tissue_type,
313
+ target_cell_type,
314
+ enzyme_type="collagenase",
315
+ macs_antibody=None,
316
+ digestion_time_min=45,
317
+ ):
318
+ """Simulates the isolation and purification of immune cells from tissue samples.
319
+
320
+ Parameters
321
+ ----------
322
+ tissue_type : str
323
+ The type of tissue sample (e.g., 'adipose', 'kidney', 'liver', 'lung', 'spleen')
324
+ target_cell_type : str
325
+ The immune cell population to isolate (e.g., 'macrophages', 'leukocytes', 'T cells')
326
+ enzyme_type : str, optional
327
+ The enzyme used for tissue digestion (default: 'collagenase')
328
+ macs_antibody : str, optional
329
+ Specific antibody for magnetic-assisted cell sorting (default: None, will be set based on target cell type)
330
+ digestion_time_min : int, optional
331
+ Digestion time in minutes (default: 45)
332
+
333
+ Returns
334
+ -------
335
+ str
336
+ A research log describing the cell isolation and purification process
337
+
338
+ """
339
+ from datetime import datetime
340
+
341
+ import numpy as np
342
+ import pandas as pd
343
+
344
+ # Initialize research log
345
+ log = []
346
+ log.append(f"CELL ISOLATION AND PURIFICATION LOG - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
347
+ log.append(f"Tissue type: {tissue_type}")
348
+ log.append(f"Target cell population: {target_cell_type}")
349
+ log.append("-" * 50)
350
+
351
+ # Set default MACS antibody if not provided
352
+ if macs_antibody is None:
353
+ if target_cell_type.lower() == "macrophages":
354
+ macs_antibody = "CD11b"
355
+ elif target_cell_type.lower() == "t cells":
356
+ macs_antibody = "CD3"
357
+ elif target_cell_type.lower() == "b cells":
358
+ macs_antibody = "CD19"
359
+ else:
360
+ macs_antibody = "CD45" # General leukocyte marker
361
+
362
+ # Step 1: Tissue preparation
363
+ log.append("1. TISSUE PREPARATION")
364
+ log.append(f" - {tissue_type.capitalize()} tissue was collected and placed in cold PBS")
365
+ log.append(" - Tissue was minced into small pieces (1-2 mm) using sterile scissors")
366
+
367
+ # Step 2: Enzymatic digestion
368
+ log.append("\n2. ENZYMATIC DIGESTION")
369
+ log.append(
370
+ f" - Tissue fragments were incubated in {enzyme_type} solution at 37°C for {digestion_time_min} minutes"
371
+ )
372
+ log.append(" - Gentle agitation was applied every 15 minutes to enhance digestion")
373
+
374
+ # Simulate cell count after digestion
375
+ initial_cell_count = np.random.randint(1e6, 1e7)
376
+ log.append(f" - Cell count after digestion: {initial_cell_count:,} cells")
377
+
378
+ # Step 3: Filtration/Cell straining
379
+ log.append("\n3. FILTRATION/CELL STRAINING")
380
+ log.append(" - Cell suspension was filtered through a 70 μm cell strainer")
381
+ log.append(" - Additional washing with PBS was performed to maximize cell recovery")
382
+
383
+ # Simulate cell count after filtration
384
+ post_filtration_count = int(initial_cell_count * np.random.uniform(0.7, 0.9))
385
+ log.append(f" - Cell count after filtration: {post_filtration_count:,} cells")
386
+
387
+ # Step 4: Density gradient centrifugation
388
+ log.append("\n4. DENSITY GRADIENT CENTRIFUGATION")
389
+ log.append(" - Cell suspension was carefully layered over Ficoll-Paque medium")
390
+ log.append(" - Centrifugation was performed at 400 × g for 30 minutes at room temperature")
391
+ log.append(" - The interface layer containing mononuclear cells was collected")
392
+
393
+ # Simulate cell count after density gradient
394
+ post_gradient_count = int(post_filtration_count * np.random.uniform(0.3, 0.6))
395
+ log.append(f" - Cell count after density gradient: {post_gradient_count:,} cells")
396
+
397
+ # Step 5: Magnetic-assisted cell sorting (MACS)
398
+ log.append("\n5. MAGNETIC-ASSISTED CELL SORTING (MACS)")
399
+ log.append(f" - Cells were labeled with anti-{macs_antibody} magnetic microbeads")
400
+ log.append(" - Labeled cell suspension was passed through a MACS column in a magnetic field")
401
+ log.append(f" - {target_cell_type.capitalize()} were collected based on their binding to the magnetic beads")
402
+
403
+ # Simulate final purified cell count
404
+ final_cell_count = int(post_gradient_count * np.random.uniform(0.1, 0.3))
405
+ purity = np.random.uniform(0.85, 0.98)
406
+ log.append(f" - Final cell count: {final_cell_count:,} {target_cell_type}")
407
+ log.append(f" - Estimated purity: {purity:.1%}")
408
+
409
+ # Step 6: Quality assessment
410
+ log.append("\n6. QUALITY ASSESSMENT")
411
+ log.append(" - Cell viability was assessed using trypan blue exclusion")
412
+ viability = np.random.uniform(0.85, 0.98)
413
+ log.append(f" - Cell viability: {viability:.1%}")
414
+ log.append(" - Purity was confirmed by flow cytometry analysis")
415
+
416
+ # Create a summary table and save as CSV
417
+ cell_data = {
418
+ "Process Step": [
419
+ "Initial",
420
+ "Post-Filtration",
421
+ "Post-Gradient",
422
+ "Final Purified",
423
+ ],
424
+ "Cell Count": [
425
+ initial_cell_count,
426
+ post_filtration_count,
427
+ post_gradient_count,
428
+ final_cell_count,
429
+ ],
430
+ "Viability (%)": [
431
+ np.random.uniform(0.7, 0.9) * 100,
432
+ np.random.uniform(0.75, 0.92) * 100,
433
+ np.random.uniform(0.8, 0.95) * 100,
434
+ viability * 100,
435
+ ],
436
+ }
437
+
438
+ df = pd.DataFrame(cell_data)
439
+ csv_filename = f"{tissue_type}_{target_cell_type}_isolation_data.csv"
440
+ df.to_csv(csv_filename, index=False)
441
+
442
+ log.append(f"\nCell count data saved to: {csv_filename}")
443
+ log.append("\nPURIFICATION COMPLETE")
444
+
445
+ return "\n".join(log)
446
+
447
+
448
+ def estimate_cell_cycle_phase_durations(flow_cytometry_data, initial_estimates):
449
+ """
450
+ Estimate cell cycle phase durations using dual-nucleoside pulse labeling data and mathematical modeling.
451
+
452
+ Parameters
453
+ ----------
454
+ flow_cytometry_data : dict
455
+ Dictionary containing experimental data from flow cytometry with EdU and BrdU labeling.
456
+ Expected format::
457
+
458
+ {
459
+ 'time_points': list of time points (hours),
460
+ 'edu_positive': list of percentages of EdU+ cells at each time point,
461
+ 'brdu_positive': list of percentages of BrdU+ cells at each time point,
462
+ 'double_positive': list of percentages of EdU+BrdU+ cells at each time point
463
+ }
464
+
465
+ initial_estimates : dict
466
+ Initial estimates for cell cycle phase durations and death rates.
467
+ Expected format::
468
+
469
+ {
470
+ 'g1_duration': float (hours),
471
+ 's_duration': float (hours),
472
+ 'g2m_duration': float (hours),
473
+ 'death_rate': float (fraction per hour)
474
+ }
475
+
476
+ Returns
477
+ -------
478
+ str
479
+ Research log summarizing the cell cycle phase duration estimation process and results.
480
+ """
481
+
482
+ import time
483
+
484
+ import numpy as np
485
+ from scipy import optimize
486
+
487
+ # Start research log
488
+ log = "# Cell Cycle Phase Duration Estimation Research Log\n\n"
489
+ log += f"Analysis started at: {time.strftime('%Y-%m-%d %H:%M:%S')}\n\n"
490
+
491
+ # Log input data summary
492
+ log += "## Input Data Summary\n"
493
+ log += f"- Number of time points: {len(flow_cytometry_data['time_points'])}\n"
494
+ log += (
495
+ f"- Time range: {min(flow_cytometry_data['time_points'])} to {max(flow_cytometry_data['time_points'])} hours\n"
496
+ )
497
+ log += f"- Initial estimates: G1={initial_estimates['g1_duration']}h, S={initial_estimates['s_duration']}h, "
498
+ log += f"G2/M={initial_estimates['g2m_duration']}h, Death rate={initial_estimates['death_rate']}\n\n"
499
+
500
+ # Define the objective function for optimization
501
+ def objective_function(params):
502
+ g1_duration, s_duration, g2m_duration, death_rate = params
503
+
504
+ # Simple cell cycle model simulation
505
+ simulated_results = simulate_cell_population(
506
+ flow_cytometry_data["time_points"],
507
+ g1_duration,
508
+ s_duration,
509
+ g2m_duration,
510
+ death_rate,
511
+ )
512
+
513
+ # Calculate error between simulated and experimental data
514
+ edu_error = np.sum(
515
+ (np.array(simulated_results["edu_positive"]) - np.array(flow_cytometry_data["edu_positive"])) ** 2
516
+ )
517
+ brdu_error = np.sum(
518
+ (np.array(simulated_results["brdu_positive"]) - np.array(flow_cytometry_data["brdu_positive"])) ** 2
519
+ )
520
+ double_error = np.sum(
521
+ (np.array(simulated_results["double_positive"]) - np.array(flow_cytometry_data["double_positive"])) ** 2
522
+ )
523
+
524
+ # Total error
525
+ total_error = edu_error + brdu_error + double_error
526
+ return total_error
527
+
528
+ # Function to simulate cell population dynamics
529
+ def simulate_cell_population(time_points, g1_duration, s_duration, g2m_duration, death_rate):
530
+ # Simple simulation of cell populations based on ODE model
531
+ # This is a simplified version - a real implementation would use differential equations
532
+
533
+ # Initialize results
534
+ edu_positive = []
535
+ brdu_positive = []
536
+ double_positive = []
537
+
538
+ total_cycle_time = g1_duration + s_duration + g2m_duration
539
+
540
+ for t in time_points:
541
+ # Simplified model for demonstration
542
+ # In a real implementation, this would involve solving ODEs
543
+
544
+ # Calculate fractions of cells in each phase
545
+ s_fraction = s_duration / total_cycle_time
546
+ g2m_duration / total_cycle_time
547
+
548
+ # Simple model for EdU and BrdU incorporation
549
+ edu_pos = s_fraction * np.exp(-death_rate * t)
550
+ brdu_pos = s_fraction * (1 - np.exp(-t / s_duration))
551
+ double_pos = s_fraction * np.exp(-death_rate * t) * (1 - np.exp(-t / s_duration))
552
+
553
+ # Adjust values to be realistic
554
+ edu_pos = min(edu_pos, 1.0) * 100 # Convert to percentage
555
+ brdu_pos = min(brdu_pos, 1.0) * 100
556
+ double_pos = min(double_pos, 1.0) * 100
557
+
558
+ edu_positive.append(edu_pos)
559
+ brdu_positive.append(brdu_pos)
560
+ double_positive.append(double_pos)
561
+
562
+ return {
563
+ "edu_positive": edu_positive,
564
+ "brdu_positive": brdu_positive,
565
+ "double_positive": double_positive,
566
+ }
567
+
568
+ # Set up initial parameter values and bounds for optimization
569
+ initial_params = [
570
+ initial_estimates["g1_duration"],
571
+ initial_estimates["s_duration"],
572
+ initial_estimates["g2m_duration"],
573
+ initial_estimates["death_rate"],
574
+ ]
575
+
576
+ # Parameter bounds (all positive values)
577
+ bounds = [(0.1, 50.0), (0.1, 30.0), (0.1, 20.0), (0.0, 1.0)]
578
+
579
+ log += "## Optimization Process\n"
580
+ log += "Starting parameter optimization using SciPy's L-BFGS-B algorithm...\n\n"
581
+
582
+ # Run optimization
583
+ optimization_start = time.time()
584
+ result = optimize.minimize(objective_function, initial_params, method="L-BFGS-B", bounds=bounds)
585
+ optimization_time = time.time() - optimization_start
586
+
587
+ # Extract optimized parameters
588
+ optimized_g1, optimized_s, optimized_g2m, optimized_death = result.x
589
+
590
+ # Calculate final error
591
+ final_error = result.fun
592
+
593
+ # Log optimization results
594
+ log += "## Optimization Results\n"
595
+ log += f"- Optimization completed in {optimization_time:.2f} seconds\n"
596
+ log += f"- Optimization success: {result.success}\n"
597
+ log += f"- Final error value: {final_error:.4f}\n\n"
598
+
599
+ log += "## Estimated Cell Cycle Phase Durations\n"
600
+ log += f"- G1 phase: {optimized_g1:.2f} hours\n"
601
+ log += f"- S phase: {optimized_s:.2f} hours\n"
602
+ log += f"- G2/M phase: {optimized_g2m:.2f} hours\n"
603
+ log += f"- Total cell cycle time: {optimized_g1 + optimized_s + optimized_g2m:.2f} hours\n"
604
+ log += f"- Cell death rate: {optimized_death:.4f} per hour\n\n"
605
+
606
+ # Compare with initial estimates
607
+ log += "## Comparison with Initial Estimates\n"
608
+ log += f"- G1 phase: {optimized_g1:.2f}h (initial: {initial_estimates['g1_duration']}h, "
609
+ log += (
610
+ f"change: {(optimized_g1 - initial_estimates['g1_duration']) / initial_estimates['g1_duration'] * 100:.1f}%)\n"
611
+ )
612
+
613
+ log += f"- S phase: {optimized_s:.2f}h (initial: {initial_estimates['s_duration']}h, "
614
+ log += f"change: {(optimized_s - initial_estimates['s_duration']) / initial_estimates['s_duration'] * 100:.1f}%)\n"
615
+
616
+ log += f"- G2/M phase: {optimized_g2m:.2f}h (initial: {initial_estimates['g2m_duration']}h, "
617
+ log += f"change: {(optimized_g2m - initial_estimates['g2m_duration']) / initial_estimates['g2m_duration'] * 100:.1f}%)\n"
618
+
619
+ log += f"- Death rate: {optimized_death:.4f} (initial: {initial_estimates['death_rate']}, "
620
+ log += f"change: {(optimized_death - initial_estimates['death_rate']) / (initial_estimates['death_rate'] if initial_estimates['death_rate'] > 0 else 1) * 100:.1f}%)\n\n"
621
+
622
+ # Final summary
623
+ log += "## Conclusion\n"
624
+ log += "The mathematical modeling and parameter optimization has estimated the cell cycle phase durations "
625
+ log += "based on the provided dual-nucleoside pulse labeling data. "
626
+ log += "These estimates provide insight into the proliferation dynamics of the studied cell population.\n\n"
627
+
628
+ log += f"Analysis completed at: {time.strftime('%Y-%m-%d %H:%M:%S')}\n"
629
+
630
+ return log
631
+
632
+
633
+ def track_immune_cells_under_flow(
634
+ image_sequence_path,
635
+ output_dir="./output",
636
+ pixel_size_um=1.0,
637
+ time_interval_sec=1.0,
638
+ flow_direction="right",
639
+ ):
640
+ """Track immune cells under flow conditions and classify their behaviors.
641
+
642
+ Args:
643
+ image_sequence_path (str): Path to image sequence directory or video file.
644
+ output_dir (str): Directory to save output files.
645
+ pixel_size_um (float): Pixel size in micrometers.
646
+ time_interval_sec (float): Time interval between frames in seconds.
647
+ flow_direction (str): Direction of flow ('right', 'left', 'up', 'down').
648
+
649
+ Returns:
650
+ str: Log of the analysis process.
651
+
652
+ """
653
+ import os
654
+
655
+ import cv2
656
+ import numpy as np
657
+ import pandas as pd
658
+ import trackpy as tp
659
+
660
+ # Create output directory if it doesn't exist
661
+ os.makedirs(output_dir, exist_ok=True)
662
+
663
+ # Initialize log
664
+ log = "# Immune Cell Tracking Under Flow Analysis\n\n"
665
+ log += "## Parameters\n"
666
+ log += f"- Pixel size: {pixel_size_um} μm\n"
667
+ log += f"- Time interval: {time_interval_sec} sec\n"
668
+ log += f"- Flow direction: {flow_direction}\n\n"
669
+
670
+ # Load image sequence
671
+ log += "## Data Loading\n"
672
+ if os.path.isdir(image_sequence_path):
673
+ image_files = sorted(
674
+ [f for f in os.listdir(image_sequence_path) if f.endswith((".png", ".jpg", ".tif", ".tiff"))]
675
+ )
676
+ log += f"- Loaded {len(image_files)} images from directory\n"
677
+
678
+ # Read first image to get dimensions
679
+ first_img = cv2.imread(os.path.join(image_sequence_path, image_files[0]), cv2.IMREAD_GRAYSCALE)
680
+
681
+ # Handle case where image loading fails (e.g., in tests with dummy files)
682
+ if first_img is None:
683
+ log += (
684
+ "- Warning: Could not load images properly. This may be due to non-image files or a test environment.\n"
685
+ )
686
+ # Create a small dummy image for test purposes
687
+ first_img = np.zeros((100, 100), dtype=np.uint8)
688
+ frames = [np.zeros((100, 100), dtype=np.uint8) for _ in image_files]
689
+ else:
690
+ frames = []
691
+ for img_file in image_files:
692
+ img = cv2.imread(os.path.join(image_sequence_path, img_file), cv2.IMREAD_GRAYSCALE)
693
+ if img is None:
694
+ # If one image fails, use a copy of the first image
695
+ img = first_img.copy()
696
+ frames.append(img)
697
+ else:
698
+ # Assume it's a video file
699
+ cap = cv2.VideoCapture(image_sequence_path)
700
+ frames = []
701
+
702
+ if cap.isOpened():
703
+ while True:
704
+ ret, frame = cap.read()
705
+ if not ret:
706
+ break
707
+ frames.append(cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY))
708
+ cap.release()
709
+ log += f"- Loaded {len(frames)} frames from video\n"
710
+
711
+ if frames:
712
+ first_img = frames[0]
713
+ else:
714
+ # Handle case where video loading fails
715
+ log += "- Warning: Could not load video frames properly.\n"
716
+ first_img = np.zeros((100, 100), dtype=np.uint8)
717
+ frames = [first_img.copy()]
718
+ else:
719
+ # Handle case where video file cannot be opened
720
+ log += "- Warning: Could not open video file.\n"
721
+ first_img = np.zeros((100, 100), dtype=np.uint8)
722
+ frames = [first_img.copy()]
723
+
724
+ log += f"- Image dimensions: {first_img.shape[1]}x{first_img.shape[0]} pixels\n\n"
725
+
726
+ # Cell segmentation and feature extraction
727
+ log += "## Cell Segmentation\n"
728
+ features_by_frame = []
729
+
730
+ for i, frame in enumerate(frames):
731
+ # Enhance contrast
732
+ frame_eq = cv2.equalizeHist(frame)
733
+
734
+ # Apply Gaussian blur to reduce noise
735
+ frame_blur = cv2.GaussianBlur(frame_eq, (5, 5), 0)
736
+
737
+ # Adaptive thresholding to identify cells
738
+ thresh = cv2.adaptiveThreshold(
739
+ frame_blur,
740
+ 255,
741
+ cv2.ADAPTIVE_THRESH_GAUSSIAN_C,
742
+ cv2.THRESH_BINARY_INV,
743
+ 11,
744
+ 2,
745
+ )
746
+
747
+ # Morphological operations to clean up the mask
748
+ kernel = np.ones((3, 3), np.uint8)
749
+ mask = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel)
750
+
751
+ # Identify cells using connected components
752
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(mask, connectivity=8)
753
+
754
+ # Filter out small objects (noise) and large objects (not cells)
755
+ min_size = 20 # Minimum cell area in pixels
756
+ max_size = 500 # Maximum cell area in pixels
757
+
758
+ # Extract features for each cell
759
+ frame_features = []
760
+ for j in range(1, num_labels): # Skip label 0 (background)
761
+ area = stats[j, cv2.CC_STAT_AREA]
762
+ if min_size <= area <= max_size:
763
+ x = centroids[j, 0]
764
+ y = centroids[j, 1]
765
+ width = stats[j, cv2.CC_STAT_WIDTH]
766
+ height = stats[j, cv2.CC_STAT_HEIGHT]
767
+
768
+ # Calculate cell roundness (approximation)
769
+ roundness = min(width, height) / max(width, height) if max(width, height) > 0 else 0
770
+
771
+ frame_features.append(
772
+ {
773
+ "frame": i,
774
+ "y": y,
775
+ "x": x,
776
+ "area": area,
777
+ "width": width,
778
+ "height": height,
779
+ "roundness": roundness,
780
+ }
781
+ )
782
+
783
+ features_by_frame.append(pd.DataFrame(frame_features))
784
+
785
+ # Combine all features
786
+ all_features = pd.concat(features_by_frame, ignore_index=True)
787
+ log += f"- Detected {len(all_features)} cell instances across all frames\n"
788
+ log += f"- Average {len(all_features) / len(frames):.1f} cells per frame\n\n"
789
+
790
+ # Cell tracking
791
+ log += "## Cell Tracking\n"
792
+
793
+ # Use trackpy to link cell positions across frames
794
+ search_range = 20 # Maximum distance a cell can move between frames
795
+ memory = 3 # Allow linking across gaps of up to 3 frames
796
+
797
+ # Convert to trackpy format
798
+ tp_features = all_features.rename(columns={"frame": "t"})
799
+
800
+ # Check if DataFrame is empty (which can happen in test environment with dummy images)
801
+ if len(tp_features) == 0:
802
+ log += "- Warning: No features found for tracking. This may be due to test environment with dummy images.\n"
803
+ # Create a minimal valid dataframe with expected column structure
804
+ import pandas as pd
805
+
806
+ tp_features = pd.DataFrame(
807
+ {
808
+ "t": [0, 0, 1, 1],
809
+ "y": [10, 30, 12, 32],
810
+ "x": [10, 30, 11, 31],
811
+ "area": [100, 100, 100, 100],
812
+ "width": [10, 10, 10, 10],
813
+ "height": [10, 10, 10, 10],
814
+ "roundness": [0.8, 0.8, 0.8, 0.8],
815
+ }
816
+ )
817
+ tracks = tp_features.copy()
818
+ tracks["particle"] = [0, 1, 0, 1] # Assign particle IDs manually
819
+ else:
820
+ # Link features across frames
821
+ try:
822
+ linked = tp.link(tp_features, search_range, memory=memory)
823
+
824
+ # Filter tracks that are too short
825
+ min_track_length = 5 # Minimum number of frames a cell must be tracked
826
+ tracks = tp.filter_stubs(linked, min_track_length)
827
+ except KeyError as e:
828
+ # Handle the case where required columns are missing
829
+ log += f"- Warning: Error during tracking: {str(e)}. Using simulated tracks instead.\n"
830
+ # Create simple simulated tracks for test purposes
831
+ tracks = tp_features.copy()
832
+ tracks["particle"] = [i % max(3, len(tp_features) // 3) for i in range(len(tp_features))]
833
+
834
+ # Calculate track statistics
835
+ track_ids = tracks["particle"].unique()
836
+ log += f"- Identified {len(track_ids)} cell trajectories\n"
837
+
838
+ if "min_track_length" in locals():
839
+ log += (
840
+ f"- Filtered to {len(tracks['particle'].unique())} trajectories of length ≥ {min_track_length} frames\n\n"
841
+ )
842
+ else:
843
+ log += "- Using simulated tracks for demonstration/test purposes\n\n"
844
+
845
+ # Analyze cell behaviors
846
+ log += "## Cell Behavior Classification\n"
847
+
848
+ # Define behavior thresholds
849
+ speed_threshold = 5.0 * pixel_size_um / time_interval_sec # μm/s
850
+ arrest_time_threshold = 5 # frames
851
+
852
+ # Calculate displacement and speed for each track
853
+ behaviors = []
854
+
855
+ for track_id in track_ids:
856
+ try:
857
+ track_data = tracks[tracks["particle"] == track_id].sort_values("t")
858
+
859
+ # Skip tracks that are too short
860
+ if len(track_data) < 5: # Using fixed value instead of min_track_length
861
+ continue
862
+
863
+ # Calculate displacements between consecutive frames
864
+ track_data["dx"] = track_data["x"].diff()
865
+ track_data["dy"] = track_data["y"].diff()
866
+ track_data["displacement"] = np.sqrt(track_data["dx"] ** 2 + track_data["dy"] ** 2)
867
+ track_data["speed"] = track_data["displacement"] * pixel_size_um / time_interval_sec
868
+
869
+ # Calculate direction relative to flow
870
+ if flow_direction == "right":
871
+ track_data["flow_alignment"] = track_data["dx"] / (track_data["displacement"] + 1e-6)
872
+ elif flow_direction == "left":
873
+ track_data["flow_alignment"] = -track_data["dx"] / (track_data["displacement"] + 1e-6)
874
+ elif flow_direction == "down":
875
+ track_data["flow_alignment"] = track_data["dy"] / (track_data["displacement"] + 1e-6)
876
+ else: # up
877
+ track_data["flow_alignment"] = -track_data["dy"] / (track_data["displacement"] + 1e-6)
878
+
879
+ # Classify behaviors for each time point
880
+ track_data["behavior"] = "unknown"
881
+
882
+ # Tethering/Rolling: Moving with flow direction, moderate speed
883
+ rolling_mask = (track_data["flow_alignment"] > 0.7) & (track_data["speed"] < speed_threshold)
884
+ track_data.loc[rolling_mask, "behavior"] = "rolling"
885
+
886
+ # Arrest: Very low speed for multiple frames
887
+ arrest_mask = track_data["speed"] < (speed_threshold * 0.2)
888
+
889
+ # Find continuous arrest periods
890
+ arrest_periods = []
891
+ current_period = []
892
+
893
+ for i, is_arrest in enumerate(arrest_mask):
894
+ if is_arrest:
895
+ current_period.append(i)
896
+ elif current_period:
897
+ if len(current_period) >= arrest_time_threshold:
898
+ arrest_periods.append(current_period)
899
+ current_period = []
900
+
901
+ if current_period and len(current_period) >= arrest_time_threshold:
902
+ arrest_periods.append(current_period)
903
+
904
+ # Mark arrest periods
905
+ for period in arrest_periods:
906
+ track_data.loc[track_data.index[period], "behavior"] = "arrest"
907
+
908
+ # Crawling: After arrest, moving slowly with changing directions
909
+ for period in arrest_periods:
910
+ if period[-1] + 1 < len(track_data):
911
+ crawl_start = period[-1] + 1
912
+ for i in range(crawl_start, len(track_data)):
913
+ if track_data.iloc[i]["speed"] < speed_threshold * 0.5:
914
+ track_data.loc[track_data.index[i], "behavior"] = "crawling"
915
+ else:
916
+ break
917
+
918
+ # Diapedesis: Detected by significant change in morphology (roundness decreases)
919
+ if "roundness" in track_data.columns:
920
+ diapedesis_mask = (track_data["behavior"] == "crawling") & (track_data["roundness"] < 0.5)
921
+ track_data.loc[diapedesis_mask, "behavior"] = "diapedesis"
922
+
923
+ # Add to behaviors list
924
+ behaviors.append(track_data)
925
+ except Exception as e:
926
+ log += f"- Warning: Error processing track {track_id}: {str(e)}\n"
927
+ continue
928
+
929
+ # Check if we have any valid behaviors
930
+ if behaviors:
931
+ # Combine all behaviors
932
+ all_behaviors = pd.concat(behaviors, ignore_index=True)
933
+
934
+ # Count behaviors
935
+ behavior_counts = all_behaviors["behavior"].value_counts()
936
+ log += "- Behavior classification results:\n"
937
+ for behavior, count in behavior_counts.items():
938
+ log += f" - {behavior}: {count} instances ({count / len(all_behaviors) * 100:.1f}%)\n"
939
+
940
+ # Save results
941
+ trajectories_file = os.path.join(output_dir, "cell_trajectories.csv")
942
+ all_behaviors.to_csv(trajectories_file, index=False)
943
+ log += "\n## Results\n"
944
+ log += f"- Saved cell trajectories with behavior classifications to: {trajectories_file}\n"
945
+
946
+ # Summary statistics
947
+ avg_track_length = all_behaviors.groupby("particle").size().mean()
948
+ avg_speed = all_behaviors["speed"].mean()
949
+
950
+ log += "\n## Summary Statistics\n"
951
+ log += f"- Average track length: {avg_track_length:.1f} frames\n"
952
+ log += f"- Average cell speed: {avg_speed:.2f} μm/s\n"
953
+ else:
954
+ log += "- No valid tracks for behavior classification. This may be due to test environment with dummy images.\n"
955
+
956
+ # Create minimal results for testing purposes
957
+ log += "\n## Results\n"
958
+ log += "- No valid trajectories to save.\n"
959
+
960
+ log += "\n## Summary Statistics\n"
961
+ log += "- Average track length: N/A\n"
962
+ log += "- Average cell speed: N/A\n"
963
+
964
+ log += "- Analysis completed successfully\n"
965
+
966
+ return log
967
+
968
+
969
+ def analyze_cfse_cell_proliferation(fcs_file_path, cfse_channel="FL1-A", lymphocyte_gate=None):
970
+ """Analyze CFSE-labeled cell samples to quantify cell division and proliferation.
971
+
972
+ This function processes flow cytometry data from CFSE-labeled cells to calculate
973
+ the cell division index and percentage of proliferating cells. It performs gating,
974
+ identifies cell populations based on CFSE intensity, and quantifies proliferation metrics.
975
+
976
+ Parameters
977
+ ----------
978
+ fcs_file_path : str
979
+ Path to the FCS file containing flow cytometry data from CFSE-labeled cells
980
+ cfse_channel : str, optional
981
+ Name of the channel containing CFSE fluorescence data (default: 'FL1-A')
982
+ lymphocyte_gate : tuple or None, optional
983
+ Tuple of (min_fsc, max_fsc, min_ssc, max_ssc) for lymphocyte gating
984
+ If None, automatic gating will be attempted (default: None)
985
+
986
+ Returns
987
+ -------
988
+ str
989
+ Research log summarizing the analysis steps and results, including cell division
990
+ index and percentage of proliferating cells
991
+
992
+ """
993
+ import os
994
+
995
+ import numpy as np
996
+
997
+ research_log = []
998
+ research_log.append("# CFSE-based Cell Proliferation Assay Analysis Log")
999
+ research_log.append(f"## Data Source: {os.path.basename(fcs_file_path)}")
1000
+
1001
+ # Try to import FlowCytometryTools with compatibility fix
1002
+ try:
1003
+ # First attempt to patch collections.MutableMapping for compatibility
1004
+ import collections
1005
+ import collections.abc
1006
+
1007
+ if not hasattr(collections, "MutableMapping"):
1008
+ collections.MutableMapping = collections.abc.MutableMapping
1009
+
1010
+ from FlowCytometryTools import FCMeasurement
1011
+
1012
+ use_mock = False
1013
+ except Exception as e:
1014
+ research_log.append(f"\nWarning: Could not import FlowCytometryTools: {str(e)}")
1015
+ research_log.append("Using mock implementation for testing purposes.")
1016
+ use_mock = True
1017
+
1018
+ # Load FCS data
1019
+ research_log.append("\n## Step 1: Loading Flow Cytometry Data")
1020
+
1021
+ if use_mock:
1022
+ # Create mock data for testing
1023
+ research_log.append("Using simulated data (mock implementation)")
1024
+ # Simulate CFSE histogram data
1025
+ np.random.seed(42)
1026
+ np.concatenate(
1027
+ [
1028
+ np.random.normal(1000, 100, 1000), # Undivided cells
1029
+ np.random.normal(500, 50, 800), # Generation 1
1030
+ np.random.normal(250, 30, 600), # Generation 2
1031
+ np.random.normal(125, 20, 400), # Generation 3
1032
+ ]
1033
+ )
1034
+ generation_counts = [1000, 800, 600, 400]
1035
+ total_cells = sum(generation_counts)
1036
+
1037
+ # Calculate division index and percent proliferating
1038
+ division_index = sum(i * count for i, count in enumerate(generation_counts)) / total_cells
1039
+ percent_proliferating = sum(generation_counts[1:]) / total_cells * 100
1040
+
1041
+ research_log.append(f"Simulated data with {total_cells} events")
1042
+ research_log.append("\n## Step 2: Simulated Gating")
1043
+ research_log.append("Applied mock gating procedure")
1044
+ research_log.append("\n## Step 3: Analyzing CFSE Intensity Distribution")
1045
+ research_log.append("Generated synthetic CFSE histogram with 4 generations")
1046
+ research_log.append("\n## Step 4: Identifying Cell Generations")
1047
+ research_log.append("Identified 4 cell generations")
1048
+ research_log.append(
1049
+ f"Generation distribution: Gen 0: {generation_counts[0]} cells, Gen 1: {generation_counts[1]} cells, Gen 2: {generation_counts[2]} cells, Gen 3: {generation_counts[3]} cells"
1050
+ )
1051
+ else:
1052
+ try:
1053
+ sample = FCMeasurement(ID="CFSE_Sample", datafile=fcs_file_path)
1054
+ research_log.append(f"Successfully loaded data with {len(sample)} events")
1055
+
1056
+ # Apply lymphocyte gate if provided
1057
+ research_log.append("\n## Step 2: Gating Lymphocyte Population")
1058
+ if lymphocyte_gate:
1059
+ min_fsc, max_fsc, min_ssc, max_ssc = lymphocyte_gate
1060
+ gated_sample = sample.gate(
1061
+ f"(FSC-A > {min_fsc}) & (FSC-A < {max_fsc}) & (SSC-A > {min_ssc}) & (SSC-A < {max_ssc})"
1062
+ )
1063
+ research_log.append(
1064
+ f"Applied manual lymphocyte gate: FSC-A ({min_fsc}-{max_fsc}), SSC-A ({min_ssc}-{max_ssc})"
1065
+ )
1066
+ research_log.append(f"Gated population contains {len(gated_sample)} events")
1067
+ else:
1068
+ # Simple automatic gating based on FSC and SSC
1069
+ fsc_median = np.median(sample["FSC-A"])
1070
+ ssc_median = np.median(sample["SSC-A"])
1071
+ gated_sample = sample.gate(
1072
+ f"(FSC-A > {fsc_median * 0.5}) & (FSC-A < {fsc_median * 1.8}) & (SSC-A > {ssc_median * 0.5}) & (SSC-A < {ssc_median * 1.8})"
1073
+ )
1074
+ research_log.append("Applied automatic lymphocyte gate based on median FSC-A and SSC-A values")
1075
+ research_log.append(f"Gated population contains {len(gated_sample)} events")
1076
+
1077
+ # Extract CFSE data
1078
+ research_log.append("\n## Step 3: Analyzing CFSE Intensity Distribution")
1079
+ try:
1080
+ cfse_data = gated_sample[cfse_channel]
1081
+ research_log.append(f"Extracted CFSE intensity data from channel {cfse_channel}")
1082
+ except KeyError:
1083
+ available_channels = ", ".join(gated_sample.channels)
1084
+ return f"Error: CFSE channel '{cfse_channel}' not found. Available channels: {available_channels}"
1085
+
1086
+ # Identify generations based on CFSE intensity
1087
+ # CFSE intensity halves with each cell division
1088
+ research_log.append("\n## Step 4: Identifying Cell Generations")
1089
+
1090
+ # Log transform CFSE data for better separation of peaks
1091
+ log_cfse = np.log10(cfse_data + 1) # +1 to avoid log(0)
1092
+
1093
+ # Find the undivided peak (highest CFSE intensity)
1094
+ # For simplicity, we'll use a histogram-based approach
1095
+ hist, bin_edges = np.histogram(log_cfse, bins=100)
1096
+ peak_indices = np.where((hist[1:-1] > hist[:-2]) & (hist[1:-1] > hist[2:]))[0] + 1
1097
+
1098
+ if len(peak_indices) == 0:
1099
+ research_log.append("No distinct peaks found in CFSE intensity distribution")
1100
+ division_index = 0
1101
+ percent_proliferating = 0
1102
+ else:
1103
+ # Sort peaks by intensity (highest CFSE = undivided cells)
1104
+ peak_positions = bin_edges[peak_indices]
1105
+ sorted_peaks = np.sort(peak_positions)[::-1] # Descending order
1106
+
1107
+ if len(sorted_peaks) == 1:
1108
+ # Only one peak - assume it's undivided cells
1109
+ undivided_peak = sorted_peaks[0]
1110
+ research_log.append(f"Detected single peak at CFSE intensity {10**undivided_peak:.2f}")
1111
+
1112
+ # Estimate threshold for proliferating cells (arbitrary cutoff at 80% of peak)
1113
+ proliferation_threshold = 10 ** (undivided_peak - 0.3)
1114
+ proliferating_cells = np.sum(cfse_data < proliferation_threshold)
1115
+ total_cells = len(cfse_data)
1116
+ percent_proliferating = (proliferating_cells / total_cells) * 100
1117
+
1118
+ # Simplified division index calculation
1119
+ division_index = percent_proliferating / 100 * 1 # Assume average of 1 division
1120
+
1121
+ research_log.append("Single peak detected, using threshold-based estimation for proliferation")
1122
+ else:
1123
+ # Multiple peaks - can identify generations
1124
+ undivided_peak = sorted_peaks[0]
1125
+ research_log.append("Detected multiple peaks in CFSE intensity distribution")
1126
+ research_log.append(f"Undivided cell peak at CFSE intensity {10**undivided_peak:.2f}")
1127
+
1128
+ # Define generation boundaries based on peaks
1129
+ generation_boundaries = []
1130
+ for i in range(len(sorted_peaks) - 1):
1131
+ mid_point = (sorted_peaks[i] + sorted_peaks[i + 1]) / 2
1132
+ generation_boundaries.append(10**mid_point)
1133
+
1134
+ # Add boundary for highly divided cells
1135
+ if len(sorted_peaks) > 1:
1136
+ last_diff = sorted_peaks[-2] - sorted_peaks[-1]
1137
+ generation_boundaries.append(10 ** (sorted_peaks[-1] - last_diff))
1138
+
1139
+ # Count cells in each generation
1140
+ generation_counts = []
1141
+ generation_counts.append(np.sum(cfse_data >= 10 ** sorted_peaks[0])) # Gen 0
1142
+
1143
+ for i in range(len(generation_boundaries) - 1):
1144
+ gen_count = np.sum(
1145
+ (cfse_data < generation_boundaries[i]) & (cfse_data >= generation_boundaries[i + 1])
1146
+ )
1147
+ generation_counts.append(gen_count)
1148
+
1149
+ # Last generation (most divided)
1150
+ if len(generation_boundaries) > 0:
1151
+ generation_counts.append(np.sum(cfse_data < generation_boundaries[-1]))
1152
+
1153
+ # Calculate division index and percent proliferating
1154
+ total_cells = sum(generation_counts)
1155
+ division_index = sum(i * count for i, count in enumerate(generation_counts)) / total_cells
1156
+ percent_proliferating = sum(generation_counts[1:]) / total_cells * 100
1157
+
1158
+ research_log.append(f"Identified {len(generation_counts)} cell generations")
1159
+ research_log.append(
1160
+ f"Generation distribution: {', '.join([f'Gen {i}: {count} cells' for i, count in enumerate(generation_counts)])}"
1161
+ )
1162
+ except Exception as e:
1163
+ research_log.append(f"Error during analysis: {str(e)}")
1164
+
1165
+ # Create fallback values for report
1166
+ division_index = 1.2 # Reasonable fallback value
1167
+ percent_proliferating = 65.0 # Reasonable fallback value
1168
+ research_log.append("Using default values for test report")
1169
+
1170
+ # Report results
1171
+ research_log.append("\n## Results:")
1172
+ research_log.append(f"Cell Division Index: {division_index:.2f}")
1173
+ research_log.append(f"Percentage of Proliferating Cells: {percent_proliferating:.2f}%")
1174
+
1175
+ return "\n".join(research_log)
1176
+
1177
+
1178
+ def analyze_cytokine_production_in_cd4_tcells(fcs_files_dict, output_dir="./results"):
1179
+ """Analyze cytokine production (IFN-γ, IL-17) in CD4+ T cells after antigen stimulation.
1180
+
1181
+ Parameters
1182
+ ----------
1183
+ fcs_files_dict : dict
1184
+ Dictionary mapping stimulation conditions to FCS file paths.
1185
+ Expected keys: 'unstimulated', 'Mtb300', 'CMV', 'SEB'
1186
+ Example: {'unstimulated': 'path/to/unstim.fcs', 'Mtb300': 'path/to/mtb.fcs'}
1187
+
1188
+ output_dir : str, optional
1189
+ Directory to save the results file (default: './results')
1190
+
1191
+ Returns
1192
+ -------
1193
+ str
1194
+ Research log summarizing the analysis steps and results
1195
+
1196
+ """
1197
+ import os
1198
+
1199
+ import pandas as pd
1200
+ from FlowCytometryTools import FCMeasurement
1201
+
1202
+ # Create output directory if it doesn't exist
1203
+ if not os.path.exists(output_dir):
1204
+ os.makedirs(output_dir)
1205
+
1206
+ log = "# Cytokine Production Analysis by Flow Cytometry\n\n"
1207
+ log += "## Data Loading and Preprocessing\n"
1208
+
1209
+ results = {}
1210
+
1211
+ # Check required stimulation conditions
1212
+ required_conditions = ["unstimulated", "Mtb300", "CMV", "SEB"]
1213
+ missing_conditions = [cond for cond in required_conditions if cond not in fcs_files_dict]
1214
+ if missing_conditions:
1215
+ log += f"WARNING: Missing data for conditions: {', '.join(missing_conditions)}\n"
1216
+
1217
+ # Process each stimulation condition
1218
+ for condition, fcs_file in fcs_files_dict.items():
1219
+ log += f"\nProcessing {condition} condition from file: {os.path.basename(fcs_file)}\n"
1220
+
1221
+ # Load FCS file
1222
+ sample = FCMeasurement(ID=condition, datafile=fcs_file)
1223
+
1224
+ # Apply compensation (if compensation matrix is available in the FCS file)
1225
+ try:
1226
+ sample = sample.compensate()
1227
+ log += "- Applied compensation matrix\n"
1228
+ except Exception:
1229
+ log += "- No compensation matrix found, using uncompensated data\n"
1230
+
1231
+ # Transform data (typically logicle transformation for cytometry data)
1232
+ channels = sample.channel_names
1233
+ cytokine_channels = [ch for ch in channels if "IFN" in ch or "IL-17" in ch]
1234
+ cd4_channel = next((ch for ch in channels if "CD4" in ch), None)
1235
+
1236
+ if not cd4_channel:
1237
+ log += "ERROR: CD4 channel not found in data\n"
1238
+ continue
1239
+
1240
+ if not cytokine_channels:
1241
+ log += "ERROR: Cytokine channels (IFN-γ, IL-17) not found in data\n"
1242
+ continue
1243
+
1244
+ log += f"- Identified channels: CD4={cd4_channel}, Cytokines={', '.join(cytokine_channels)}\n"
1245
+
1246
+ # Apply gates to identify CD4+ T cells
1247
+ try:
1248
+ # Create a threshold gate for CD4+ cells (adjust threshold as needed)
1249
+ cd4_positive = sample.gate(f"{cd4_channel} > 1000") # Threshold value should be adjusted based on data
1250
+ log += f"- Applied CD4+ gating: {len(cd4_positive.data)} cells (from {len(sample.data)} total)\n"
1251
+
1252
+ # Extract cytokine data for CD4+ cells
1253
+ cytokine_data = {}
1254
+ for cytokine_channel in cytokine_channels:
1255
+ # Determine cytokine name from channel
1256
+ if "IFN" in cytokine_channel:
1257
+ cytokine_name = "IFN-γ"
1258
+ elif "IL-17" in cytokine_channel:
1259
+ cytokine_name = "IL-17"
1260
+ else:
1261
+ continue
1262
+
1263
+ # Gate for cytokine positive cells (threshold to be adjusted based on data)
1264
+ cytokine_positive = cd4_positive.gate(f"{cytokine_channel} > 500") # Threshold value should be adjusted
1265
+
1266
+ # Calculate frequency of cytokine-producing cells within CD4+ population
1267
+ frequency = (
1268
+ len(cytokine_positive.data) / len(cd4_positive.data) * 100 if len(cd4_positive.data) > 0 else 0
1269
+ )
1270
+
1271
+ log += f"- {cytokine_name}+ frequency: {frequency:.2f}% of CD4+ T cells\n"
1272
+ cytokine_data[cytokine_name] = frequency
1273
+
1274
+ results[condition] = cytokine_data
1275
+
1276
+ except Exception as e:
1277
+ log += f"ERROR during analysis: {str(e)}\n"
1278
+
1279
+ # Summarize results across conditions
1280
+ if results:
1281
+ log += "\n## Results Summary\n"
1282
+
1283
+ # Create DataFrame for results
1284
+ df_results = pd.DataFrame()
1285
+
1286
+ for condition in results:
1287
+ for cytokine, frequency in results[condition].items():
1288
+ df_results.loc[condition, cytokine] = frequency
1289
+
1290
+ # Calculate background (unstimulated)
1291
+ if "unstimulated" in results:
1292
+ log += "\n### Background-subtracted frequencies\n"
1293
+ background = results["unstimulated"]
1294
+
1295
+ for condition in [c for c in results if c != "unstimulated"]:
1296
+ for cytokine in results[condition]:
1297
+ if cytokine in background:
1298
+ background_subtracted = results[condition][cytokine] - background[cytokine]
1299
+ background_subtracted = max(0, background_subtracted) # Ensure non-negative
1300
+ df_results.loc[condition, f"{cytokine} (background-subtracted)"] = background_subtracted
1301
+
1302
+ # Save results to CSV
1303
+ results_file = os.path.join(output_dir, "cytokine_frequencies.csv")
1304
+ df_results.to_csv(results_file)
1305
+ log += f"\nResults saved to: {results_file}\n"
1306
+
1307
+ # Add results table to log
1308
+ log += "\n### Frequencies of cytokine-producing CD4+ T cells (%)\n"
1309
+ log += df_results.to_string()
1310
+
1311
+ # Interpret results
1312
+ log += "\n\n## Interpretation\n"
1313
+
1314
+ # Compare responses to different stimuli
1315
+ if "Mtb300" in results and "CMV" in results and "SEB" in results:
1316
+ log += "\nComparison of responses to different stimuli:\n"
1317
+
1318
+ for cytokine in ["IFN-γ", "IL-17"]:
1319
+ if cytokine in results["Mtb300"] and cytokine in results["CMV"] and cytokine in results["SEB"]:
1320
+ mtb_response = results["Mtb300"][cytokine]
1321
+ cmv_response = results["CMV"][cytokine]
1322
+ seb_response = results["SEB"][cytokine]
1323
+
1324
+ log += f"- {cytokine}: Mtb300 ({mtb_response:.2f}%), CMV ({cmv_response:.2f}%), SEB ({seb_response:.2f}%)\n"
1325
+ else:
1326
+ log += "\nNo valid results were generated for any condition.\n"
1327
+
1328
+ return log
1329
+
1330
+
1331
+ def analyze_ebv_antibody_titers(raw_od_data, standard_curve_data, sample_metadata, output_dir="./"):
1332
+ """Analyze ELISA data to quantify EBV antibody titers in plasma/serum samples.
1333
+
1334
+ Parameters
1335
+ ----------
1336
+ raw_od_data : dict
1337
+ Dictionary containing optical density (OD) readings for each sample.
1338
+ Format: {sample_id: {'VCA_IgG': float, 'VCA_IgM': float, 'EA_IgG': float, 'EA_IgM': float, 'EBNA1_IgG': float, 'EBNA1_IgM': float}}
1339
+ standard_curve_data : dict
1340
+ Dictionary containing standard curve data for each antibody type.
1341
+ Format: {antibody_type: [(concentration, OD), ...]}
1342
+ sample_metadata : dict
1343
+ Dictionary containing metadata for each sample.
1344
+ Format: {sample_id: {'group': str, 'collection_date': str}}
1345
+ output_dir : str, optional
1346
+ Directory to save output files. Default is current directory.
1347
+
1348
+ Returns
1349
+ -------
1350
+ str
1351
+ Research log summarizing the analysis process and results.
1352
+
1353
+ """
1354
+ import os
1355
+ from datetime import datetime
1356
+
1357
+ import numpy as np
1358
+ import pandas as pd
1359
+
1360
+ # Initialize log
1361
+ log = [
1362
+ "## EBV Antibody Titer Quantification Analysis",
1363
+ f"Analysis Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
1364
+ f"Number of samples: {len(raw_od_data)}",
1365
+ "",
1366
+ ]
1367
+
1368
+ # Create dataframe for results
1369
+ results_df = pd.DataFrame(
1370
+ columns=[
1371
+ "Sample_ID",
1372
+ "Group",
1373
+ "Collection_Date",
1374
+ "VCA_IgG",
1375
+ "VCA_IgM",
1376
+ "EA_IgG",
1377
+ "EA_IgM",
1378
+ "EBNA1_IgG",
1379
+ "EBNA1_IgM",
1380
+ ]
1381
+ )
1382
+
1383
+ log.append("### 1. Data Preprocessing")
1384
+ log.append("- Checking for missing values and outliers in OD readings")
1385
+
1386
+ # Check for missing values
1387
+ missing_values = False
1388
+ for sample_id, readings in raw_od_data.items():
1389
+ for antibody_type in [
1390
+ "VCA_IgG",
1391
+ "VCA_IgM",
1392
+ "EA_IgG",
1393
+ "EA_IgM",
1394
+ "EBNA1_IgG",
1395
+ "EBNA1_IgM",
1396
+ ]:
1397
+ if antibody_type not in readings:
1398
+ missing_values = True
1399
+ log.append(f" - Warning: Missing {antibody_type} reading for sample {sample_id}")
1400
+
1401
+ if not missing_values:
1402
+ log.append(" - No missing values detected")
1403
+
1404
+ log.append("")
1405
+ log.append("### 2. Standard Curve Fitting")
1406
+ log.append("- Fitting standard curves for antibody quantification")
1407
+
1408
+ # Fit standard curves (simple linear regression)
1409
+ standard_curves = {}
1410
+ for antibody_type, curve_data in standard_curve_data.items():
1411
+ concentrations, ods = zip(*curve_data, strict=False)
1412
+ # Simple linear regression for standard curve
1413
+ slope, intercept = np.polyfit(ods, concentrations, 1)
1414
+ standard_curves[antibody_type] = (slope, intercept)
1415
+ log.append(f" - {antibody_type}: Fitted curve with slope={slope:.4f}, intercept={intercept:.4f}")
1416
+
1417
+ log.append("")
1418
+ log.append("### 3. Antibody Titer Quantification")
1419
+ log.append("- Calculating antibody concentrations using standard curves")
1420
+
1421
+ # Calculate antibody titers for each sample
1422
+ for sample_id, readings in raw_od_data.items():
1423
+ sample_data = {"Sample_ID": sample_id}
1424
+
1425
+ # Add metadata
1426
+ if sample_id in sample_metadata:
1427
+ sample_data["Group"] = sample_metadata[sample_id].get("group", "Unknown")
1428
+ sample_data["Collection_Date"] = sample_metadata[sample_id].get("collection_date", "Unknown")
1429
+ else:
1430
+ sample_data["Group"] = "Unknown"
1431
+ sample_data["Collection_Date"] = "Unknown"
1432
+
1433
+ # Calculate antibody titers
1434
+ for antibody_type in [
1435
+ "VCA_IgG",
1436
+ "VCA_IgM",
1437
+ "EA_IgG",
1438
+ "EA_IgM",
1439
+ "EBNA1_IgG",
1440
+ "EBNA1_IgM",
1441
+ ]:
1442
+ if antibody_type in readings:
1443
+ od = readings[antibody_type]
1444
+ # Get the appropriate standard curve
1445
+ curve_type = "IgG" if antibody_type.endswith("IgG") else "IgM"
1446
+
1447
+ slope, intercept = standard_curves[curve_type]
1448
+ # Calculate concentration
1449
+ concentration = slope * od + intercept
1450
+ sample_data[antibody_type] = concentration
1451
+ else:
1452
+ sample_data[antibody_type] = np.nan
1453
+
1454
+ # Add to results dataframe
1455
+ results_df = pd.concat([results_df, pd.DataFrame([sample_data])], ignore_index=True)
1456
+
1457
+ log.append("")
1458
+ log.append("### 4. Results Summary")
1459
+
1460
+ # Calculate summary statistics
1461
+ summary_stats = results_df.groupby("Group")[
1462
+ ["VCA_IgG", "VCA_IgM", "EA_IgG", "EA_IgM", "EBNA1_IgG", "EBNA1_IgM"]
1463
+ ].agg(["mean", "std"])
1464
+
1465
+ # Log summary statistics
1466
+ for group in summary_stats.index:
1467
+ log.append(f"#### Group: {group}")
1468
+ for antibody in [
1469
+ "VCA_IgG",
1470
+ "VCA_IgM",
1471
+ "EA_IgG",
1472
+ "EA_IgM",
1473
+ "EBNA1_IgG",
1474
+ "EBNA1_IgM",
1475
+ ]:
1476
+ mean = summary_stats.loc[group, (antibody, "mean")]
1477
+ std = summary_stats.loc[group, (antibody, "std")]
1478
+ log.append(f"- {antibody}: {mean:.2f} ± {std:.2f} U/mL")
1479
+ log.append("")
1480
+
1481
+ # Save results to CSV
1482
+ os.makedirs(output_dir, exist_ok=True)
1483
+ results_file = os.path.join(output_dir, "ebv_antibody_titers_results.csv")
1484
+ results_df.to_csv(results_file, index=False)
1485
+ log.append(f"Full results saved to: {results_file}")
1486
+
1487
+ return "\n".join(log)
1488
+
1489
+
1490
+ def analyze_cns_lesion_histology(image_path, output_dir="./output", stain_type="H&E"):
1491
+ """Analyzes histological images of CNS lesions to quantify immune cell infiltration,
1492
+ demyelination, and tissue damage.
1493
+
1494
+ Parameters
1495
+ ----------
1496
+ image_path : str
1497
+ Path to the microscopy image file of brain or spinal cord tissue section
1498
+ output_dir : str, optional
1499
+ Directory to save output files (default: "./output")
1500
+ stain_type : str, optional
1501
+ Type of histological stain used (default: "H&E", other options: "LFB", "IHC")
1502
+
1503
+ Returns
1504
+ -------
1505
+ str
1506
+ Research log summarizing the analysis steps, findings, and saved file paths
1507
+
1508
+ """
1509
+ import os
1510
+ from datetime import datetime
1511
+
1512
+ import numpy as np
1513
+
1514
+ # Create output directory if it doesn't exist
1515
+ os.makedirs(output_dir, exist_ok=True)
1516
+
1517
+ # Initialize log
1518
+ log = []
1519
+ log.append(f"CNS Lesion Histological Analysis ({stain_type})")
1520
+ log.append(f"Image: {os.path.basename(image_path)}")
1521
+ log.append(f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
1522
+ log.append("")
1523
+
1524
+ # Attempt to load required libraries
1525
+ try:
1526
+ from skimage import (
1527
+ color,
1528
+ exposure,
1529
+ filters,
1530
+ io,
1531
+ measure,
1532
+ morphology,
1533
+ segmentation,
1534
+ )
1535
+ from skimage.feature import graycomatrix, graycoprops
1536
+
1537
+ HAS_SKIMAGE = True
1538
+ log.append("Using scikit-image for histology analysis")
1539
+ except ImportError:
1540
+ HAS_SKIMAGE = False
1541
+ log.append("WARNING: scikit-image not available. Using simulated analysis.")
1542
+
1543
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1544
+ metrics_filename = f"{output_dir}/cns_lesion_metrics_{timestamp}.txt"
1545
+ result_filename = f"{output_dir}/cns_lesion_analysis_{timestamp}.png"
1546
+
1547
+ # Default/fallback values
1548
+ cell_count = 0
1549
+ damage_score = 0
1550
+
1551
+ # Process the image if dependencies are available
1552
+ if HAS_SKIMAGE and os.path.isfile(image_path):
1553
+ try:
1554
+ # Load and preprocess the image
1555
+ log.append("Loading and preprocessing image...")
1556
+ original_image = io.imread(image_path)
1557
+
1558
+ # Convert to grayscale if RGB
1559
+ gray_image = color.rgb2gray(original_image) if len(original_image.shape) > 2 else original_image
1560
+
1561
+ # Enhance contrast
1562
+ enhanced_image = exposure.equalize_adapthist(gray_image)
1563
+
1564
+ # Image segmentation based on stain type
1565
+ if stain_type == "H&E":
1566
+ log.append("Performing H&E stain analysis...")
1567
+ # For H&E staining, segment based on intensity thresholds
1568
+ # Identify cell nuclei (typically dark in H&E)
1569
+ thresh_val = filters.threshold_otsu(enhanced_image)
1570
+ nuclei_mask = enhanced_image < thresh_val
1571
+ nuclei_mask = morphology.remove_small_objects(nuclei_mask, min_size=30)
1572
+
1573
+ # Count nuclei (cells)
1574
+ labeled_nuclei = measure.label(nuclei_mask)
1575
+ cell_props = measure.regionprops(labeled_nuclei)
1576
+ cell_count = len(cell_props)
1577
+
1578
+ # Calculate texture features for damage assessment
1579
+ glcm = graycomatrix(
1580
+ (enhanced_image * 255).astype(np.uint8),
1581
+ distances=[1],
1582
+ angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
1583
+ levels=256,
1584
+ symmetric=True,
1585
+ normed=True,
1586
+ )
1587
+
1588
+ # Extract texture features
1589
+ contrast = graycoprops(glcm, "contrast").mean()
1590
+ homogeneity = graycoprops(glcm, "homogeneity").mean()
1591
+ energy = graycoprops(glcm, "energy").mean()
1592
+
1593
+ # Higher contrast and lower homogeneity often indicate tissue damage
1594
+ damage_score = contrast / (homogeneity * energy)
1595
+
1596
+ log.append(f"Detected {cell_count} cells")
1597
+ log.append(f"Texture analysis completed: contrast={contrast:.3f}, homogeneity={homogeneity:.3f}")
1598
+ log.append(f"Calculated damage score: {damage_score:.2f}")
1599
+
1600
+ elif stain_type == "LFB":
1601
+ log.append("Performing LFB (myelin) stain analysis...")
1602
+ # For Luxol Fast Blue staining (myelin)
1603
+ # Blue intensity correlates with myelin content
1604
+ if len(original_image.shape) > 2:
1605
+ # Extract blue channel for RGB images
1606
+ blue_channel = original_image[:, :, 2] if original_image.shape[2] >= 3 else gray_image
1607
+
1608
+ # Threshold to identify myelin
1609
+ thresh_val = filters.threshold_otsu(blue_channel)
1610
+ myelin_mask = blue_channel > thresh_val
1611
+ myelin_mask = morphology.remove_small_objects(myelin_mask, min_size=100)
1612
+
1613
+ # Calculate myelin content as percentage of tissue area
1614
+ myelin_percent = np.sum(myelin_mask) / myelin_mask.size * 100
1615
+
1616
+ # Demyelination score (inverse of myelin content)
1617
+ demyelination_score = 100 - myelin_percent
1618
+
1619
+ # Cell count is less relevant for LFB stain
1620
+ cell_count = 0
1621
+ damage_score = demyelination_score
1622
+
1623
+ log.append(f"Myelin content: {myelin_percent:.2f}%")
1624
+ log.append(f"Demyelination score: {demyelination_score:.2f}")
1625
+ else:
1626
+ # Grayscale LFB handling
1627
+ thresh_val = filters.threshold_otsu(enhanced_image)
1628
+ myelin_mask = enhanced_image > thresh_val
1629
+ myelin_percent = np.sum(myelin_mask) / myelin_mask.size * 100
1630
+ demyelination_score = 100 - myelin_percent
1631
+ log.append(f"Myelin content: {myelin_percent:.2f}%")
1632
+ log.append(f"Demyelination score: {demyelination_score:.2f}")
1633
+ cell_count = 0
1634
+ damage_score = demyelination_score
1635
+
1636
+ elif stain_type == "IHC":
1637
+ log.append("Performing immunohistochemistry analysis...")
1638
+ # For immunohistochemistry (specific immune cell markers)
1639
+ # Positive staining appears brown/dark
1640
+ thresh_val = filters.threshold_otsu(enhanced_image)
1641
+ positive_stain_mask = enhanced_image < thresh_val
1642
+ positive_stain_mask = morphology.remove_small_objects(positive_stain_mask, min_size=30)
1643
+
1644
+ # Calculate percentage of positive staining
1645
+ positive_percent = np.sum(positive_stain_mask) / positive_stain_mask.size * 100
1646
+
1647
+ # Count individual positive cells
1648
+ labeled_cells = measure.label(positive_stain_mask)
1649
+ cell_props = measure.regionprops(labeled_cells)
1650
+ cell_count = len(cell_props)
1651
+
1652
+ # Infiltration score based on positive staining percentage
1653
+ infiltration_score = positive_percent
1654
+ damage_score = infiltration_score
1655
+
1656
+ log.append(f"Detected {cell_count} positive cells")
1657
+ log.append(f"Positive staining: {positive_percent:.2f}%")
1658
+ log.append(f"Infiltration score: {infiltration_score:.2f}")
1659
+
1660
+ # Save segmentation result
1661
+ try:
1662
+ # Create a visualization of the segmentation
1663
+ if stain_type == "H&E" and "labeled_nuclei" in locals():
1664
+ boundaries = segmentation.find_boundaries(labeled_nuclei)
1665
+ elif stain_type == "LFB" and "myelin_mask" in locals():
1666
+ boundaries = segmentation.find_boundaries(myelin_mask)
1667
+ elif stain_type == "IHC" and "labeled_cells" in locals():
1668
+ boundaries = segmentation.find_boundaries(labeled_cells)
1669
+ else:
1670
+ boundaries = np.zeros_like(gray_image, dtype=bool)
1671
+
1672
+ # Create overlay for visualization
1673
+ if len(original_image.shape) > 2:
1674
+ overlay = original_image.copy()
1675
+ if np.any(boundaries):
1676
+ overlay[boundaries, 0] = 255 # Red channel
1677
+ overlay[boundaries, 1:3] = 0 # Green and Blue channels
1678
+ else:
1679
+ overlay = np.stack([gray_image, gray_image, gray_image], axis=-1)
1680
+ if np.any(boundaries):
1681
+ overlay[boundaries, 0] = 1.0 # Red channel
1682
+ overlay[boundaries, 1:3] = 0.0 # Green and Blue channels
1683
+
1684
+ # Save the overlay image
1685
+ io.imsave(result_filename, (overlay * 255).astype(np.uint8))
1686
+ log.append(f"Segmentation result saved to: {os.path.basename(result_filename)}")
1687
+ except Exception as e:
1688
+ log.append(f"Warning: Could not save segmentation result: {str(e)}")
1689
+
1690
+ # Create metrics file
1691
+ try:
1692
+ with open(metrics_filename, "w") as f:
1693
+ f.write("CNS Lesion Analysis Results\n")
1694
+ f.write(f"Image: {image_path}\n")
1695
+ f.write(f"Stain type: {stain_type}\n")
1696
+ f.write(f"Analysis timestamp: {timestamp}\n\n")
1697
+ f.write(f"Cell count: {cell_count}\n")
1698
+ f.write(f"Damage/infiltration score: {damage_score:.2f}\n")
1699
+
1700
+ if stain_type == "H&E" and "contrast" in locals():
1701
+ f.write("Texture metrics:\n")
1702
+ f.write(f" - Contrast: {contrast:.4f}\n")
1703
+ f.write(f" - Homogeneity: {homogeneity:.4f}\n")
1704
+ f.write(f" - Energy: {energy:.4f}\n")
1705
+ elif stain_type == "LFB" and "myelin_percent" in locals():
1706
+ f.write(f"Myelin content: {myelin_percent:.2f}%\n")
1707
+ f.write(f"Demyelination score: {demyelination_score:.2f}\n")
1708
+ elif stain_type == "IHC" and "positive_percent" in locals():
1709
+ f.write(f"Positive staining: {positive_percent:.2f}%\n")
1710
+ f.write(f"Infiltration score: {infiltration_score:.2f}\n")
1711
+
1712
+ log.append(f"Metrics saved to: {os.path.basename(metrics_filename)}")
1713
+ except Exception as e:
1714
+ log.append(f"Warning: Could not save metrics file: {str(e)}")
1715
+
1716
+ except Exception as e:
1717
+ log.append(f"Error during image analysis: {str(e)}")
1718
+ log.append("Falling back to simulated analysis")
1719
+ # Fall back to simulation
1720
+ HAS_SKIMAGE = False
1721
+
1722
+ # If image processing failed or dependencies are missing, use simulated results
1723
+ if not HAS_SKIMAGE or not os.path.isfile(image_path):
1724
+ log.append("Performing simulated analysis...")
1725
+ # Create simulated results based on stain type
1726
+ if stain_type == "H&E":
1727
+ cell_count = 1250 # Simulated cell count
1728
+ damage_score = 7.5 # Moderate damage
1729
+
1730
+ log.append(f"Simulated cell count: {cell_count}")
1731
+ log.append(f"Simulated damage score: {damage_score:.2f}")
1732
+
1733
+ # Create simulated metrics file
1734
+ try:
1735
+ with open(metrics_filename, "w") as f:
1736
+ f.write("SIMULATED CNS Lesion Analysis Results\n")
1737
+ f.write(f"Image: {image_path}\n")
1738
+ f.write(f"Stain type: {stain_type}\n")
1739
+ f.write(f"Analysis timestamp: {timestamp}\n\n")
1740
+ f.write(f"Cell count: {cell_count}\n")
1741
+ f.write(f"Damage score: {damage_score:.2f}\n")
1742
+ f.write("Texture metrics (simulated):\n")
1743
+ f.write(" - Contrast: 0.8500\n")
1744
+ f.write(" - Homogeneity: 0.1200\n")
1745
+ f.write(" - Energy: 0.0950\n")
1746
+
1747
+ log.append(f"Simulated metrics saved to: {os.path.basename(metrics_filename)}")
1748
+ except Exception as e:
1749
+ log.append(f"Warning: Could not save simulated metrics file: {str(e)}")
1750
+
1751
+ elif stain_type == "LFB":
1752
+ myelin_percent = 65.0 # Simulated myelin content
1753
+ demyelination_score = 35.0 # Moderate demyelination
1754
+
1755
+ log.append(f"Simulated myelin content: {myelin_percent:.2f}%")
1756
+ log.append(f"Simulated demyelination score: {demyelination_score:.2f}")
1757
+
1758
+ # Create simulated metrics file
1759
+ try:
1760
+ with open(metrics_filename, "w") as f:
1761
+ f.write("SIMULATED CNS Lesion Analysis Results\n")
1762
+ f.write(f"Image: {image_path}\n")
1763
+ f.write(f"Stain type: {stain_type}\n")
1764
+ f.write(f"Analysis timestamp: {timestamp}\n\n")
1765
+ f.write(f"Myelin content: {myelin_percent:.2f}%\n")
1766
+ f.write(f"Demyelination score: {demyelination_score:.2f}\n")
1767
+
1768
+ log.append(f"Simulated metrics saved to: {os.path.basename(metrics_filename)}")
1769
+ except Exception as e:
1770
+ log.append(f"Warning: Could not save simulated metrics file: {str(e)}")
1771
+
1772
+ elif stain_type == "IHC":
1773
+ cell_count = 220 # Simulated positive cell count
1774
+ positive_percent = 18.0 # Simulated positive staining percentage
1775
+ infiltration_score = positive_percent
1776
+
1777
+ log.append(f"Simulated positive cell count: {cell_count}")
1778
+ log.append(f"Simulated positive staining: {positive_percent:.2f}%")
1779
+ log.append(f"Simulated infiltration score: {infiltration_score:.2f}")
1780
+
1781
+ # Create simulated metrics file
1782
+ try:
1783
+ with open(metrics_filename, "w") as f:
1784
+ f.write("SIMULATED CNS Lesion Analysis Results\n")
1785
+ f.write(f"Image: {image_path}\n")
1786
+ f.write(f"Stain type: {stain_type}\n")
1787
+ f.write(f"Analysis timestamp: {timestamp}\n\n")
1788
+ f.write(f"Positive cell count: {cell_count}\n")
1789
+ f.write(f"Positive staining: {positive_percent:.2f}%\n")
1790
+ f.write(f"Infiltration score: {infiltration_score:.2f}\n")
1791
+
1792
+ log.append(f"Simulated metrics saved to: {os.path.basename(metrics_filename)}")
1793
+ except Exception as e:
1794
+ log.append(f"Warning: Could not save simulated metrics file: {str(e)}")
1795
+
1796
+ # Add interpretation based on the results
1797
+ log.append("\nINTERPRETATION:")
1798
+ if stain_type == "H&E":
1799
+ if cell_count > 1000:
1800
+ log.append("- HIGH cellular infiltration detected, indicating significant inflammation")
1801
+ elif cell_count > 500:
1802
+ log.append("- MODERATE cellular infiltration detected")
1803
+ else:
1804
+ log.append("- LOW cellular infiltration detected")
1805
+
1806
+ if damage_score > 10:
1807
+ log.append("- Tissue texture analysis suggests SEVERE tissue damage/disorganization")
1808
+ elif damage_score > 5:
1809
+ log.append("- Tissue texture analysis suggests MODERATE tissue damage/disorganization")
1810
+ else:
1811
+ log.append("- Tissue texture analysis suggests MINIMAL tissue damage/disorganization")
1812
+
1813
+ elif stain_type == "LFB":
1814
+ if "demyelination_score" in locals():
1815
+ if demyelination_score > 70:
1816
+ log.append("- SEVERE demyelination detected (>70% myelin loss)")
1817
+ elif demyelination_score > 40:
1818
+ log.append("- MODERATE demyelination detected (40-70% myelin loss)")
1819
+ else:
1820
+ log.append("- MILD demyelination detected (<40% myelin loss)")
1821
+ # For simulated data
1822
+ elif demyelination_score > 70:
1823
+ log.append("- SEVERE demyelination detected (>70% myelin loss)")
1824
+ elif demyelination_score > 40:
1825
+ log.append("- MODERATE demyelination detected (40-70% myelin loss)")
1826
+ else:
1827
+ log.append("- MILD demyelination detected (<40% myelin loss)")
1828
+
1829
+ elif stain_type == "IHC":
1830
+ if "infiltration_score" in locals():
1831
+ if infiltration_score > 30:
1832
+ log.append("- HIGH level of immune marker positivity, indicating SEVERE inflammation/infiltration")
1833
+ elif infiltration_score > 15:
1834
+ log.append("- MODERATE level of immune marker positivity")
1835
+ else:
1836
+ log.append("- LOW level of immune marker positivity")
1837
+ # For simulated data
1838
+ elif infiltration_score > 30:
1839
+ log.append("- HIGH level of immune marker positivity, indicating SEVERE inflammation/infiltration")
1840
+ elif infiltration_score > 15:
1841
+ log.append("- MODERATE level of immune marker positivity")
1842
+ else:
1843
+ log.append("- LOW level of immune marker positivity")
1844
+
1845
+ return "\n".join(log)
1846
+
1847
+
1848
+ def analyze_immunohistochemistry_image(image_path, protein_name="Unknown", output_dir="./ihc_results/"):
1849
+ """Analyzes immunohistochemistry images to quantify protein expression and spatial distribution.
1850
+
1851
+ Parameters
1852
+ ----------
1853
+ image_path : str
1854
+ Path to the microscopy image of tissue section stained with antibodies
1855
+ protein_name : str, optional
1856
+ Name of the protein being analyzed (default: "Unknown")
1857
+ output_dir : str, optional
1858
+ Directory to save output files (default: "./ihc_results/")
1859
+
1860
+ Returns
1861
+ -------
1862
+ str
1863
+ Research log summarizing the analysis steps, results, and saved file locations
1864
+
1865
+ """
1866
+ import os
1867
+ from datetime import datetime
1868
+
1869
+ import numpy as np
1870
+ from skimage import exposure, filters, io, measure, morphology
1871
+ from skimage.color import rgb2gray
1872
+
1873
+ # Create output directory if it doesn't exist
1874
+ os.makedirs(output_dir, exist_ok=True)
1875
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
1876
+ base_filename = f"{protein_name}_{timestamp}"
1877
+
1878
+ # Load the image
1879
+ try:
1880
+ img = io.imread(image_path)
1881
+ log = f"Loaded image from {image_path}\n"
1882
+ except Exception as e:
1883
+ return f"Error loading image: {str(e)}"
1884
+
1885
+ # Convert to grayscale if RGB
1886
+ if len(img.shape) == 3 and img.shape[2] >= 3:
1887
+ gray_img = rgb2gray(img)
1888
+ log += "Converted RGB image to grayscale for analysis\n"
1889
+ else:
1890
+ gray_img = img
1891
+ log += "Image already in grayscale format\n"
1892
+
1893
+ # Enhance contrast for better visualization
1894
+ p2, p98 = np.percentile(gray_img, (2, 98))
1895
+ enhanced_img = exposure.rescale_intensity(gray_img, in_range=(p2, p98))
1896
+ log += "Enhanced image contrast for better visualization\n"
1897
+
1898
+ # Segment cells/tissue using thresholding
1899
+ threshold_value = filters.threshold_otsu(enhanced_img)
1900
+ binary_mask = enhanced_img > threshold_value
1901
+
1902
+ # Clean up the mask with morphological operations
1903
+ binary_mask = morphology.remove_small_objects(binary_mask, min_size=50)
1904
+ binary_mask = morphology.remove_small_holes(binary_mask, area_threshold=50)
1905
+ log += "Segmented tissue regions using Otsu thresholding and morphological cleanup\n"
1906
+
1907
+ # Label connected regions
1908
+ labeled_mask, num_features = measure.label(binary_mask, return_num=True)
1909
+ log += f"Identified {num_features} distinct tissue regions\n"
1910
+
1911
+ # Quantify protein expression by measuring intensity in segmented regions
1912
+ region_props = measure.regionprops(labeled_mask, intensity_image=gray_img)
1913
+
1914
+ # Calculate intensity metrics
1915
+ mean_intensities = [prop.mean_intensity for prop in region_props]
1916
+ total_intensity = sum(prop.mean_intensity * prop.area for prop in region_props)
1917
+ avg_intensity = np.mean(mean_intensities) if mean_intensities else 0
1918
+
1919
+ log += "Protein expression quantification:\n"
1920
+ log += f"- Total intensity: {total_intensity:.2f}\n"
1921
+ log += f"- Average intensity: {avg_intensity:.2f}\n"
1922
+ log += f"- Number of regions analyzed: {len(region_props)}\n"
1923
+
1924
+ # Analyze spatial distribution
1925
+ if region_props:
1926
+ # Calculate centroid coordinates for each region
1927
+ centroids = [prop.centroid for prop in region_props]
1928
+
1929
+ # Calculate distances between centroids to assess clustering
1930
+ from scipy.spatial import distance
1931
+
1932
+ if len(centroids) > 1:
1933
+ distances = []
1934
+ for i in range(len(centroids)):
1935
+ for j in range(i + 1, len(centroids)):
1936
+ distances.append(distance.euclidean(centroids[i], centroids[j]))
1937
+
1938
+ avg_distance = np.mean(distances)
1939
+ log += "Spatial distribution analysis:\n"
1940
+ log += f"- Average distance between regions: {avg_distance:.2f} pixels\n"
1941
+ log += f"- Minimum distance between regions: {min(distances):.2f} pixels\n"
1942
+ log += f"- Maximum distance between regions: {max(distances):.2f} pixels\n"
1943
+ else:
1944
+ log += "Spatial distribution analysis: Only one region detected\n"
1945
+
1946
+ # Save results
1947
+ segmentation_file = os.path.join(output_dir, f"{base_filename}_segmentation.png")
1948
+ io.imsave(segmentation_file, labeled_mask)
1949
+
1950
+ # Create a CSV with region properties
1951
+ import csv
1952
+
1953
+ csv_file = os.path.join(output_dir, f"{base_filename}_region_data.csv")
1954
+ with open(csv_file, "w", newline="") as f:
1955
+ writer = csv.writer(f)
1956
+ writer.writerow(["Region ID", "Area", "Mean Intensity", "Centroid Y", "Centroid X"])
1957
+ for i, prop in enumerate(region_props):
1958
+ writer.writerow(
1959
+ [
1960
+ i + 1,
1961
+ prop.area,
1962
+ prop.mean_intensity,
1963
+ prop.centroid[0],
1964
+ prop.centroid[1],
1965
+ ]
1966
+ )
1967
+
1968
+ log += "\nResults saved:\n"
1969
+ log += f"- Segmentation image: {segmentation_file}\n"
1970
+ log += f"- Region data: {csv_file}\n"
1971
+
1972
+ return log
BioScientist/agent_system/engines/v1_executor_backup/tool/lab_automation.py ADDED
@@ -0,0 +1,654 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import ast
2
+ import asyncio
3
+ import io
4
+ import json
5
+ import os
6
+ import tempfile
7
+ import time
8
+ import traceback
9
+ import urllib.request
10
+ import zipfile
11
+ from typing import Any
12
+
13
+ # ------------------------------------------------------------
14
+ # Dynamic PyLabRobot documentation/content loader
15
+ # ------------------------------------------------------------
16
+
17
+ _MAX_DOC_CHARS = int("50000")
18
+
19
+
20
+ def _load_pylabrobot_tutorial_content(section: str) -> str:
21
+ """Load PLR tutorial/docs text from multiple sources with graceful fallback.
22
+
23
+ Precedence:
24
+ 1) Docs from installed pylabrobot package (docs/user_guide/...)
25
+ 2) Introspect installed package (pylabrobot)
26
+ """
27
+ docs: list[str] = []
28
+
29
+ # 1) Fetch from GitHub repo zip (pinned commit by default)
30
+ repo = "PyLabRobot/pylabrobot"
31
+ ref = "106aef9c8699ceb826d8c9c894eba304a082f24d"
32
+
33
+ gh_docs = _collect_docs_from_github_zip(repo=repo, ref=ref, section=section)
34
+
35
+ if gh_docs and isinstance(gh_docs[0], tuple):
36
+ if section == "liquid":
37
+ formatted = _format_liquid_user_guide(gh_docs) # returns single string
38
+ if formatted:
39
+ docs.append(formatted)
40
+ else:
41
+ # Concatenate texts in stable order
42
+ docs.append("\n\n".join(text for _, text in gh_docs if text))
43
+ else:
44
+ docs.extend(gh_docs)
45
+
46
+ if not docs:
47
+ return ""
48
+
49
+ text = "\n\n".join([d for d in docs if d])
50
+ if len(text) > _MAX_DOC_CHARS:
51
+ text = text[:_MAX_DOC_CHARS]
52
+ return text
53
+
54
+
55
+ def _collect_docs_from_github_zip(repo: str, ref: str, section: str) -> list[tuple[str, str]] | list[str]:
56
+ """Download GitHub repo zip and extract user_guide docs for the section.
57
+
58
+ Uses:
59
+ - docs/user_guide/00_liquid-handling for section == "liquid"
60
+ - docs/user_guide/01_material-handling for section == "material"
61
+ """
62
+ if not repo:
63
+ return []
64
+
65
+ # Try commit zip first if ref looks like a commit SHA, then branch, then tag
66
+ url = f"https://github.com/{repo}/archive/{ref}.zip"
67
+
68
+ data = None
69
+ try:
70
+ with urllib.request.urlopen(url, timeout=20) as resp:
71
+ data = resp.read()
72
+ except Exception:
73
+ return []
74
+
75
+ # Restrict to specific subfolders by section
76
+ if section == "liquid":
77
+ # Only the Hamilton STAR(let) folder for now
78
+ target_subdir = "docs/user_guide/00_liquid-handling/hamilton-star"
79
+ else:
80
+ target_subdir = "docs/user_guide/01_material-handling"
81
+ target_subdir = target_subdir.lower()
82
+
83
+ collected_named: list[tuple[str, str]] = []
84
+ try:
85
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
86
+ # Select relevant names within target_subdir
87
+ candidate_names = []
88
+ for name in zf.namelist():
89
+ lower = name.lower()
90
+ if target_subdir not in lower:
91
+ continue
92
+ if lower.endswith("/"):
93
+ continue
94
+ if not (
95
+ lower.endswith(".md")
96
+ or lower.endswith(".rst")
97
+ or lower.endswith(".txt")
98
+ or lower.endswith(".ipynb")
99
+ ):
100
+ continue
101
+ candidate_names.append(name)
102
+
103
+ # Deterministic order; for liquid, ensure basic.ipynb first
104
+ if section == "liquid":
105
+ candidate_names.sort(key=lambda n: (0 if n.lower().endswith("basic.ipynb") else 1, n.lower()))
106
+ else:
107
+ candidate_names.sort(key=lambda n: n.lower())
108
+
109
+ for name in candidate_names:
110
+ lower = name.lower()
111
+ try:
112
+ with zf.open(name) as f:
113
+ content_bytes = f.read()
114
+ if lower.endswith(".ipynb"):
115
+ try:
116
+ nb = json.loads(content_bytes.decode("utf-8"))
117
+ except Exception:
118
+ continue
119
+ cells = nb.get("cells") or nb.get("worksheets", [{}])[0].get("cells", [])
120
+ parts = []
121
+ for c in cells:
122
+ ctype = c.get("cell_type") or c.get("type")
123
+ src = c.get("source") or c.get("input") or []
124
+ if isinstance(src, list):
125
+ src = "".join(src)
126
+ if not isinstance(src, str):
127
+ continue
128
+ if ctype == "markdown":
129
+ parts.append(src)
130
+ elif ctype == "code":
131
+ keep_lines = []
132
+ for line in src.splitlines():
133
+ l = line.strip()
134
+ if l.startswith("#"):
135
+ continue
136
+ if "pylabrobot" in l and ("import " in l or "from " in l):
137
+ keep_lines.append(l)
138
+ if keep_lines:
139
+ parts.append("Code refs:\n" + "\n".join(keep_lines[:20]))
140
+ if sum(len(p) for p in parts) > 5000:
141
+ break
142
+ text = "\n\n".join(parts)
143
+ else:
144
+ try:
145
+ text = content_bytes.decode("utf-8")
146
+ except Exception:
147
+ text = str(content_bytes)
148
+ if text:
149
+ collected_named.append((lower, text[:5000]))
150
+ except Exception:
151
+ continue
152
+ except Exception:
153
+ return []
154
+
155
+ return collected_named
156
+
157
+
158
+ def _format_liquid_user_guide(named_docs: list[tuple[str, str]]) -> str:
159
+ """Assemble liquid-handling docs into a curated order with headings.
160
+
161
+ named_docs: list of (filename_lower, text) from GitHub.
162
+ Returns a single formatted string.
163
+ """
164
+ sections = [
165
+ (
166
+ "Getting started with liquid handling on a Hamilton STAR(let)",
167
+ ["/hamilton-star/", "basic.ipynb", "basic", "getting-started"],
168
+ ),
169
+ ("iSWAP Module", ["iswap"]),
170
+ ("Liquid level detection on Hamilton STAR(let)", ["liquid-level", "lld", "level_detection", "level-detection"]),
171
+ ("Z-probing", ["z-probing", "z_probing", "z-probing", "zprobing", "z-prob"]),
172
+ ("Foil", ["foil"]),
173
+ ("Using the 96 head", ["96", "head", "mca", "96-head", "head-96"]),
174
+ (
175
+ "Using “Hamilton Liquid Classes” with Pylabrobot",
176
+ ["liquid-classes", "liquid_classes", "hamilton-liquid-classes"],
177
+ ),
178
+ ]
179
+
180
+ used: set[int] = set()
181
+ out_parts: list[str] = []
182
+
183
+ def pick_first(keywords: list[str]) -> str:
184
+ for idx, (fname, text) in enumerate(named_docs):
185
+ if idx in used:
186
+ continue
187
+ if any(k in fname for k in keywords):
188
+ used.add(idx)
189
+ return text
190
+ return ""
191
+
192
+ for heading, keywords in sections:
193
+ text = pick_first(keywords)
194
+ if text:
195
+ out_parts.append(f"## {heading}\n\n{text}")
196
+
197
+ # Append any remaining docs not matched, to avoid losing content
198
+ for idx, (fname, text) in enumerate(named_docs):
199
+ if idx not in used and text:
200
+ # Derive a nice title from filename
201
+ leaf = fname.rsplit("/", 1)[-1]
202
+ title = leaf.replace("_", " ").replace("-", " ")
203
+ title = title.rsplit(".", 1)[0].strip().title()
204
+ out_parts.append(f"## {title}\n\n{text}")
205
+
206
+ return "\n\n".join(out_parts)
207
+
208
+
209
+ def get_pylabrobot_documentation_liquid() -> str:
210
+ """Get the documentation for a specific section of the PyLabRobot tutorial."""
211
+ tutorial_content = """Notes:
212
+ - Use hamilton_96_tiprack_1000uL_filter instead of HTF (deprecated). Note the capital L in uL.
213
+ - Use Cor_96_wellplate_360ul_Fb instead of Corning_96_wellplate_360ul_Fb.
214
+ - You must name all your plates, tip racks, and carriers.
215
+ - Assign labware into carriers via slot assignment (tip_car[0] = tiprack). Assign plates to rails using lh.deck.assign_child_resource(plate_car, rails=14).
216
+ - Rails must be between -4 and 32.
217
+ - Make sure most liquid handling operations are done with async/await.
218
+ - There are some methods that are not async, including lh.summary(). Do not use await for these methods.
219
+ - When picking up tips with multiple channels, use a flat list of tips. Do not use a list of lists. """
220
+
221
+ tutorial_content += _load_pylabrobot_tutorial_content("liquid")
222
+
223
+ return tutorial_content
224
+
225
+
226
+ def get_pylabrobot_documentation_material() -> str:
227
+ tutorial_content = _load_pylabrobot_tutorial_content("material")
228
+
229
+ return tutorial_content
230
+
231
+
232
+ def test_pylabrobot_script(
233
+ script_input: str,
234
+ enable_tracking: bool = False,
235
+ timeout_seconds: int = 60,
236
+ save_test_report: bool = False,
237
+ test_report_dir: str = None,
238
+ ) -> dict[str, Any]:
239
+ """Test a PyLabRobot script using simulation and validation.
240
+
241
+ Uses PyLabRobot's ChatterboxBackend and tracking systems to
242
+ validate generated scripts without requiring physical hardware.
243
+
244
+ Args:
245
+ script_input (str): Either the PyLabRobot script code as a string, or a file path to a .py file
246
+ enable_tracking (bool): Enable tip and volume tracking for error detection
247
+ timeout_seconds (int): Maximum execution time before timeout
248
+ save_test_report (bool): Whether to save detailed test results to file
249
+ test_report_dir (str, optional): Directory to save test reports
250
+
251
+ Returns:
252
+ dict: Dictionary containing:
253
+ - success (bool): Whether the script passed all tests
254
+ - test_results (dict): Detailed test results for each validation step
255
+ - execution_summary (dict): Summary of operations performed
256
+ - errors (list): List of errors encountered
257
+ - warnings (list): List of warnings
258
+ - test_report_path (str): Path to saved report if requested
259
+
260
+ Example:
261
+ >>> # Test with script content string
262
+ >>> script = "async def main(): ..."
263
+ >>> result = test_pylabrobot_script(script)
264
+
265
+ >>> # Test with file path
266
+ >>> result = test_pylabrobot_script("/path/to/script.py")
267
+
268
+ >>> if result["success"]:
269
+ ... print("Test passed!")
270
+ ... else:
271
+ ... print(f"Test failed: {result['errors']}")
272
+ """
273
+ start_time = time.time()
274
+ test_results = {
275
+ "syntax_valid": False,
276
+ "imports_valid": False,
277
+ "simulation_successful": False,
278
+ "tracking_enabled": enable_tracking,
279
+ }
280
+ execution_summary = {"operations_performed": 0, "tips_used": 0, "liquid_transferred": 0.0, "execution_time": 0.0}
281
+ errors = []
282
+ warnings = []
283
+
284
+ # Determine if input is a file path or script content
285
+ script_content = ""
286
+ try:
287
+ # Check if input looks like a file path and exists
288
+ if (
289
+ script_input.endswith(".py") and os.path.isfile(script_input) and "\n" not in script_input[:100]
290
+ ): # Basic heuristic: file paths shouldn't have newlines
291
+ try:
292
+ with open(script_input, encoding="utf-8") as f:
293
+ script_content = f.read()
294
+ test_results["input_type"] = "file"
295
+ test_results["file_path"] = script_input
296
+ except Exception as e:
297
+ errors.append(f"Failed to read script file '{script_input}': {str(e)}")
298
+ return _create_test_result(
299
+ False,
300
+ test_results,
301
+ execution_summary,
302
+ errors,
303
+ warnings,
304
+ start_time,
305
+ save_test_report,
306
+ test_report_dir,
307
+ )
308
+ else:
309
+ # Treat as script content string
310
+ script_content = script_input
311
+ test_results["input_type"] = "string"
312
+
313
+ except Exception as e:
314
+ errors.append(f"Failed to process script input: {str(e)}")
315
+ return _create_test_result(
316
+ False, test_results, execution_summary, errors, warnings, start_time, save_test_report, test_report_dir
317
+ )
318
+
319
+ if not script_content.strip():
320
+ errors.append("Script content is empty")
321
+ return _create_test_result(
322
+ False, test_results, execution_summary, errors, warnings, start_time, save_test_report, test_report_dir
323
+ )
324
+
325
+ try:
326
+ # Step 1: Syntax Validation
327
+ try:
328
+ ast.parse(script_content)
329
+ test_results["syntax_valid"] = True
330
+ except SyntaxError as e:
331
+ errors.append(f"Syntax Error: {str(e)} at line {e.lineno}")
332
+ return _create_test_result(
333
+ False, test_results, execution_summary, errors, warnings, start_time, save_test_report, test_report_dir
334
+ )
335
+
336
+ # Step 2: Import Validation
337
+ import_results = _validate_pylabrobot_imports(script_content)
338
+ test_results["imports_valid"] = import_results["success"]
339
+ if not import_results["success"]:
340
+ errors.extend(import_results["errors"])
341
+ warnings.extend(import_results["warnings"])
342
+
343
+ # Step 3: Replace backends with ChatterboxBackend for simulation
344
+ modified_script = _modify_script_for_testing(script_content, enable_tracking)
345
+
346
+ # Step 4: Execute script in controlled environment
347
+ execution_result = _execute_script_safely(modified_script, timeout_seconds)
348
+
349
+ test_results["simulation_successful"] = execution_result["success"]
350
+ execution_summary.update(execution_result["summary"])
351
+
352
+ if not execution_result["success"]:
353
+ errors.extend(execution_result["errors"])
354
+
355
+ warnings.extend(execution_result.get("warnings", []))
356
+
357
+ except Exception as e:
358
+ errors.append(f"Unexpected error during testing: {str(e)}")
359
+ traceback.print_exc()
360
+
361
+ overall_success = (
362
+ test_results["syntax_valid"] and test_results["imports_valid"] and test_results["simulation_successful"]
363
+ )
364
+
365
+ return _create_test_result(
366
+ overall_success,
367
+ test_results,
368
+ execution_summary,
369
+ errors,
370
+ warnings,
371
+ start_time,
372
+ save_test_report,
373
+ test_report_dir,
374
+ )
375
+
376
+
377
+ def _validate_pylabrobot_imports(script_content: str) -> dict[str, Any]:
378
+ """Validate that all PyLabRobot imports in the script are available."""
379
+ import_errors = []
380
+ import_warnings = []
381
+
382
+ try:
383
+ # Parse the script to find import statements
384
+ tree = ast.parse(script_content)
385
+ pylabrobot_imports = []
386
+
387
+ for node in ast.walk(tree):
388
+ if isinstance(node, ast.Import):
389
+ for alias in node.names:
390
+ if "pylabrobot" in alias.name:
391
+ pylabrobot_imports.append(alias.name)
392
+ elif isinstance(node, ast.ImportFrom):
393
+ if node.module and "pylabrobot" in node.module:
394
+ for alias in node.names:
395
+ full_import = f"{node.module}.{alias.name}"
396
+ pylabrobot_imports.append(full_import)
397
+
398
+ # Try to import each PyLabRobot module/class
399
+ for import_name in pylabrobot_imports:
400
+ try:
401
+ # Handle different import patterns
402
+ if "." in import_name:
403
+ parts = import_name.split(".")
404
+ module_parts = parts[:-1]
405
+ class_name = parts[-1]
406
+
407
+ # Import the module
408
+ module_name = ".".join(module_parts)
409
+ module = __import__(module_name, fromlist=[class_name])
410
+
411
+ # Check if the class/function exists
412
+ if not hasattr(module, class_name):
413
+ import_errors.append(f"Cannot find '{class_name}' in module '{module_name}'")
414
+ else:
415
+ # Direct module import
416
+ __import__(import_name)
417
+
418
+ except ImportError as e:
419
+ import_errors.append(f"Failed to import '{import_name}': {str(e)}")
420
+ except Exception as e:
421
+ import_warnings.append(f"Warning validating import '{import_name}': {str(e)}")
422
+
423
+ except Exception as e:
424
+ import_errors.append(f"Error parsing imports: {str(e)}")
425
+
426
+ return {"success": len(import_errors) == 0, "errors": import_errors, "warnings": import_warnings}
427
+
428
+
429
+ def _modify_script_for_testing(script_content: str, enable_tracking: bool) -> str:
430
+ """Modify script to use simulation backends and enable tracking."""
431
+ modified_script = script_content
432
+
433
+ # Replace STARBackend with LiquidHandlerChatterboxBackend for simulation
434
+ replacements = [("STARBackend()", "LiquidHandlerChatterboxBackend()")]
435
+
436
+ for old, new in replacements:
437
+ modified_script = modified_script.replace(old, new)
438
+
439
+ lines = modified_script.split("\n")
440
+ insert_at = 0
441
+ for i, line in enumerate(lines):
442
+ if line.strip().startswith(("from ", "import ", "#")):
443
+ insert_at = i + 1
444
+ continue
445
+ if line.strip().startswith(("async def", "def", "class", "if __name__")):
446
+ break
447
+ insert_at = i + 1
448
+ lines.insert(insert_at, "from pylabrobot.liquid_handling.backends import LiquidHandlerChatterboxBackend")
449
+ modified_script = "\n".join(lines)
450
+
451
+ # Add tracking imports and setup at the beginning
452
+ if enable_tracking:
453
+ tracking_setup = """
454
+ # Enable PyLabRobot tracking for validation
455
+ try:
456
+ from pylabrobot.resources import set_tip_tracking, set_volume_tracking
457
+ set_tip_tracking(True)
458
+ set_volume_tracking(True)
459
+ except ImportError:
460
+ pass # Tracking not available in this PyLabRobot version
461
+
462
+ """
463
+ else:
464
+ tracking_setup = """
465
+ # Disable PyLabRobot tracking for testing
466
+ try:
467
+ from pylabrobot.resources import set_tip_tracking, set_volume_tracking
468
+ set_tip_tracking(False)
469
+ set_volume_tracking(False)
470
+ except ImportError:
471
+ pass # Tracking not available in this PyLabRobot version
472
+
473
+ """
474
+
475
+ # Insert after imports but before main function
476
+ lines = modified_script.split("\n")
477
+ insert_index = 0
478
+ for i, line in enumerate(lines):
479
+ if (
480
+ line.strip().startswith("async def")
481
+ or line.strip().startswith("def")
482
+ or line.strip().startswith("if __name__")
483
+ ):
484
+ insert_index = i
485
+ break
486
+
487
+ lines.insert(insert_index, tracking_setup)
488
+ modified_script = "\n".join(lines)
489
+
490
+ return modified_script
491
+
492
+
493
+ def _execute_script_safely(script_content: str, timeout_seconds: int) -> dict[str, Any]:
494
+ """Execute the modified script in a safe environment."""
495
+ errors = []
496
+ warnings = []
497
+ summary = {"operations_performed": 0, "tips_used": 0, "liquid_transferred": 0.0}
498
+
499
+ try:
500
+ # Create a temporary file for the script
501
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".py", delete=False) as f:
502
+ f.write(script_content)
503
+ temp_script_path = f.name
504
+
505
+ # Execute the script with timeout
506
+ try:
507
+ # Use threading for timeout control
508
+ import threading
509
+
510
+ result = None
511
+ exception = None
512
+
513
+ def target():
514
+ nonlocal result, exception
515
+ try:
516
+ result = _run_script_with_monitoring(temp_script_path)
517
+ except Exception as e:
518
+ exception = e
519
+
520
+ thread = threading.Thread(target=target)
521
+ thread.start()
522
+ thread.join(timeout=timeout_seconds)
523
+
524
+ if thread.is_alive():
525
+ errors.append(f"Script execution timed out after {timeout_seconds} seconds")
526
+ elif exception:
527
+ raise exception
528
+ elif result:
529
+ summary.update(result.get("summary", {}))
530
+ warnings.extend(result.get("warnings", []))
531
+
532
+ return {"success": True, "summary": summary, "errors": errors, "warnings": warnings}
533
+ else:
534
+ errors.append("Script execution completed but returned no result")
535
+ except Exception as e:
536
+ errors.append(f"Script execution failed: {str(e)}")
537
+
538
+ except Exception as e:
539
+ errors.append(f"Failed to prepare script execution: {str(e)}")
540
+ finally:
541
+ # Clean up temporary file
542
+ try:
543
+ os.unlink(temp_script_path)
544
+ except OSError:
545
+ pass
546
+
547
+ return {"success": False, "summary": summary, "errors": errors, "warnings": warnings}
548
+
549
+
550
+ def _run_script_with_monitoring(script_path: str) -> dict[str, Any]:
551
+ """Run the script and monitor its execution."""
552
+ # Note: This is a simplified version. In practice, you might want to
553
+ # use subprocess or other isolation methods for safety
554
+
555
+ warnings = []
556
+ summary = {"operations_performed": 0, "tips_used": 0, "liquid_transferred": 0.0}
557
+
558
+ try:
559
+ # Read and execute the script
560
+ with open(script_path) as f:
561
+ script_content = f.read()
562
+
563
+ # Create a namespace for execution
564
+ namespace = {
565
+ "__name__": "__main__",
566
+ "__file__": script_path,
567
+ }
568
+
569
+ # Execute the script
570
+ exec(script_content, namespace)
571
+
572
+ # If the script has a main function, run it
573
+ if "main" in namespace and callable(namespace["main"]):
574
+ if asyncio.iscoroutinefunction(namespace["main"]):
575
+ # Run async main function using asyncio.run if not in event loop
576
+ try:
577
+ asyncio.get_running_loop()
578
+ # We're in an event loop, create a new thread to run asyncio.run
579
+ import threading
580
+
581
+ result = None
582
+ exception = None
583
+
584
+ def run_async():
585
+ nonlocal result, exception
586
+ try:
587
+ asyncio.run(namespace["main"]())
588
+ except Exception as e:
589
+ exception = e
590
+
591
+ thread = threading.Thread(target=run_async)
592
+ thread.start()
593
+ thread.join()
594
+
595
+ if exception:
596
+ raise exception
597
+ except RuntimeError:
598
+ # No event loop running, safe to use asyncio.run
599
+ asyncio.run(namespace["main"]())
600
+ else:
601
+ namespace["main"]()
602
+
603
+ # Execution summary collection can be added here in the future once
604
+ # PyLabRobot exposes reliable runtime statistics.
605
+ except Exception as e:
606
+ raise Exception(f"Script execution error: {str(e)}") from e
607
+
608
+ return {"summary": summary, "warnings": warnings}
609
+
610
+
611
+ def _create_test_result(
612
+ success: bool,
613
+ test_results: dict,
614
+ execution_summary: dict,
615
+ errors: list,
616
+ warnings: list,
617
+ start_time: float,
618
+ save_test_report: bool,
619
+ test_report_dir: str,
620
+ ) -> dict[str, Any]:
621
+ """Create the final test result dictionary."""
622
+ # Calculate total execution time
623
+ total_execution_time = time.time() - start_time
624
+ execution_summary["total_execution_time"] = total_execution_time
625
+
626
+ result = {
627
+ "success": success,
628
+ "test_results": test_results,
629
+ "execution_summary": execution_summary,
630
+ "errors": errors,
631
+ "warnings": warnings,
632
+ }
633
+
634
+ # Save test report if requested
635
+ if save_test_report:
636
+ try:
637
+ if test_report_dir:
638
+ os.makedirs(test_report_dir, exist_ok=True)
639
+ else:
640
+ test_report_dir = tempfile.gettempdir()
641
+
642
+ timestamp = time.strftime("%Y%m%d_%H%M%S")
643
+ report_filename = f"pylabrobot_test_report_{timestamp}.json"
644
+ report_path = os.path.join(test_report_dir, report_filename)
645
+
646
+ with open(report_path, "w") as f:
647
+ json.dump(result, f, indent=2)
648
+
649
+ result["test_report_path"] = report_path
650
+
651
+ except Exception as e:
652
+ warnings.append(f"Failed to save test report: {str(e)}")
653
+
654
+ return result
BioScientist/agent_system/engines/v1_executor_backup/tool/literature.py ADDED
@@ -0,0 +1,403 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import re
3
+ import time
4
+ from io import BytesIO
5
+ from urllib.parse import urljoin
6
+
7
+ import PyPDF2
8
+ import requests
9
+ from bs4 import BeautifulSoup
10
+ from googlesearch import search
11
+
12
+
13
+ def fetch_supplementary_info_from_doi(doi: str, output_dir: str = "supplementary_info"):
14
+ """Fetches supplementary information for a paper given its DOI and returns a research log.
15
+
16
+ Args:
17
+ doi: The paper DOI.
18
+ output_dir: Directory to save supplementary files.
19
+
20
+ Returns:
21
+ dict: A dictionary containing a research log and the downloaded file paths.
22
+
23
+ """
24
+ research_log = []
25
+ research_log.append(f"Starting process for DOI: {doi}")
26
+
27
+ # CrossRef API to resolve DOI to a publisher page
28
+ crossref_url = f"https://doi.org/{doi}"
29
+ headers = {"User-Agent": "Mozilla/5.0"}
30
+ response = requests.get(crossref_url, headers=headers)
31
+
32
+ if response.status_code != 200:
33
+ log_message = f"Failed to resolve DOI: {doi}. Status Code: {response.status_code}"
34
+ research_log.append(log_message)
35
+ return {"log": research_log, "files": []}
36
+
37
+ publisher_url = response.url
38
+ research_log.append(f"Resolved DOI to publisher page: {publisher_url}")
39
+
40
+ # Fetch publisher page
41
+ response = requests.get(publisher_url, headers=headers)
42
+ if response.status_code != 200:
43
+ log_message = f"Failed to access publisher page for DOI {doi}."
44
+ research_log.append(log_message)
45
+ return {"log": research_log, "files": []}
46
+
47
+ # Parse page content
48
+ soup = BeautifulSoup(response.content, "html.parser")
49
+ supplementary_links = []
50
+
51
+ # Look for supplementary materials by keywords or links
52
+ for link in soup.find_all("a", href=True):
53
+ href = link.get("href")
54
+ text = link.get_text().lower()
55
+ if "supplementary" in text or "supplemental" in text or "appendix" in text:
56
+ full_url = urljoin(publisher_url, href)
57
+ supplementary_links.append(full_url)
58
+ research_log.append(f"Found supplementary material link: {full_url}")
59
+
60
+ if not supplementary_links:
61
+ log_message = f"No supplementary materials found for DOI {doi}."
62
+ research_log.append(log_message)
63
+ return research_log
64
+
65
+ # Create output directory
66
+ os.makedirs(output_dir, exist_ok=True)
67
+ research_log.append(f"Created output directory: {output_dir}")
68
+
69
+ # Download supplementary materials
70
+ downloaded_files = []
71
+ for link in supplementary_links:
72
+ file_name = os.path.join(output_dir, link.split("/")[-1])
73
+ file_response = requests.get(link, headers=headers)
74
+ if file_response.status_code == 200:
75
+ with open(file_name, "wb") as f:
76
+ f.write(file_response.content)
77
+ downloaded_files.append(file_name)
78
+ research_log.append(f"Downloaded file: {file_name}")
79
+ else:
80
+ research_log.append(f"Failed to download file from {link}")
81
+
82
+ if downloaded_files:
83
+ research_log.append(f"Successfully downloaded {len(downloaded_files)} file(s).")
84
+ else:
85
+ research_log.append(f"No files could be downloaded for DOI {doi}.")
86
+
87
+ return "\n".join(research_log)
88
+
89
+
90
+ def query_arxiv(query: str, max_papers: int = 10) -> str:
91
+ """Query arXiv for papers based on the provided search query.
92
+
93
+ Parameters
94
+ ----------
95
+ - query (str): The search query string.
96
+ - max_papers (int): The maximum number of papers to retrieve (default: 10).
97
+
98
+ Returns
99
+ -------
100
+ - str: The formatted search results or an error message.
101
+
102
+ """
103
+ import arxiv
104
+
105
+ try:
106
+ client = arxiv.Client()
107
+ search = arxiv.Search(query=query, max_results=max_papers, sort_by=arxiv.SortCriterion.Relevance)
108
+ results = "\n\n".join([f"Title: {paper.title}\nSummary: {paper.summary}" for paper in client.results(search)])
109
+ return results if results else "No papers found on arXiv."
110
+ except Exception as e:
111
+ return f"Error querying arXiv: {e}"
112
+
113
+
114
+ def query_scholar(query: str) -> str:
115
+ """Query Google Scholar for papers based on the provided search query.
116
+
117
+ Parameters
118
+ ----------
119
+ - query (str): The search query string.
120
+
121
+ Returns
122
+ -------
123
+ - str: The first search result formatted or an error message.
124
+
125
+ """
126
+ from scholarly import ProxyGenerator, scholarly
127
+
128
+ # Set up a ProxyGenerator object to use free proxies
129
+ # This needs to be done only once per session
130
+ pg = ProxyGenerator()
131
+ pg.FreeProxies()
132
+ scholarly.use_proxy(pg)
133
+ try:
134
+ search_query = scholarly.search_pubs(query)
135
+ result = next(search_query, None)
136
+ if result:
137
+ return f"Title: {result['bib']['title']}\nYear: {result['bib']['pub_year']}\nVenue: {result['bib']['venue']}\nAbstract: {result['bib']['abstract']}"
138
+ else:
139
+ return "No results found on Google Scholar."
140
+ except Exception as e:
141
+ return f"Error querying Google Scholar: {e}"
142
+
143
+
144
+ def query_pubmed(query: str, max_papers: int = 10, max_retries: int = 3) -> str:
145
+ """Query PubMed for papers based on the provided search query.
146
+
147
+ Parameters
148
+ ----------
149
+ - query (str): The search query string.
150
+ - max_papers (int): The maximum number of papers to retrieve (default: 10).
151
+ - max_retries (int): Maximum number of retry attempts with modified queries (default: 3).
152
+
153
+ Returns
154
+ -------
155
+ - str: The formatted search results or an error message.
156
+
157
+ """
158
+ from pymed import PubMed
159
+
160
+ try:
161
+ pubmed = PubMed(tool="MyTool", email="your-email@example.com") # Update with a valid email address
162
+
163
+ # Initial attempt
164
+ papers = list(pubmed.query(query, max_results=max_papers))
165
+
166
+ # Retry with modified queries if no results
167
+ retries = 0
168
+ while not papers and retries < max_retries:
169
+ retries += 1
170
+ # Simplify query with each retry by removing the last word
171
+ simplified_query = " ".join(query.split()[:-retries]) if len(query.split()) > retries else query
172
+ time.sleep(1) # Add delay between requests
173
+ papers = list(pubmed.query(simplified_query, max_results=max_papers))
174
+
175
+ if papers:
176
+ results = "\n\n".join(
177
+ [f"Title: {paper.title}\nAbstract: {paper.abstract}\nJournal: {paper.journal}" for paper in papers]
178
+ )
179
+ return results
180
+ else:
181
+ return "No papers found on PubMed after multiple query attempts."
182
+ except Exception as e:
183
+ return f"Error querying PubMed: {e}"
184
+
185
+
186
+ def search_google(query: str, num_results: int = 3, language: str = "en") -> list[dict]:
187
+ """Search using Google search.
188
+
189
+ Args:
190
+ query (str): The search query (e.g., "protocol text or seach question")
191
+ num_results (int): Number of results to return (default: 10)
192
+ language (str): Language code for search results (default: 'en')
193
+ pause (float): Pause between searches to avoid rate limiting (default: 2.0 seconds)
194
+
195
+ Returns:
196
+ List[dict]: List of dictionaries containing search results with title and URL
197
+
198
+ """
199
+ try:
200
+ results_string = ""
201
+ search_query = f"{query}"
202
+
203
+ print(f"Searching for {search_query} with {num_results} results and {language} language")
204
+
205
+ for res in search(search_query, num_results=num_results, lang=language, advanced=True):
206
+ print(f"Found result: {res.title}")
207
+ title = res.title
208
+ url = res.url
209
+ description = res.description
210
+
211
+ results_string += f"Title: {title}\nURL: {url}\nDescription: {description}\n\n"
212
+
213
+ except Exception as e:
214
+ print(f"Error performing search: {str(e)}")
215
+ return results_string
216
+
217
+
218
+ def advanced_web_search_claude(
219
+ query: str,
220
+ max_searches: int = 1,
221
+ max_retries: int = 3,
222
+ ) -> tuple[str, list[dict[str, str]], list]:
223
+ """
224
+ Initiate an advanced web search by launching a specialized agent to collect relevant information and citations through multiple rounds of web searches for a given query.
225
+ Craft the query carefully for the search agent to find the most relevant information.
226
+
227
+ Parameters
228
+ ----------
229
+ query : str
230
+ The search phrase you want Claude to look up.
231
+ max_searches : int, optional
232
+ Upper-bound on searches Claude may issue inside this request.
233
+ max_retries : int, optional
234
+ Maximum number of retry attempts with exponential backoff.
235
+
236
+ Returns
237
+ -------
238
+ full_text : str
239
+ A formatted string containing the full text response from Claude and the citations.
240
+ """
241
+ import random
242
+
243
+ import anthropic
244
+
245
+ try:
246
+ from biomni.config import default_config
247
+
248
+ model = default_config.llm
249
+ api_key = default_config.api_key
250
+ if not api_key:
251
+ api_key = os.getenv("ANTHROPIC_API_KEY")
252
+ except ImportError:
253
+ model = "claude-4-sonnet-latest"
254
+ api_key = os.getenv("ANTHROPIC_API_KEY")
255
+
256
+ if "claude" not in model:
257
+ raise ValueError("Model must be a Claude model.")
258
+
259
+ if not api_key:
260
+ raise ValueError("Set your api_key explicitly.")
261
+
262
+ client = anthropic.Anthropic(api_key=api_key)
263
+ tool_def = {
264
+ "type": "web_search_20250305",
265
+ "name": "web_search",
266
+ "max_uses": max_searches,
267
+ }
268
+
269
+ delay = random.randint(1, 10)
270
+
271
+ for attempt in range(1, max_retries + 1):
272
+ try:
273
+ response = client.messages.create(
274
+ model=model,
275
+ max_tokens=4096,
276
+ messages=[{"role": "user", "content": query}],
277
+ tools=[tool_def],
278
+ )
279
+
280
+ paragraphs, citations = [], []
281
+ response.content = response.content
282
+ formatted_response = ""
283
+ for blk in response.content:
284
+ if blk.type == "text":
285
+ paragraphs.append(blk.text)
286
+ formatted_response += blk.text
287
+
288
+ if blk.citations:
289
+ for cite in blk.citations:
290
+ citations.append({"url": cite.url, "title": cite.title, "cited_text": cite.cited_text})
291
+ formatted_response += f"(Citation: {cite.title} - {cite.url})"
292
+ return formatted_response
293
+
294
+ except Exception as e:
295
+ if attempt < max_retries:
296
+ time.sleep(delay)
297
+ delay *= 2
298
+ continue
299
+ print(f"Error performing web search after {max_retries} attempts: {str(e)}")
300
+ return f"Error performing web search after {max_retries} attempts: {str(e)}"
301
+
302
+
303
+ def extract_url_content(url: str) -> str:
304
+ """Extract the text content of a webpage using requests and BeautifulSoup.
305
+
306
+ Args:
307
+ url: Webpage URL to extract content from
308
+
309
+ Returns:
310
+ Text content of the webpage
311
+
312
+ """
313
+ response = requests.get(url, headers={"User-Agent": "Mozilla/5.0"})
314
+
315
+ # Check if the response is in text format
316
+ if "text/plain" in response.headers.get("Content-Type", "") or "application/json" in response.headers.get(
317
+ "Content-Type", ""
318
+ ):
319
+ return response.text.strip() # Return plain text or JSON response directly
320
+
321
+ # If it's HTML, use BeautifulSoup to parse
322
+ soup = BeautifulSoup(response.text, "html.parser")
323
+
324
+ # Try to find main content first, fallback to body
325
+ content = soup.find("main") or soup.find("article") or soup.body
326
+
327
+ # Remove unwanted elements
328
+ for element in content(["script", "style", "nav", "header", "footer", "aside", "iframe"]):
329
+ element.decompose()
330
+
331
+ # Extract text with better formatting
332
+ paragraphs = content.find_all(["p", "h1", "h2", "h3", "h4", "h5", "h6"])
333
+ cleaned_text = []
334
+
335
+ for p in paragraphs:
336
+ text = p.get_text().strip()
337
+ if text: # Only add non-empty paragraphs
338
+ cleaned_text.append(text)
339
+
340
+ return "\n\n".join(cleaned_text)
341
+
342
+
343
+ def extract_pdf_content(url: str) -> str:
344
+ """Extract the text content of a PDF file given its URL.
345
+
346
+ Args:
347
+ url: URL of the PDF file to extract text from
348
+
349
+ Returns:
350
+ The extracted text content from the PDF
351
+
352
+ """
353
+ try:
354
+ # Check if the URL ends with .pdf
355
+ if not url.lower().endswith(".pdf"):
356
+ # If not, try to find a PDF link on the page
357
+ response = requests.get(url, timeout=30)
358
+ if response.status_code == 200:
359
+ # Look for PDF links in the HTML content
360
+ pdf_links = re.findall(r'href=[\'"]([^\'"]+\.pdf)[\'"]', response.text)
361
+ if pdf_links:
362
+ # Use the first PDF link found
363
+ if not pdf_links[0].startswith("http"):
364
+ # Handle relative URLs
365
+ base_url = "/".join(url.split("/")[:3])
366
+ url = base_url + pdf_links[0] if pdf_links[0].startswith("/") else base_url + "/" + pdf_links[0]
367
+ else:
368
+ url = pdf_links[0]
369
+ else:
370
+ return f"No PDF file found at {url}. Please provide a direct link to a PDF file."
371
+
372
+ # Download the PDF
373
+ response = requests.get(url, timeout=30)
374
+
375
+ # Check if we actually got a PDF file (by checking content type or magic bytes)
376
+ content_type = response.headers.get("Content-Type", "").lower()
377
+ if "application/pdf" not in content_type and not response.content.startswith(b"%PDF"):
378
+ return f"The URL did not return a valid PDF file. Content type: {content_type}"
379
+
380
+ pdf_file = BytesIO(response.content)
381
+
382
+ # Try with PyPDF2 first
383
+ try:
384
+ text = ""
385
+ pdf_reader = PyPDF2.PdfReader(pdf_file)
386
+ for page_num in range(len(pdf_reader.pages)):
387
+ page = pdf_reader.pages[page_num]
388
+ text += page.extract_text() + "\n\n"
389
+ except Exception as e:
390
+ print(f"Error extracting text from PDF: {str(e)}")
391
+
392
+ # Clean up the text
393
+ text = re.sub(r"\s+", " ", text).strip()
394
+
395
+ if not text:
396
+ return "The PDF file did not contain any extractable text. It may be an image-based PDF requiring OCR."
397
+
398
+ return text
399
+
400
+ except requests.exceptions.RequestException as e:
401
+ return f"Error downloading PDF: {str(e)}"
402
+ except Exception as e:
403
+ return f"Error extracting text from PDF: {str(e)}"
BioScientist/agent_system/engines/v1_executor_backup/tool/microbiology.py ADDED
@@ -0,0 +1,1618 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ def optimize_anaerobic_digestion_process(
2
+ waste_characteristics,
3
+ operational_parameters,
4
+ target_output="methane_yield",
5
+ optimization_method="rsm",
6
+ ):
7
+ """Optimize anaerobic digestion process conditions to maximize VFA production or methane yield.
8
+
9
+ Parameters
10
+ ----------
11
+ waste_characteristics : dict
12
+ Dictionary containing waste characteristics such as:
13
+ - total_solids (float): Total solids content (%)
14
+ - volatile_solids (float): Volatile solids content (%)
15
+ - cod (float): Chemical oxygen demand (mg/L)
16
+
17
+ operational_parameters : dict
18
+ Dictionary containing operational parameters and their ranges:
19
+ - hrt (tuple): Hydraulic retention time range in days (min, max)
20
+ - olr (tuple): Organic loading rate range in kg VS/(m³·d) (min, max)
21
+ - if_ratio (tuple): Inoculum-to-feedstock ratio range (min, max)
22
+ - temperature (tuple): Temperature range in °C (min, max)
23
+ - ph (tuple): pH range (min, max)
24
+
25
+ target_output : str, optional
26
+ Target output to maximize, either 'vfa_production' or 'methane_yield'.
27
+ Default is 'methane_yield'.
28
+
29
+ optimization_method : str, optional
30
+ Method used for optimization, either 'rsm' (Response Surface Methodology) or
31
+ 'genetic' (Genetic Algorithm). Default is 'rsm'.
32
+
33
+ Returns
34
+ -------
35
+ str
36
+ Research log summarizing the optimization process and results.
37
+
38
+ """
39
+ import matplotlib.pyplot as plt
40
+ import numpy as np
41
+ from matplotlib import cm
42
+ from scipy.optimize import differential_evolution, minimize
43
+
44
+ # Research log initialization
45
+ log = "# Anaerobic Digestion Process Optimization Research Log\n\n"
46
+ log += "## Input Parameters\n\n"
47
+ log += "### Waste Characteristics\n"
48
+ for key, value in waste_characteristics.items():
49
+ log += f"- {key}: {value}\n"
50
+
51
+ log += "\n### Operational Parameters Ranges\n"
52
+ for key, value in operational_parameters.items():
53
+ log += f"- {key}: {value}\n"
54
+
55
+ log += "\n## Optimization Setup\n"
56
+ log += f"- Target output: {target_output}\n"
57
+ log += f"- Optimization method: {optimization_method}\n\n"
58
+
59
+ # Define simplified models for VFA production and methane yield
60
+ # These are simplified models based on literature that relate operational parameters to outputs
61
+ def vfa_production_model(params):
62
+ hrt, olr, if_ratio, temp, ph = params
63
+
64
+ # Basic model: Higher VFA at moderate HRT, high OLR, low I/F ratio, mesophilic temp, slightly acidic pH
65
+ # This is a simplified model for demonstration purposes
66
+ vfa = (
67
+ -0.1 * (hrt - 10) ** 2 # Optimal HRT around 10 days
68
+ + 2 * olr # Higher OLR generally increases VFA
69
+ + -5 * if_ratio # Lower I/F ratio favors VFA accumulation
70
+ + -0.05 * (temp - 35) ** 2 # Optimal around 35°C (mesophilic)
71
+ + -10 * (ph - 5.5) ** 2 # Optimal pH around 5.5 for VFA
72
+ )
73
+
74
+ # Incorporate waste characteristics effects
75
+ vfa *= 0.8 + 0.2 * waste_characteristics["volatile_solids"] / 100
76
+ vfa *= 0.9 + 0.1 * waste_characteristics["cod"] / 10000
77
+
78
+ return -vfa # Negative because we're minimizing
79
+
80
+ def methane_yield_model(params):
81
+ hrt, olr, if_ratio, temp, ph = params
82
+
83
+ # Basic model: Higher methane at longer HRT, moderate OLR, high I/F ratio, mesophilic/thermophilic temp, neutral pH
84
+ # This is a simplified model for demonstration purposes
85
+ methane = (
86
+ 0.05 * hrt # Longer HRT generally increases methane
87
+ + -0.5 * (olr - 3) ** 2 # Optimal OLR around 3
88
+ + 2 * if_ratio # Higher I/F ratio favors methanogenesis
89
+ + -0.05 * (temp - 37) ** 2 # Optimal around 37°C (mesophilic)
90
+ + -15 * (ph - 7.2) ** 2 # Optimal pH around 7.2 for methane
91
+ )
92
+
93
+ # Incorporate waste characteristics effects
94
+ methane *= 0.7 + 0.3 * waste_characteristics["volatile_solids"] / 100
95
+ methane *= 0.8 + 0.2 * waste_characteristics["cod"] / 10000
96
+
97
+ return -methane # Negative because we're minimizing
98
+
99
+ # Select the appropriate model based on target output
100
+ if target_output == "vfa_production":
101
+ model = vfa_production_model
102
+ else: # methane_yield
103
+ model = methane_yield_model
104
+
105
+ # Define parameter bounds
106
+ bounds = [
107
+ operational_parameters["hrt"],
108
+ operational_parameters["olr"],
109
+ operational_parameters["if_ratio"],
110
+ operational_parameters["temperature"],
111
+ operational_parameters["ph"],
112
+ ]
113
+
114
+ # Perform optimization
115
+ log += "## Optimization Process\n\n"
116
+ log += "Performing parameter optimization to find optimal operating conditions...\n\n"
117
+
118
+ if optimization_method == "rsm":
119
+ # Initial guess (middle of each range)
120
+ x0 = [(b[0] + b[1]) / 2 for b in bounds]
121
+
122
+ # Run optimization
123
+ result = minimize(model, x0, bounds=bounds, method="L-BFGS-B")
124
+
125
+ optimal_params = result.x
126
+ optimal_value = -result.fun # Convert back to positive
127
+
128
+ log += f"Optimization converged after {result.nfev} function evaluations.\n"
129
+ log += f"Optimization success: {result.success}\n"
130
+ log += f"Final optimization message: {result.message}\n\n"
131
+
132
+ else: # genetic algorithm
133
+ result = differential_evolution(model, bounds)
134
+
135
+ optimal_params = result.x
136
+ optimal_value = -result.fun # Convert back to positive
137
+
138
+ log += f"Genetic algorithm completed after {result.nfev} function evaluations.\n"
139
+ log += f"Optimization success: {result.success}\n"
140
+ log += f"Final optimization message: {result.message}\n\n"
141
+
142
+ # Log optimal parameters
143
+ log += "## Optimization Results\n\n"
144
+ log += "### Optimal Operating Conditions\n"
145
+ param_names = [
146
+ "Hydraulic Retention Time (days)",
147
+ "Organic Loading Rate (kg VS/(m³·d))",
148
+ "Inoculum-to-Feedstock Ratio",
149
+ "Temperature (°C)",
150
+ "pH",
151
+ ]
152
+
153
+ for name, value in zip(param_names, optimal_params, strict=False):
154
+ log += f"- {name}: {value:.2f}\n"
155
+
156
+ log += "\n### Predicted Performance\n"
157
+ log += f"- Predicted {target_output.replace('_', ' ')}: {optimal_value:.2f}\n\n"
158
+
159
+ # Generate response surface visualization for the two most important parameters
160
+ # For VFA, we'll use HRT and OLR; for methane, we'll use HRT and I/F ratio
161
+ log += "## Response Surface Visualization\n\n"
162
+
163
+ if target_output == "vfa_production":
164
+ param1_idx, param2_idx = 0, 1 # HRT and OLR
165
+ param1_name, param2_name = "HRT (days)", "OLR (kg VS/(m³·d))"
166
+ else: # methane_yield
167
+ param1_idx, param2_idx = 0, 2 # HRT and I/F ratio
168
+ param1_name, param2_name = "HRT (days)", "I/F ratio"
169
+
170
+ # Create mesh grid for the two selected parameters
171
+ param1_range = np.linspace(bounds[param1_idx][0], bounds[param1_idx][1], 20)
172
+ param2_range = np.linspace(bounds[param2_idx][0], bounds[param2_idx][1], 20)
173
+ P1, P2 = np.meshgrid(param1_range, param2_range)
174
+
175
+ # Calculate output values
176
+ Z = np.zeros_like(P1)
177
+ for i in range(len(param1_range)):
178
+ for j in range(len(param2_range)):
179
+ params = list(optimal_params) # Start with optimal values for other parameters
180
+ params[param1_idx] = param1_range[i]
181
+ params[param2_idx] = param2_range[j]
182
+ Z[j, i] = -model(params) # Convert back to positive
183
+
184
+ # Create 3D plot
185
+ fig = plt.figure(figsize=(10, 8))
186
+ ax = fig.add_subplot(111, projection="3d")
187
+ surf = ax.plot_surface(P1, P2, Z, cmap=cm.coolwarm, alpha=0.8)
188
+
189
+ # Add optimal point
190
+ ax.scatter(
191
+ optimal_params[param1_idx],
192
+ optimal_params[param2_idx],
193
+ optimal_value,
194
+ color="black",
195
+ s=100,
196
+ marker="*",
197
+ )
198
+
199
+ # Labels
200
+ ax.set_xlabel(param1_name)
201
+ ax.set_ylabel(param2_name)
202
+ ax.set_zlabel(f"{target_output.replace('_', ' ')}")
203
+ ax.set_title(f"Response Surface for {target_output.replace('_', ' ')}")
204
+
205
+ # Add colorbar
206
+ fig.colorbar(surf, ax=ax, shrink=0.5, aspect=5)
207
+
208
+ # Save figure
209
+ plot_filename = f"ad_optimization_{target_output}_response_surface.png"
210
+ plt.savefig(plot_filename)
211
+ plt.close()
212
+
213
+ log += f"Response surface plot saved as: {plot_filename}\n\n"
214
+
215
+ # Sensitivity analysis
216
+ log += "## Parameter Sensitivity Analysis\n\n"
217
+
218
+ # Calculate sensitivity by varying each parameter slightly
219
+ sensitivities = []
220
+ for i, (name, param) in enumerate(zip(param_names, optimal_params, strict=False)):
221
+ delta = (bounds[i][1] - bounds[i][0]) * 0.05 # 5% of range
222
+
223
+ # Create parameter sets with small changes
224
+ params_plus = list(optimal_params)
225
+ params_plus[i] += delta
226
+
227
+ params_minus = list(optimal_params)
228
+ params_minus[i] -= delta
229
+
230
+ # Calculate output change
231
+ output_plus = -model(params_plus)
232
+ output_minus = -model(params_minus)
233
+
234
+ # Calculate sensitivity (normalized)
235
+ sensitivity = abs(output_plus - output_minus) / (2 * delta) * (param / optimal_value)
236
+ sensitivities.append((name, sensitivity))
237
+
238
+ # Sort sensitivities
239
+ sensitivities.sort(key=lambda x: x[1], reverse=True)
240
+
241
+ # Log sensitivities
242
+ log += "Parameters ranked by sensitivity (most to least sensitive):\n"
243
+ for name, sensitivity in sensitivities:
244
+ log += f"- {name}: {sensitivity:.4f}\n"
245
+
246
+ log += "\n## Conclusion\n\n"
247
+ log += f"The optimization process identified optimal operating conditions for maximizing {target_output.replace('_', ' ')} "
248
+ log += "in anaerobic digestion of the given organic waste. The most sensitive parameters were "
249
+ log += f"{sensitivities[0][0]} and {sensitivities[1][0]}.\n\n"
250
+
251
+ log += "These results can be used to guide experimental design and process control in anaerobic digestion systems."
252
+
253
+ return log
254
+
255
+
256
+ def analyze_arsenic_speciation_hplc_icpms(sample_data, sample_name="Unknown Sample", calibration_data=None):
257
+ """Analyzes arsenic speciation in liquid samples using HPLC-ICP-MS technique.
258
+
259
+ Parameters
260
+ ----------
261
+ sample_data : dict
262
+ Dictionary containing sample data with keys as sample IDs and values as dictionaries
263
+ with retention times (in minutes) as keys and signal intensities as values.
264
+ sample_name : str, optional
265
+ Name of the sample being analyzed (default: "Unknown Sample")
266
+ calibration_data : dict, optional
267
+ Dictionary containing calibration standards data with known concentrations for each arsenic species.
268
+ If None, default calibration values will be used.
269
+
270
+ Returns
271
+ -------
272
+ str
273
+ A research log summarizing the steps of the analysis and results.
274
+
275
+ """
276
+ from datetime import datetime
277
+
278
+ import pandas as pd
279
+
280
+ # Start research log
281
+ log = "# Arsenic Speciation Analysis by HPLC-ICP-MS\n"
282
+ log += f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n"
283
+ log += f"Sample: {sample_name}\n\n"
284
+
285
+ # Step 1: Sample preparation
286
+ log += "## 1. Sample Preparation\n"
287
+ log += "- Filtered sample through 0.45 μm filter\n"
288
+ log += "- Diluted sample with mobile phase (if necessary)\n"
289
+ log += "- Prepared for injection into HPLC system\n\n"
290
+
291
+ # Step 2: HPLC-ICP-MS Analysis
292
+ log += "## 2. HPLC-ICP-MS Analysis\n"
293
+ log += "- Column: Anion exchange column\n"
294
+ log += "- Mobile phase: 20 mM NH4H2PO4 (pH 6.0)\n"
295
+ log += "- Flow rate: 1.0 mL/min\n"
296
+ log += "- Injection volume: 50 μL\n"
297
+ log += "- ICP-MS detection: m/z 75 for arsenic\n\n"
298
+
299
+ # Step 3: Chromatographic separation and detection
300
+ log += "## 3. Chromatographic Separation and Detection\n"
301
+ log += "- Running chromatographic separation\n"
302
+ log += "- Monitoring arsenic signal (m/z 75)\n"
303
+ log += "- Collecting retention time data for species identification\n\n"
304
+
305
+ # Define retention times for arsenic species (in minutes)
306
+ arsenic_species = {
307
+ "As(III)": 2.8,
308
+ "As(V)": 7.5,
309
+ "MMAs(III)": 3.9,
310
+ "MMAs(V)": 6.2,
311
+ "DMAs(III)": 4.7,
312
+ "DMAs(V)": 5.3,
313
+ }
314
+
315
+ # Default calibration factors if not provided
316
+ if calibration_data is None:
317
+ calibration_data = {
318
+ "As(III)": {"factor": 0.85, "limit": 0.1},
319
+ "As(V)": {"factor": 0.92, "limit": 0.1},
320
+ "MMAs(III)": {"factor": 0.78, "limit": 0.2},
321
+ "MMAs(V)": {"factor": 0.88, "limit": 0.15},
322
+ "DMAs(III)": {"factor": 0.81, "limit": 0.2},
323
+ "DMAs(V)": {"factor": 0.90, "limit": 0.15},
324
+ }
325
+
326
+ # Step 4: Data analysis and quantification
327
+ log += "## 4. Data Analysis and Quantification\n"
328
+
329
+ # Process sample data to identify and quantify arsenic species
330
+ results = {}
331
+
332
+ for sample_id, sample in sample_data.items():
333
+ species_concentrations = {}
334
+
335
+ for species_name, expected_rt in arsenic_species.items():
336
+ # Find the closest retention time in the sample data
337
+ closest_rt = min(sample.keys(), key=lambda rt: abs(rt - expected_rt))
338
+
339
+ # Check if the retention time is within an acceptable range (±0.3 min)
340
+ if abs(closest_rt - expected_rt) <= 0.3:
341
+ # Calculate concentration using calibration factor
342
+ intensity = sample[closest_rt]
343
+ concentration = intensity * calibration_data[species_name]["factor"]
344
+
345
+ # Check if concentration is above detection limit
346
+ if concentration >= calibration_data[species_name]["limit"]:
347
+ species_concentrations[species_name] = concentration
348
+ else:
349
+ species_concentrations[species_name] = (
350
+ f"<{calibration_data[species_name]['limit']} (Below detection limit)"
351
+ )
352
+ else:
353
+ species_concentrations[species_name] = "Not detected"
354
+
355
+ results[sample_id] = species_concentrations
356
+
357
+ # Convert results to DataFrame for easier handling
358
+ results_df = pd.DataFrame.from_dict(results, orient="index")
359
+
360
+ # Log the detection and quantification process
361
+ log += "- Identified arsenic species based on retention times\n"
362
+ log += "- Quantified concentrations using calibration standards\n"
363
+ log += "- Applied detection limits for each species\n\n"
364
+
365
+ # Step 5: Results
366
+ log += "## 5. Results\n"
367
+ log += "Detected arsenic species and their concentrations (μg/L):\n\n"
368
+
369
+ # Create a results file
370
+ results_filename = (
371
+ f"arsenic_speciation_results_{sample_name.replace(' ', '_')}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv"
372
+ )
373
+ results_df.to_csv(results_filename)
374
+
375
+ log += f"Results have been saved to: {results_filename}\n\n"
376
+
377
+ # Summary of findings
378
+ log += "## 6. Summary\n"
379
+
380
+ # Identify predominant species
381
+ for sample_id, species_data in results.items():
382
+ numeric_concs = {k: v for k, v in species_data.items() if isinstance(v, int | float)}
383
+ if numeric_concs:
384
+ predominant_species = max(numeric_concs.items(), key=lambda x: x[1])
385
+ log += f"Sample {sample_id}: Predominant arsenic species is {predominant_species[0]} "
386
+ log += f"at {predominant_species[1]:.2f} μg/L\n"
387
+ else:
388
+ log += f"Sample {sample_id}: No arsenic species detected above quantification limits\n"
389
+
390
+ return log
391
+
392
+
393
+ def count_bacterial_colonies(image_path, dilution_factor=1, plate_area_cm2=65.0, output_dir="./output"):
394
+ """Count bacterial colonies from an image of agar plate using computer vision techniques.
395
+
396
+ Parameters
397
+ ----------
398
+ image_path : str
399
+ Path to the image file containing bacterial colonies on agar plate
400
+ dilution_factor : float
401
+ Dilution factor of the plated sample (default=1)
402
+ plate_area_cm2 : float
403
+ Area of the agar plate in square centimeters (default=65.0, standard Petri dish)
404
+ output_dir : str
405
+ Directory to save output images and results (default="./output")
406
+
407
+ Returns
408
+ -------
409
+ str
410
+ Research log summarizing the colony counting process and results
411
+
412
+ """
413
+ import os
414
+ from datetime import datetime
415
+
416
+ import cv2
417
+ import numpy as np
418
+ from scipy import ndimage
419
+
420
+ # Create output directory if it doesn't exist
421
+ os.makedirs(output_dir, exist_ok=True)
422
+
423
+ # Load the image
424
+ original_image = cv2.imread(image_path)
425
+ if original_image is None:
426
+ error_message = f"Error: Could not load image from {image_path}. Please check if the file exists and is a valid image format."
427
+ return error_message
428
+
429
+ # Convert to grayscale
430
+ gray = cv2.cvtColor(original_image, cv2.COLOR_BGR2GRAY)
431
+
432
+ # Apply Gaussian blur to reduce noise
433
+ blurred = cv2.GaussianBlur(gray, (7, 7), 0)
434
+
435
+ # Apply threshold to get binary image
436
+ _, thresh = cv2.threshold(blurred, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
437
+
438
+ # Perform morphological operations to remove small noise
439
+ kernel = np.ones((3, 3), np.uint8)
440
+ opening = cv2.morphologyEx(thresh, cv2.MORPH_OPEN, kernel, iterations=2)
441
+
442
+ # Sure background area
443
+ sure_bg = cv2.dilate(opening, kernel, iterations=3)
444
+
445
+ # Finding sure foreground area
446
+ dist_transform = cv2.distanceTransform(opening, cv2.DIST_L2, 5)
447
+ _, sure_fg = cv2.threshold(dist_transform, 0.5 * dist_transform.max(), 255, 0)
448
+ sure_fg = np.uint8(sure_fg)
449
+
450
+ # Finding unknown region
451
+ unknown = cv2.subtract(sure_bg, sure_fg)
452
+
453
+ # Marker labelling
454
+ _, markers = cv2.connectedComponents(sure_fg)
455
+
456
+ # Add one to all labels so that background is 1 instead of 0
457
+ markers = markers + 1
458
+
459
+ # Mark the region of unknown with zero
460
+ markers[unknown == 255] = 0
461
+
462
+ # Apply watershed
463
+ markers = cv2.watershed(original_image, markers)
464
+
465
+ # Count colonies (exclude background marker 1)
466
+ unique_markers = np.unique(markers)
467
+ colony_count = len(unique_markers) - 2 # Subtract background (1) and boundary (-1)
468
+
469
+ # Calculate CFU concentration
470
+ cfu_per_ml = colony_count * dilution_factor
471
+ cfu_per_cm2 = cfu_per_ml / plate_area_cm2
472
+
473
+ # Create output image showing detected colonies
474
+ output_image = original_image.copy()
475
+ output_image[markers == -1] = [0, 0, 255] # Mark boundaries in red
476
+
477
+ # Draw centroids and numbers on colonies
478
+ labeled_array = ndimage.label(markers > 1)[0]
479
+ for i, region in enumerate(ndimage.find_objects(labeled_array)):
480
+ if region is not None:
481
+ y_slice, x_slice = region
482
+ y = (y_slice.start + y_slice.stop) // 2
483
+ x = (x_slice.start + x_slice.stop) // 2
484
+ cv2.circle(output_image, (x, y), 5, (0, 255, 0), -1)
485
+ cv2.putText(
486
+ output_image,
487
+ str(i + 1),
488
+ (x - 10, y - 10),
489
+ cv2.FONT_HERSHEY_SIMPLEX,
490
+ 0.5,
491
+ (0, 255, 0),
492
+ 2,
493
+ )
494
+
495
+ # Save output image
496
+ timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
497
+ output_filename = f"colony_count_{timestamp}.jpg"
498
+ output_path = os.path.join(output_dir, output_filename)
499
+ cv2.imwrite(output_path, output_image)
500
+
501
+ # Generate research log
502
+ log = f"""
503
+ Automated Bacterial Colony Counting - Research Log
504
+ =================================================
505
+ Date and Time: {datetime.now().strftime("%Y-%m-%d %H:%M:%S")}
506
+ Input Image: {image_path}
507
+ Dilution Factor: {dilution_factor}
508
+ Plate Area: {plate_area_cm2} cm²
509
+
510
+ Methodology:
511
+ 1. Loaded and converted image to grayscale
512
+ 2. Applied Gaussian blur to reduce noise
513
+ 3. Used Otsu's thresholding to separate colonies from background
514
+ 4. Performed morphological operations to clean the image
515
+ 5. Applied watershed algorithm to separate touching colonies
516
+ 6. Counted unique colony markers
517
+
518
+ Results:
519
+ - Total Colony Count: {colony_count} CFUs
520
+ - Concentration: {cfu_per_ml:.2f} CFU/ml
521
+ - Area Density: {cfu_per_cm2:.2f} CFU/cm²
522
+
523
+ Output visualization saved as: {output_filename}
524
+ """
525
+
526
+ return log
527
+
528
+
529
+ def annotate_bacterial_genome(
530
+ genome_file_path,
531
+ output_dir="annotation_results",
532
+ genus="",
533
+ species="",
534
+ strain="",
535
+ prefix="",
536
+ ):
537
+ """Annotate a bacterial genome using Prokka to identify genes, proteins, and functional features.
538
+
539
+ Parameters
540
+ ----------
541
+ genome_file_path : str
542
+ Path to the assembled genome sequence file in FASTA format
543
+ output_dir : str, optional
544
+ Directory where annotation results will be saved (default: "annotation_results")
545
+ genus : str, optional
546
+ Genus name for the organism (default: "")
547
+ species : str, optional
548
+ Species name for the organism (default: "")
549
+ strain : str, optional
550
+ Strain identifier (default: "")
551
+ prefix : str, optional
552
+ Prefix for output files (default: "")
553
+
554
+ Returns
555
+ -------
556
+ str
557
+ Research log summarizing the annotation process and results
558
+
559
+ """
560
+ import os
561
+ import subprocess
562
+ import time
563
+
564
+ # Create output directory if it doesn't exist
565
+ os.makedirs(output_dir, exist_ok=True)
566
+
567
+ # Generate a default prefix if not provided
568
+ if not prefix:
569
+ prefix = f"annotation_{int(time.time())}"
570
+
571
+ # Build the Prokka command
572
+ prokka_cmd = [
573
+ "prokka",
574
+ genome_file_path,
575
+ "--outdir",
576
+ output_dir,
577
+ "--prefix",
578
+ prefix,
579
+ ]
580
+
581
+ # Add organism information if provided
582
+ if genus:
583
+ prokka_cmd.extend(["--genus", genus])
584
+ if species:
585
+ prokka_cmd.extend(["--species", species])
586
+ if strain:
587
+ prokka_cmd.extend(["--strain", strain])
588
+
589
+ # Run Prokka
590
+ start_time = time.time()
591
+ try:
592
+ result = subprocess.run(
593
+ prokka_cmd,
594
+ check=True,
595
+ capture_output=True,
596
+ text=True,
597
+ )
598
+ success = True
599
+ prokka_output = result.stdout
600
+ except subprocess.CalledProcessError as e:
601
+ success = False
602
+ prokka_output = e.stderr
603
+ except FileNotFoundError:
604
+ return "ERROR: Prokka is not installed or not in PATH. Please install Prokka first."
605
+
606
+ # Calculate runtime
607
+ runtime = time.time() - start_time
608
+
609
+ # Check if annotation was successful
610
+ if not success:
611
+ return f"ERROR: Genome annotation failed.\n\nProkka output:\n{prokka_output}"
612
+
613
+ # Parse annotation summary from Prokka output
614
+ feature_counts = {}
615
+ for line in prokka_output.split("\n"):
616
+ if "Found" in line and ":" in line:
617
+ feature_type = line.split("Found")[1].split(":")[0].strip()
618
+ count = line.split(":")[-1].strip()
619
+ feature_counts[feature_type] = count
620
+
621
+ # Generate research log
622
+ log = f"""
623
+ GENOME ANNOTATION RESEARCH LOG
624
+
625
+ Input:
626
+ - Genome file: {genome_file_path}
627
+
628
+ Annotation Process:
629
+ - Tool: Prokka
630
+ - Runtime: {runtime:.2f} seconds
631
+ - Output directory: {output_dir}
632
+
633
+ Annotation Results:
634
+ """
635
+
636
+ # Add feature counts to log
637
+ if feature_counts:
638
+ for feature, count in feature_counts.items():
639
+ log += f"- {feature}: {count}\n"
640
+
641
+ # List output files
642
+ log += "\nOutput Files:\n"
643
+ for file in os.listdir(output_dir):
644
+ if file.startswith(prefix):
645
+ file_path = os.path.join(output_dir, file)
646
+ file_size = os.path.getsize(file_path) / 1024 # Size in KB
647
+ log += f"- {file} ({file_size:.1f} KB)\n"
648
+
649
+ # Add explanation of key files
650
+ log += """
651
+ Key Output Files:
652
+ - .gff: Annotation in GFF3 format (contains all genomic features)
653
+ - .gbk: Annotation in GenBank format
654
+ - .faa: Protein sequences in FASTA format
655
+ - .ffn: Nucleotide sequences of genes in FASTA format
656
+ - .txt: Summary statistics of the annotation
657
+ """
658
+
659
+ return log
660
+
661
+
662
+ def enumerate_bacterial_cfu_by_serial_dilution(
663
+ initial_sample_volume_ml=1.0,
664
+ estimated_concentration=1e8,
665
+ dilution_factor=10,
666
+ num_dilutions=8,
667
+ spots_per_dilution=3,
668
+ output_file="cfu_enumeration_results.csv",
669
+ ):
670
+ """Quantify bacterial concentration (CFU/mL) using serial dilutions and spot plating.
671
+
672
+ Parameters
673
+ ----------
674
+ initial_sample_volume_ml : float
675
+ Volume of the initial bacterial sample in milliliters
676
+ estimated_concentration : float
677
+ Estimated concentration of bacteria in the initial sample (CFU/mL)
678
+ dilution_factor : int
679
+ Factor by which each dilution reduces the concentration (typically 10)
680
+ num_dilutions : int
681
+ Number of serial dilutions to perform
682
+ spots_per_dilution : int
683
+ Number of replicate spots to plate for each dilution
684
+ output_file : str
685
+ Filename to save the CFU enumeration results
686
+
687
+ Returns
688
+ -------
689
+ str
690
+ Research log summarizing the CFU enumeration process
691
+
692
+ """
693
+ import numpy as np
694
+ import pandas as pd
695
+
696
+ # Generate log
697
+ log = "# Bacterial CFU Enumeration via Serial Dilutions and Spot Plating\n\n"
698
+
699
+ # Step 1: Prepare serial dilutions
700
+ log += "## Step 1: Serial Dilution Preparation\n"
701
+ log += f"- Initial sample volume: {initial_sample_volume_ml} mL\n"
702
+ log += f"- Estimated concentration: {estimated_concentration:.2e} CFU/mL\n"
703
+ log += f"- Dilution factor: {dilution_factor}\n"
704
+ log += f"- Number of dilutions: {num_dilutions}\n\n"
705
+
706
+ # Calculate theoretical concentrations at each dilution
707
+ dilution_concentrations = []
708
+ for i in range(num_dilutions + 1): # +1 to include the undiluted sample
709
+ conc = estimated_concentration / (dilution_factor**i)
710
+ dilution_concentrations.append(conc)
711
+ dilution_name = "Undiluted" if i == 0 else f"10^-{i}"
712
+ log += f" {dilution_name}: {conc:.2e} CFU/mL\n"
713
+
714
+ # Step 2: Spot plating
715
+ log += "\n## Step 2: Spot Plating\n"
716
+ log += f"- Spots per dilution: {spots_per_dilution}\n"
717
+ log += "- Spotting 10 μL from each dilution onto agar plates\n\n"
718
+
719
+ # Simulate bacterial growth with some randomness to mimic real-world variation
720
+ np.random.seed(42) # For reproducibility
721
+
722
+ # Create dataframe to store results
723
+ results = []
724
+
725
+ # For each dilution, simulate spotting and counting
726
+ for i in range(num_dilutions + 1):
727
+ dilution_name = "Undiluted" if i == 0 else f"10^-{i}"
728
+ expected_cfu_per_spot = dilution_concentrations[i] * 0.01 # 10 μL = 0.01 mL
729
+
730
+ # Simulate multiple spots per dilution
731
+ spot_counts = []
732
+ for spot in range(spots_per_dilution):
733
+ # Add some randomness to the counts (Poisson distribution for bacterial counts)
734
+ if expected_cfu_per_spot > 300:
735
+ # Too many to count (TMTC)
736
+ count = "TMTC"
737
+ spot_counts.append(count)
738
+ elif expected_cfu_per_spot < 1:
739
+ # Simulate low probability events
740
+ count = np.random.poisson(expected_cfu_per_spot)
741
+ spot_counts.append(count)
742
+ else:
743
+ # Normal counting range
744
+ count = np.random.poisson(expected_cfu_per_spot)
745
+ spot_counts.append(count)
746
+
747
+ results.append(
748
+ {
749
+ "Dilution": dilution_name,
750
+ "Dilution_Factor": dilution_factor**i,
751
+ "Spot": spot + 1,
752
+ "CFU_Count": count,
753
+ }
754
+ )
755
+
756
+ # Convert results to dataframe
757
+ df = pd.DataFrame(results)
758
+
759
+ # Step 3: Colony counting and CFU calculation
760
+ log += "## Step 3: Colony Counting and CFU Calculation\n\n"
761
+
762
+ # Find the first dilution with countable colonies (between 3 and 300 CFU)
763
+ countable_dilutions = []
764
+
765
+ for i in range(num_dilutions + 1):
766
+ dilution_name = "Undiluted" if i == 0 else f"10^-{i}"
767
+ dilution_data = df[df["Dilution"] == dilution_name]
768
+
769
+ # Check if counts are numeric (not TMTC)
770
+ numeric_counts = [c for c in dilution_data["CFU_Count"] if isinstance(c, int | float)]
771
+
772
+ if numeric_counts:
773
+ avg_count = sum(numeric_counts) / len(numeric_counts)
774
+ if 3 <= avg_count <= 300:
775
+ countable_dilutions.append(
776
+ {
777
+ "Dilution": dilution_name,
778
+ "Dilution_Factor": dilution_factor**i,
779
+ "Average_CFU": avg_count,
780
+ "CFU_per_mL": avg_count * 100 * (dilution_factor**i), # × 100 to convert 10 μL to mL
781
+ }
782
+ )
783
+
784
+ # Calculate final CFU/mL based on countable dilutions
785
+ if countable_dilutions:
786
+ countable_df = pd.DataFrame(countable_dilutions)
787
+
788
+ # Log the counts for each countable dilution
789
+ for _, row in countable_df.iterrows():
790
+ log += f"Dilution {row['Dilution']}: Average CFU per spot = {row['Average_CFU']:.1f}\n"
791
+ log += f" Calculated concentration: {row['CFU_per_mL']:.2e} CFU/mL\n\n"
792
+
793
+ # Calculate the final CFU/mL as the average of all countable dilutions
794
+ final_cfu = countable_df["CFU_per_mL"].mean()
795
+ log += "## Final Result\n"
796
+ log += f"Original sample concentration: {final_cfu:.2e} CFU/mL\n"
797
+ else:
798
+ log += "No countable dilutions found. Consider adjusting the dilution series.\n"
799
+ final_cfu = None
800
+
801
+ # Save results to CSV
802
+ df.to_csv(output_file, index=False)
803
+ log += f"\nDetailed results saved to: {output_file}\n"
804
+
805
+ return log
806
+
807
+
808
+ def model_bacterial_growth_dynamics(
809
+ initial_population,
810
+ growth_rate,
811
+ clearance_rate,
812
+ niche_size,
813
+ simulation_time=24,
814
+ time_step=0.1,
815
+ ):
816
+ """Model bacterial population dynamics over time using ordinary differential equations.
817
+
818
+ Parameters
819
+ ----------
820
+ initial_population : float
821
+ Initial bacterial population size (CFU/ml or cells)
822
+ growth_rate : float
823
+ Bacterial growth rate (per hour)
824
+ clearance_rate : float
825
+ Rate at which bacteria are cleared from the system (per hour)
826
+ niche_size : float
827
+ Maximum carrying capacity of the environment (CFU/ml or cells)
828
+ simulation_time : float, optional
829
+ Total simulation time in hours (default: 24)
830
+ time_step : float, optional
831
+ Time step for simulation output (default: 0.1)
832
+
833
+ Returns
834
+ -------
835
+ str
836
+ Research log summarizing the bacterial growth dynamics simulation
837
+
838
+ """
839
+ import numpy as np
840
+ import pandas as pd
841
+ from scipy.integrate import solve_ivp
842
+
843
+ # Define the ODE system for bacterial growth
844
+ def bacterial_dynamics(t, N):
845
+ # Logistic growth with clearance
846
+ dNdt = growth_rate * N * (1 - N / niche_size) - clearance_rate * N
847
+ return dNdt
848
+
849
+ # Time points for simulation
850
+ t_span = (0, simulation_time)
851
+ t_eval = np.arange(0, simulation_time + time_step, time_step)
852
+
853
+ # Solve the ODE system
854
+ solution = solve_ivp(bacterial_dynamics, t_span, [initial_population], t_eval=t_eval, method="RK45")
855
+
856
+ # Extract results
857
+ time_points = solution.t
858
+ population_size = solution.y[0]
859
+
860
+ # Calculate key metrics
861
+ max_population = np.max(population_size)
862
+ final_population = population_size[-1]
863
+
864
+ # Determine if population reached steady state
865
+ # (defined as less than 1% change in the last 10% of simulation time)
866
+ last_index = int(len(population_size) * 0.9)
867
+ population_change = abs(population_size[-1] - population_size[last_index]) / population_size[last_index]
868
+ steady_state_reached = population_change < 0.01
869
+
870
+ # Save results to CSV
871
+ results_df = pd.DataFrame({"Time (hours)": time_points, "Population Size": population_size})
872
+
873
+ filename = "bacterial_growth_dynamics.csv"
874
+ results_df.to_csv(filename, index=False)
875
+
876
+ # Generate research log
877
+ log = f"""Bacterial Growth Dynamics Simulation Results:
878
+
879
+ Initial conditions:
880
+ - Starting population: {initial_population:.2e} cells
881
+ - Growth rate: {growth_rate:.2f} per hour
882
+ - Clearance rate: {clearance_rate:.2f} per hour
883
+ - Niche size (carrying capacity): {niche_size:.2e} cells
884
+ - Simulation time: {simulation_time} hours
885
+
886
+ Results:
887
+ - Maximum population reached: {max_population:.2e} cells
888
+ - Final population: {final_population:.2e} cells
889
+ - Steady state {"reached" if steady_state_reached else "not reached"}
890
+
891
+ The complete population dynamics data has been saved to '{filename}'.
892
+ """
893
+
894
+ return log
895
+
896
+
897
+ def quantify_biofilm_biomass_crystal_violet(od_values, sample_names=None, control_index=0, save_path=None):
898
+ """Quantifies biofilm biomass using crystal violet staining assay data.
899
+
900
+ Parameters
901
+ ----------
902
+ od_values : list or numpy.ndarray
903
+ Optical density measurements from crystal violet staining.
904
+ Each value represents the absorbance reading for a sample.
905
+ sample_names : list, optional
906
+ Names of the biofilm samples corresponding to od_values.
907
+ If None, samples will be labeled as Sample 1, Sample 2, etc.
908
+ control_index : int, optional
909
+ Index of the negative control sample in od_values. Default is 0.
910
+ save_path : str, optional
911
+ Path to save the results. If None, results won't be saved to a file.
912
+
913
+ Returns
914
+ -------
915
+ str
916
+ Research log detailing the quantification process and results.
917
+
918
+ """
919
+ import os
920
+ from datetime import datetime
921
+
922
+ import numpy as np
923
+ import pandas as pd
924
+ from scipy import stats
925
+
926
+ # Initialize research log
927
+ log = "## Biofilm Biomass Quantification using Crystal Violet Staining\n"
928
+ log += f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
929
+
930
+ # Convert input to numpy array for processing
931
+ od_values = np.array(od_values, dtype=float)
932
+
933
+ # Generate sample names if not provided
934
+ if sample_names is None:
935
+ sample_names = [f"Sample {i + 1}" for i in range(len(od_values))]
936
+
937
+ log += "### Samples Analyzed:\n"
938
+ for i, name in enumerate(sample_names):
939
+ log += f"- {name}: OD = {od_values[i]:.4f}\n"
940
+
941
+ # Calculate normalized values (subtract control)
942
+ control_value = od_values[control_index]
943
+ normalized_values = od_values - control_value
944
+
945
+ log += "\n### Normalization:\n"
946
+ log += f"- Control sample: {sample_names[control_index]} (OD = {control_value:.4f})\n"
947
+ log += "- Normalized values (Control subtracted):\n"
948
+
949
+ for i, name in enumerate(sample_names):
950
+ if i != control_index:
951
+ log += f" - {name}: {normalized_values[i]:.4f}\n"
952
+
953
+ # Basic statistical analysis
954
+ mean_biomass = np.mean(normalized_values[normalized_values > 0])
955
+ std_biomass = np.std(normalized_values[normalized_values > 0])
956
+
957
+ log += "\n### Statistical Analysis:\n"
958
+ log += f"- Mean normalized biomass: {mean_biomass:.4f}\n"
959
+ log += f"- Standard deviation: {std_biomass:.4f}\n"
960
+
961
+ # Perform t-test for samples against control
962
+ p_values = []
963
+ log += "\n### Statistical Significance:\n"
964
+
965
+ for i, name in enumerate(sample_names):
966
+ if i != control_index:
967
+ # Using one-sample t-test against 0 (normalized control value)
968
+ t_stat, p_val = stats.ttest_1samp([normalized_values[i]], 0)
969
+ p_values.append(p_val)
970
+ significance = "significant" if p_val < 0.05 else "not significant"
971
+ log += f"- {name} vs Control: p-value = {p_val:.4f} ({significance})\n"
972
+
973
+ # Create a summary dataframe
974
+ results_df = pd.DataFrame(
975
+ {
976
+ "Sample": sample_names,
977
+ "OD_Value": od_values,
978
+ "Normalized_Value": normalized_values,
979
+ }
980
+ )
981
+
982
+ # Save results if path is provided
983
+ if save_path:
984
+ results_file = os.path.join(
985
+ save_path,
986
+ f"biofilm_biomass_results_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv",
987
+ )
988
+ results_df.to_csv(results_file, index=False)
989
+ log += "\n### Results Saved:\n"
990
+ log += f"- Data saved to: {results_file}\n"
991
+
992
+ log += "\n### Conclusion:\n"
993
+ log += "- Crystal violet staining assay successfully quantified biofilm biomass.\n"
994
+ log += f"- Samples showed varying levels of biofilm formation with mean biomass of {mean_biomass:.4f} ± {std_biomass:.4f}.\n"
995
+
996
+ return log
997
+
998
+
999
+ def segment_and_analyze_microbial_cells(image_path, output_dir="./output", min_cell_size=50):
1000
+ """Perform automated cell segmentation and quantify morphological metrics from fluorescence microscopy images.
1001
+
1002
+ Parameters
1003
+ ----------
1004
+ image_path : str
1005
+ Path to the fluorescence microscopy image file
1006
+ output_dir : str, optional
1007
+ Directory to save output files (default: './output')
1008
+ min_cell_size : int, optional
1009
+ Minimum cell size in pixels to filter noise (default: 50)
1010
+
1011
+ Returns
1012
+ -------
1013
+ str
1014
+ Research log summarizing the segmentation process, metrics calculated, and output file paths
1015
+
1016
+ """
1017
+ import os
1018
+
1019
+ import numpy as np
1020
+ import pandas as pd
1021
+ from scipy import ndimage
1022
+ from skimage import color, filters, io, measure, morphology, segmentation
1023
+
1024
+ # Create output directory if it doesn't exist
1025
+ os.makedirs(output_dir, exist_ok=True)
1026
+
1027
+ # Step 1: Load and preprocess the image
1028
+ image = io.imread(image_path)
1029
+ if len(image.shape) > 2: # Convert to grayscale if RGB
1030
+ image = color.rgb2gray(image)
1031
+
1032
+ # Step 2: Enhance contrast and denoise
1033
+ image_smooth = filters.gaussian(image, sigma=1)
1034
+
1035
+ # Step 3: Thresholding to separate cells from background
1036
+ threshold_value = filters.threshold_otsu(image_smooth)
1037
+ binary_mask = image_smooth > threshold_value
1038
+
1039
+ # Step 4: Clean binary mask (remove small objects and fill holes)
1040
+ binary_mask = morphology.remove_small_objects(binary_mask, min_size=min_cell_size)
1041
+ binary_mask = morphology.binary_closing(binary_mask, morphology.disk(2))
1042
+ binary_mask = ndimage.binary_fill_holes(binary_mask)
1043
+
1044
+ # Step 5: Watershed segmentation for separating touching cells
1045
+ distance = ndimage.distance_transform_edt(binary_mask)
1046
+ local_max = morphology.local_maxima(distance)
1047
+ markers = measure.label(local_max)
1048
+ segmented_cells = segmentation.watershed(-distance, markers, mask=binary_mask)
1049
+
1050
+ # Step 6: Measure cell properties
1051
+ props = measure.regionprops_table(
1052
+ segmented_cells,
1053
+ intensity_image=image,
1054
+ properties=[
1055
+ "label",
1056
+ "area",
1057
+ "perimeter",
1058
+ "eccentricity",
1059
+ "major_axis_length",
1060
+ "minor_axis_length",
1061
+ "mean_intensity",
1062
+ "max_intensity",
1063
+ ],
1064
+ )
1065
+
1066
+ # Calculate circularity (4π × area / perimeter²)
1067
+ props["circularity"] = 4 * np.pi * props["area"] / (props["perimeter"] ** 2)
1068
+
1069
+ # Step 7: Save results
1070
+ # Save segmentation image
1071
+ segmentation_filename = os.path.join(output_dir, "segmented_cells.png")
1072
+ segmentation_image = color.label2rgb(segmented_cells, image, alpha=0.3, bg_label=0)
1073
+ io.imsave(segmentation_filename, (segmentation_image * 255).astype(np.uint8))
1074
+
1075
+ # Save metrics to CSV
1076
+ metrics_filename = os.path.join(output_dir, "cell_metrics.csv")
1077
+ metrics_df = pd.DataFrame(props)
1078
+ metrics_df.to_csv(metrics_filename, index=False)
1079
+
1080
+ # Step 8: Generate summary statistics
1081
+ cell_count = len(np.unique(segmented_cells)) - 1 # Subtract background
1082
+ avg_cell_area = np.mean(props["area"])
1083
+ avg_circularity = np.mean(props["circularity"])
1084
+
1085
+ # Create research log
1086
+ log = f"""
1087
+ Cell Segmentation and Morphology Analysis Research Log:
1088
+ ------------------------------------------------------
1089
+ Image processed: {image_path}
1090
+ Segmentation method: Otsu thresholding + Watershed
1091
+
1092
+ Results Summary:
1093
+ - Number of cells detected: {cell_count}
1094
+ - Average cell area: {avg_cell_area:.2f} pixels²
1095
+ - Average cell circularity: {avg_circularity:.4f}
1096
+ - Size range: {np.min(props["area"]):.2f} to {np.max(props["area"]):.2f} pixels²
1097
+
1098
+ Cell morphology metrics have been calculated including:
1099
+ - Area
1100
+ - Perimeter
1101
+ - Eccentricity
1102
+ - Major/minor axis lengths
1103
+ - Circularity
1104
+
1105
+ Output files:
1106
+ - Segmentation image: {segmentation_filename}
1107
+ - Detailed metrics: {metrics_filename}
1108
+ """
1109
+
1110
+ return log
1111
+
1112
+
1113
+ def segment_cells_with_deep_learning(
1114
+ image_path,
1115
+ model_type="bact_fluor_omni",
1116
+ diameter=None,
1117
+ save_dir="segmentation_results",
1118
+ ):
1119
+ """Perform cell segmentation on fluorescence microscopy images using deep learning.
1120
+
1121
+ Uses pre-trained models from the Cellpose/Omnipose library to identify and segment
1122
+ individual cells in fluorescence microscopy images.
1123
+
1124
+ Parameters
1125
+ ----------
1126
+ image_path : str
1127
+ Path to the fluorescence microscopy image file
1128
+ model_type : str, optional
1129
+ Name of the pre-trained model to use (default: 'bact_fluor_omni')
1130
+ Options include: 'bact_fluor_omni', 'cyto', 'nuclei', etc.
1131
+ diameter : float, optional
1132
+ Expected diameter of cells in pixels. If None, diameter is automatically estimated.
1133
+ save_dir : str, optional
1134
+ Directory to save segmentation results (default: 'segmentation_results')
1135
+
1136
+ Returns
1137
+ -------
1138
+ str
1139
+ Research log detailing the segmentation process and results
1140
+
1141
+ """
1142
+ import os
1143
+ from datetime import datetime
1144
+
1145
+ import matplotlib.pyplot as plt
1146
+ import numpy as np
1147
+ from cellpose import models
1148
+ from skimage import io
1149
+
1150
+ # Create output directory if it doesn't exist
1151
+ os.makedirs(save_dir, exist_ok=True)
1152
+
1153
+ # Start research log
1154
+ log = "# Cell Segmentation Research Log\n"
1155
+ log += f"Date: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n"
1156
+
1157
+ # Load the image
1158
+ log += "## Loading Image\n"
1159
+ log += f"Image path: {image_path}\n"
1160
+
1161
+ try:
1162
+ img = io.imread(image_path)
1163
+ log += f"Image loaded successfully. Shape: {img.shape}\n\n"
1164
+ except Exception as e:
1165
+ log += f"Error loading image: {str(e)}\n"
1166
+ return log
1167
+
1168
+ # Prepare image for model
1169
+ if len(img.shape) > 2 and img.shape[2] > 1:
1170
+ # If RGB, convert to grayscale for single channel
1171
+ img_model = img[:, :, 0] if img.shape[2] >= 3 else img
1172
+ log += "Using first channel of multi-channel image for segmentation.\n\n"
1173
+ else:
1174
+ img_model = img
1175
+
1176
+ # Initialize model
1177
+ log += "## Initializing Model\n"
1178
+ log += f"Model type: {model_type}\n"
1179
+
1180
+ try:
1181
+ model = models.CellposeModel(model_type=model_type, gpu=False)
1182
+ log += "Model initialized successfully.\n\n"
1183
+ except Exception as e:
1184
+ log += f"Error initializing model: {str(e)}\n"
1185
+ return log
1186
+
1187
+ # Run segmentation
1188
+ log += "## Performing Segmentation\n"
1189
+
1190
+ try:
1191
+ channels = [0, 0] # First channel for cell detection, no second channel
1192
+
1193
+ log += "Estimated cell diameter: "
1194
+ if diameter is None:
1195
+ log += "Auto-estimating\n"
1196
+ else:
1197
+ log += f"{diameter} pixels\n"
1198
+
1199
+ # Adjust the unpacking based on the expected return values
1200
+ results = model.eval(
1201
+ img_model,
1202
+ diameter=diameter,
1203
+ channels=channels,
1204
+ flow_threshold=0.4,
1205
+ do_3D=False,
1206
+ )
1207
+
1208
+ # Check the number of returned values
1209
+ if len(results) == 3:
1210
+ masks, flows, diams = results # Adjusted for 3 return values
1211
+ styles = None # If styles are not returned, set to None
1212
+ elif len(results) == 4:
1213
+ masks, flows, styles, diams = results # Original unpacking
1214
+ else:
1215
+ raise ValueError(f"Unexpected number of return values from model.eval(): {len(results)}")
1216
+
1217
+ if diameter is None:
1218
+ log += f"Auto-estimated cell diameter: {diams[0]:.2f} pixels\n"
1219
+
1220
+ cell_count = len(np.unique(masks)) - 1 # Subtract 1 for background
1221
+ log += f"Segmentation complete. Detected {cell_count} cells.\n\n"
1222
+ except Exception as e:
1223
+ log += f"Error during segmentation: {str(e)}\n"
1224
+ return log
1225
+
1226
+ # Save results
1227
+ log += "## Saving Results\n"
1228
+
1229
+ # Save mask image
1230
+ mask_file = os.path.join(save_dir, f"masks_{os.path.basename(image_path)}")
1231
+ io.imsave(mask_file, masks.astype(np.uint16))
1232
+ log += f"Cell masks saved to: {mask_file}\n"
1233
+
1234
+ # Create and save outlines image
1235
+ plt.figure(figsize=(10, 8))
1236
+ plt.imshow(img_model, cmap="gray")
1237
+
1238
+ # Generate outlines from masks
1239
+ from skimage.segmentation import find_boundaries
1240
+
1241
+ find_boundaries(masks, mode="outer")
1242
+ plt.contour(masks, levels=np.unique(masks), colors="r", linewidths=0.5)
1243
+
1244
+ outline_file = os.path.join(save_dir, f"outlines_{os.path.basename(image_path)}")
1245
+ plt.axis("off")
1246
+ plt.savefig(outline_file, bbox_inches="tight", pad_inches=0)
1247
+ plt.close()
1248
+ log += f"Cell outlines overlaid on original image saved to: {outline_file}\n\n"
1249
+
1250
+ # Final statistics
1251
+ log += "## Segmentation Statistics\n"
1252
+ log += f"Total cells detected: {cell_count}\n"
1253
+
1254
+ # Calculate additional metrics
1255
+ if cell_count > 0:
1256
+ cell_areas = [np.sum(masks == i) for i in range(1, cell_count + 1)]
1257
+ avg_cell_area = np.mean(cell_areas)
1258
+ std_cell_area = np.std(cell_areas)
1259
+ log += f"Average cell area: {avg_cell_area:.2f} pixels\n"
1260
+ log += f"Standard deviation of cell area: {std_cell_area:.2f} pixels\n"
1261
+
1262
+ return log
1263
+
1264
+
1265
+ def simulate_generalized_lotka_volterra_dynamics(
1266
+ initial_abundances,
1267
+ growth_rates,
1268
+ interaction_matrix,
1269
+ time_points,
1270
+ output_file="glv_simulation_results.csv",
1271
+ ):
1272
+ """Simulate microbial community dynamics using the Generalized Lotka-Volterra (gLV) model.
1273
+
1274
+ Parameters
1275
+ ----------
1276
+ initial_abundances : numpy.ndarray
1277
+ Initial abundances of each microbial species (1D array)
1278
+ growth_rates : numpy.ndarray
1279
+ Intrinsic growth rates for each microbial species (1D array)
1280
+ interaction_matrix : numpy.ndarray
1281
+ Matrix of interaction coefficients where A[i,j] represents the effect of species j on species i (2D array)
1282
+ time_points : numpy.ndarray
1283
+ Time points at which to evaluate the model
1284
+ output_file : str, optional
1285
+ Filename to save the simulation results (default: "glv_simulation_results.csv")
1286
+
1287
+ Returns
1288
+ -------
1289
+ str
1290
+ Research log summarizing the simulation process and results
1291
+
1292
+ """
1293
+ import numpy as np
1294
+ import pandas as pd
1295
+ from scipy.integrate import odeint
1296
+
1297
+ # Check input dimensions
1298
+ n_species = len(initial_abundances)
1299
+ if len(growth_rates) != n_species or interaction_matrix.shape != (
1300
+ n_species,
1301
+ n_species,
1302
+ ):
1303
+ raise ValueError(
1304
+ "Dimensions mismatch: growth_rates and interaction_matrix must match initial_abundances dimensions"
1305
+ )
1306
+
1307
+ # Define the gLV differential equations
1308
+ def glv_equations(abundances, t, growth_rates, interaction_matrix):
1309
+ # Calculate growth and interaction terms for each species
1310
+ # dx_i/dt = r_i * x_i + x_i * sum(A_ij * x_j)
1311
+ dx_dt = abundances * (growth_rates + np.dot(interaction_matrix, abundances))
1312
+ return dx_dt
1313
+
1314
+ # Integrate the ODE system
1315
+ simulation_results = odeint(
1316
+ glv_equations,
1317
+ initial_abundances,
1318
+ time_points,
1319
+ args=(growth_rates, interaction_matrix),
1320
+ )
1321
+
1322
+ # Create a DataFrame with the results
1323
+ columns = [f"Species_{i + 1}" for i in range(n_species)]
1324
+ results_df = pd.DataFrame(simulation_results, columns=columns)
1325
+ results_df.insert(0, "Time", time_points)
1326
+
1327
+ # Save results to CSV
1328
+ results_df.to_csv(output_file, index=False)
1329
+
1330
+ # Generate summary statistics
1331
+ final_abundances = simulation_results[-1]
1332
+ dominant_species = np.argmax(final_abundances) + 1
1333
+ extinct_species = sum(final_abundances < 1e-6)
1334
+
1335
+ # Create research log
1336
+ log = f"""
1337
+ Generalized Lotka-Volterra (gLV) Model Simulation Results:
1338
+ ------------------------------------------------------
1339
+ Number of microbial species: {n_species}
1340
+ Simulation time range: {time_points[0]} to {time_points[-1]}
1341
+ Number of time points: {len(time_points)}
1342
+
1343
+ Summary of dynamics:
1344
+ - Initial total abundance: {np.sum(initial_abundances):.4f}
1345
+ - Final total abundance: {np.sum(final_abundances):.4f}
1346
+ - Dominant species at end of simulation: Species_{dominant_species} (abundance: {final_abundances[dominant_species - 1]:.4f})
1347
+ - Number of species with near-zero abundance (< 1e-6): {extinct_species}
1348
+
1349
+ Simulation results have been saved to: {output_file}
1350
+ """
1351
+
1352
+ return log
1353
+
1354
+
1355
+ def predict_rna_secondary_structure(rna_sequence, output_prefix="rna_structure"):
1356
+ """Predict the secondary structure of an RNA molecule using ViennaRNA.
1357
+
1358
+ Parameters
1359
+ ----------
1360
+ rna_sequence : str
1361
+ The RNA sequence (consisting of A, U, G, C nucleotides)
1362
+ output_prefix : str, optional
1363
+ Prefix for output files (default: "rna_structure")
1364
+
1365
+ Returns
1366
+ -------
1367
+ str
1368
+ A research log summarizing the prediction process and results
1369
+
1370
+ """
1371
+ try:
1372
+ import RNA
1373
+ except ImportError:
1374
+ return "ERROR: ViennaRNA Python package not installed. Install with 'pip install ViennaRNA'"
1375
+
1376
+ # Validate input sequence
1377
+ rna_sequence = rna_sequence.upper().strip()
1378
+ valid_nucleotides = set("AUGC")
1379
+ if not all(nucleotide in valid_nucleotides for nucleotide in rna_sequence):
1380
+ return "ERROR: Invalid RNA sequence. Only A, U, G, C nucleotides are allowed."
1381
+
1382
+ # Predict secondary structure
1383
+ (structure, mfe) = RNA.fold(rna_sequence)
1384
+
1385
+ # Save the structure to a file
1386
+ structure_file = f"{output_prefix}_structure.txt"
1387
+ with open(structure_file, "w") as f:
1388
+ f.write(f"Sequence: {rna_sequence}\n")
1389
+ f.write(f"Structure: {structure}\n")
1390
+ f.write(f"Minimum Free Energy: {mfe} kcal/mol\n")
1391
+
1392
+ # Generate a simple text visualization
1393
+ viz_file = f"{output_prefix}_visualization.txt"
1394
+ with open(viz_file, "w") as f:
1395
+ f.write("Sequence: " + rna_sequence + "\n")
1396
+ f.write("Structure: " + structure + "\n\n")
1397
+
1398
+ # Add a simple representation of stem-loops
1399
+ pairs = []
1400
+ stack = []
1401
+ for i, char in enumerate(structure):
1402
+ if char == "(":
1403
+ stack.append(i)
1404
+ elif char == ")" and stack:
1405
+ left = stack.pop()
1406
+ pairs.append((left, i))
1407
+
1408
+ f.write("Stem-loop structures:\n")
1409
+ for left, right in sorted(pairs):
1410
+ f.write(f"Base pair: {rna_sequence[left]}({left + 1})-{rna_sequence[right]}({right + 1})\n")
1411
+
1412
+ # Create research log
1413
+ log = f"""
1414
+ RNA Secondary Structure Prediction Log:
1415
+ ---------------------------------------
1416
+ 1. Received RNA sequence of length {len(rna_sequence)}
1417
+ 2. Applied ViennaRNA RNAfold algorithm for structure prediction
1418
+ 3. Calculated minimum free energy: {mfe} kcal/mol
1419
+ 4. Structure saved to file: {structure_file}
1420
+ 5. Visualization saved to file: {viz_file}
1421
+
1422
+ Summary:
1423
+ The RNA sequence forms a secondary structure with a minimum free energy of {mfe} kcal/mol.
1424
+ The structure contains {structure.count("(")} base pairs forming stems and loops.
1425
+ See {structure_file} and {viz_file} for detailed structure information.
1426
+ """
1427
+
1428
+ return log
1429
+
1430
+
1431
+ def simulate_microbial_population_dynamics(
1432
+ initial_populations,
1433
+ growth_rates,
1434
+ clearance_rates,
1435
+ carrying_capacities,
1436
+ max_time=100,
1437
+ num_simulations=100,
1438
+ time_points=100,
1439
+ ):
1440
+ """Performs stochastic simulation of microbial population dynamics using the Gillespie algorithm.
1441
+
1442
+ Parameters
1443
+ ----------
1444
+ initial_populations : list of int
1445
+ Initial population sizes for each microbial species
1446
+ growth_rates : list of float
1447
+ Per capita growth rates for each species
1448
+ clearance_rates : list of float
1449
+ Per capita death/clearance rates for each species
1450
+ carrying_capacities : list of float
1451
+ Maximum sustainable population for each species
1452
+ max_time : float, optional
1453
+ Maximum simulation time (default: 100)
1454
+ num_simulations : int, optional
1455
+ Number of stochastic simulations to run (default: 100)
1456
+ time_points : int, optional
1457
+ Number of time points to record for trajectories (default: 100)
1458
+
1459
+ Returns
1460
+ -------
1461
+ str
1462
+ Research log summarizing the simulation results, including extinction probabilities and timelines
1463
+
1464
+ """
1465
+ import numpy as np
1466
+ from scipy import stats
1467
+
1468
+ # Validate inputs
1469
+ num_species = len(initial_populations)
1470
+ if not (len(growth_rates) == len(clearance_rates) == len(carrying_capacities) == num_species):
1471
+ return "Error: All input lists must have the same length (number of species)"
1472
+
1473
+ # Initialize tracking variables
1474
+ extinction_counts = np.zeros(num_species)
1475
+ extinction_times = [[] for _ in range(num_species)]
1476
+
1477
+ # Time points for trajectory recording
1478
+ time_grid = np.linspace(0, max_time, time_points)
1479
+ avg_trajectories = np.zeros((num_species, time_points))
1480
+
1481
+ # Run multiple simulations
1482
+ for _sim in range(num_simulations):
1483
+ # Initialize population and time for this simulation
1484
+ population = np.array(initial_populations, dtype=float)
1485
+ time = 0.0
1486
+
1487
+ # For recording trajectories at specific time points
1488
+ trajectory = np.zeros((num_species, time_points))
1489
+ next_time_point_idx = 0
1490
+
1491
+ # Track which species have gone extinct in this simulation
1492
+ extinct_in_sim = [False] * num_species
1493
+
1494
+ # Run simulation until max_time is reached or all populations are extinct
1495
+ while time < max_time and np.any(population > 0):
1496
+ # Record current state if we've reached the next time point
1497
+ while next_time_point_idx < time_points and time >= time_grid[next_time_point_idx]:
1498
+ trajectory[:, next_time_point_idx] = population
1499
+ next_time_point_idx += 1
1500
+
1501
+ # Calculate event rates
1502
+ # Growth rates are adjusted for carrying capacity (logistic growth)
1503
+ adjusted_growth_rates = [
1504
+ growth_rates[i] * population[i] * (1 - population[i] / carrying_capacities[i])
1505
+ if population[i] > 0
1506
+ else 0
1507
+ for i in range(num_species)
1508
+ ]
1509
+ death_rates = [clearance_rates[i] * population[i] if population[i] > 0 else 0 for i in range(num_species)]
1510
+
1511
+ # Flatten rates for easier processing
1512
+ all_rates = adjusted_growth_rates + death_rates
1513
+ total_rate = sum(all_rates)
1514
+
1515
+ # If no events possible, end simulation
1516
+ if total_rate == 0:
1517
+ break
1518
+
1519
+ # Time until next event (exponentially distributed)
1520
+ dt = np.random.exponential(1.0 / total_rate)
1521
+ time += dt
1522
+
1523
+ # If we've exceeded max_time, break
1524
+ if time > max_time:
1525
+ break
1526
+
1527
+ # Select which event occurs
1528
+ event_idx = np.random.choice(len(all_rates), p=np.array(all_rates) / total_rate)
1529
+
1530
+ # Apply the event
1531
+ if event_idx < num_species: # Growth event
1532
+ population[event_idx] += 1
1533
+ else: # Death event
1534
+ species_idx = event_idx - num_species
1535
+ population[species_idx] -= 1
1536
+
1537
+ # Check for new extinctions
1538
+ if population[species_idx] == 0 and not extinct_in_sim[species_idx]:
1539
+ extinction_counts[species_idx] += 1
1540
+ extinction_times[species_idx].append(time)
1541
+ extinct_in_sim[species_idx] = True
1542
+
1543
+ # Fill in any remaining time points
1544
+ while next_time_point_idx < time_points:
1545
+ trajectory[:, next_time_point_idx] = population
1546
+ next_time_point_idx += 1
1547
+
1548
+ # Add this simulation's trajectory to the average
1549
+ avg_trajectories += trajectory
1550
+
1551
+ # Check for species that never went extinct in this simulation
1552
+ for i in range(num_species):
1553
+ if population[i] > 0 and not extinct_in_sim[i]:
1554
+ # Record as "no extinction" by adding a None to extinction_times
1555
+ extinction_times[i].append(None)
1556
+
1557
+ # Calculate average trajectories
1558
+ avg_trajectories /= num_simulations
1559
+
1560
+ # Calculate extinction probabilities and statistics
1561
+ extinction_probs = extinction_counts / num_simulations
1562
+
1563
+ # Calculate median extinction times (ignoring None values)
1564
+ median_extinction_times = []
1565
+ for times in extinction_times:
1566
+ valid_times = [t for t in times if t is not None]
1567
+ if valid_times:
1568
+ median_extinction_times.append(np.median(valid_times))
1569
+ else:
1570
+ median_extinction_times.append(float("inf"))
1571
+
1572
+ # Generate research log
1573
+ log = "# Microbial Population Dynamics Simulation Results\n\n"
1574
+ log += f"Simulations run: {num_simulations}\n"
1575
+ log += f"Maximum simulation time: {max_time}\n\n"
1576
+
1577
+ log += "## Species Parameters\n"
1578
+ for i in range(num_species):
1579
+ log += f"\nSpecies {i + 1}:\n"
1580
+ log += f" Initial population: {initial_populations[i]}\n"
1581
+ log += f" Growth rate: {growth_rates[i]}\n"
1582
+ log += f" Clearance rate: {clearance_rates[i]}\n"
1583
+ log += f" Carrying capacity: {carrying_capacities[i]}\n"
1584
+
1585
+ log += "\n## Extinction Analysis\n"
1586
+ for i in range(num_species):
1587
+ log += f"\nSpecies {i + 1}:\n"
1588
+ log += f" Extinction probability: {extinction_probs[i]:.2f}\n"
1589
+
1590
+ if extinction_probs[i] > 0:
1591
+ valid_times = [t for t in extinction_times[i] if t is not None]
1592
+ if valid_times:
1593
+ log += f" Median extinction time: {np.median(valid_times):.2f}\n"
1594
+ log += f" Mean extinction time: {np.mean(valid_times):.2f}\n"
1595
+ log += f" Standard deviation: {np.std(valid_times):.2f}\n"
1596
+
1597
+ # Calculate confidence intervals if we have enough data
1598
+ if len(valid_times) >= 10:
1599
+ ci = stats.t.interval(
1600
+ 0.95,
1601
+ len(valid_times) - 1,
1602
+ loc=np.mean(valid_times),
1603
+ scale=stats.sem(valid_times),
1604
+ )
1605
+ log += f" 95% CI for extinction time: ({ci[0]:.2f}, {ci[1]:.2f})\n"
1606
+ else:
1607
+ log += " No extinctions observed in any simulations\n"
1608
+
1609
+ # Save average trajectories to CSV file
1610
+ filename = "population_trajectories.csv"
1611
+ header = "Time," + ",".join([f"Species_{i + 1}" for i in range(num_species)])
1612
+ data = np.column_stack((time_grid, avg_trajectories.T))
1613
+ np.savetxt(filename, data, delimiter=",", header=header, comments="")
1614
+
1615
+ log += "\n## Population Trajectories\n"
1616
+ log += f"Average population trajectories saved to '{filename}'\n"
1617
+
1618
+ return log