m3triq 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # m3t
2
+
3
+ The M3TRIQ CLI — protein-ligand analysis from the terminal.
4
+
5
+ Query self-hosted databases (ChEMBL, FooDB, ZINC), predict ADMET properties, run molecular docking, and submit molecular dynamics simulations — all from the command line.
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ npm install -g m3triq
11
+ ```
12
+
13
+ ## Setup
14
+
15
+ ```bash
16
+ m3t config --key <your-api-key>
17
+ m3t projects # list your projects
18
+ m3t use <project-id> # set active project
19
+ ```
20
+
21
+ ## Databases
22
+
23
+ ### ChEMBL (2.4M compounds, self-hosted)
24
+
25
+ ```bash
26
+ m3t chembl search aspirin
27
+ m3t chembl info CHEMBL25
28
+ m3t chembl targets PDE3B
29
+ m3t chembl binders EGFR --max-value 1000 --save "EGFR Binders"
30
+ m3t chembl activities CHEMBL25 --type IC50
31
+ m3t chembl similar "CC(=O)Oc1ccccc1C(=O)O" --threshold 0.7
32
+ m3t chembl sql "SELECT chembl_id, pref_name FROM molecule_dictionary WHERE pref_name ILIKE '%caffeine%'"
33
+ ```
34
+
35
+ ### FooDB (food compounds, self-hosted)
36
+
37
+ ```bash
38
+ m3t foodb search tomato --limit 20
39
+ m3t foodb info caffeine
40
+ m3t foodb export banana --name "Banana Compounds"
41
+ m3t foodb random --count 50 --group Fruits
42
+ ```
43
+
44
+ ### ZINC (14M purchasable compounds, self-hosted)
45
+
46
+ ```bash
47
+ m3t zinc search --subset drug-like --mw-min 200 --mw-max 500
48
+ m3t zinc info ZINC000000000001
49
+ m3t zinc random --count 20 --subset lead-like
50
+ ```
51
+
52
+ ## ADMET Prediction (41 properties)
53
+
54
+ Self-hosted ADMET-AI model. Predicts absorption, distribution, metabolism, excretion, and toxicity.
55
+
56
+ ```bash
57
+ m3t admet predict "CC(=O)Oc1ccccc1C(=O)O"
58
+ m3t admet batch "CCO" "CC(=O)Oc1ccccc1C(=O)O" "CN1C=NC2=C1C(=O)N(C(=O)N2C)C"
59
+ m3t admet compare "CCO" "CCCO" --name1 Ethanol --name2 Propanol
60
+ ```
61
+
62
+ ## Molecular Docking
63
+
64
+ ```bash
65
+ # Single compound
66
+ m3t dock gnina "CCO" 5NJ8 --cx 10 --cy 20 --cz 30
67
+ m3t dock vina "CCO" 5NJ8 --cx 10 --cy 20 --cz 30
68
+ m3t dock diffdock "CCO" 5NJ8
69
+
70
+ # Batch (CSV with smiles column)
71
+ m3t batch gnina compounds.csv 5NJ8 --cx 10 --cy 20 --cz 30
72
+ ```
73
+
74
+ ## Molecular Dynamics
75
+
76
+ GPU-accelerated simulations to validate docking poses.
77
+
78
+ ```bash
79
+ m3t md run --protein 5NJ8 --ligand-smiles "CCO" --mode quick
80
+ m3t md run --diffdock-job <job-id> --mode standard
81
+ m3t md results <job-id>
82
+ ```
83
+
84
+ ## Structure Prediction
85
+
86
+ ```bash
87
+ m3t predict esmfold MKFLILLFNILCL... # Fast (~10s, max 1024aa)
88
+ m3t predict alphafold2 MKFLILLFNILCL... # Accurate (~5min, max 2048aa)
89
+ ```
90
+
91
+ ## Protein Design
92
+
93
+ ```bash
94
+ m3t design rfantibody 6VXX --hotspots A100,A105 --type nanobody --designs 3
95
+ ```
96
+
97
+ ## Other Commands
98
+
99
+ ```bash
100
+ m3t jobs # list recent jobs
101
+ m3t job <id> # job status & results
102
+ m3t data # list project datasets
103
+ m3t dataset <id> --limit 20 # view dataset rows
104
+ m3t run script.py --packages pandas,httpx --save "Results"
105
+ ```
106
+
107
+ ## JSON Mode
108
+
109
+ Pipe-friendly output for scripting:
110
+
111
+ ```bash
112
+ m3t --json chembl binders PDE3B | jq '.[].canonical_smiles'
113
+ m3t --json zinc search --subset drug-like | jq length
114
+ ```
115
+
116
+ ## Project-per-folder
117
+
118
+ Like `.git`, each directory can be tied to a different M3TRIQ project:
119
+
120
+ ```bash
121
+ cd ~/research/egfr && m3t use <project-id> # writes .m3triq here
122
+ cd ~/research/pde3b && m3t use <project-id> # different project
123
+ m3t use --global <id> # fallback
124
+ ```
125
+
126
+ ## Links
127
+
128
+ - [M3TRIQ Platform](https://console.m3triq.com)
129
+ - [Documentation](https://m3triq.com)
package/dist/cli.js CHANGED
@@ -5,6 +5,7 @@ import { getEffectiveConfig, requireApiKey } from './config.js';
5
5
  import { M3triqClient, AgentsClient } from './client.js';
6
6
  import { registerConfigCommands } from './commands/config-cmd.js';
7
7
  import { registerProjectCommands } from './commands/projects.js';
8
+ import { registerSessionCommands } from './commands/sessions.js';
8
9
  import { registerJobCommands } from './commands/jobs.js';
9
10
  import { registerDockingCommands } from './commands/docking.js';
10
11
  import { registerPredictCommands } from './commands/predict.js';
@@ -12,11 +13,17 @@ import { registerChemblCommands } from './commands/chembl.js';
12
13
  import { registerSandboxCommands } from './commands/sandbox.js';
13
14
  import { registerDesignCommands } from './commands/design.js';
14
15
  import { registerDataCommands } from './commands/data.js';
16
+ import { registerFoodbCommands } from './commands/foodb.js';
17
+ import { registerAdmetCommands } from './commands/admet.js';
18
+ import { registerMdCommands } from './commands/md.js';
19
+ import { registerZincCommands } from './commands/zinc.js';
20
+ import { registerCreditsCommands } from './commands/credits.js';
21
+ import { registerPricingCommands } from './commands/pricing.js';
15
22
  const program = new Command();
16
23
  program
17
24
  .name('m3t')
18
25
  .description('M3TRIQ — protein-ligand analysis from the terminal')
19
- .version('0.1.0')
26
+ .version('0.2.1')
20
27
  .option('--json', 'Output as JSON (machine-readable)')
21
28
  .hook('preAction', (thisCommand) => {
22
29
  const opts = thisCommand.optsWithGlobals();
@@ -25,6 +32,7 @@ program
25
32
  });
26
33
  registerConfigCommands(program);
27
34
  registerProjectCommands(program);
35
+ registerSessionCommands(program);
28
36
  registerJobCommands(program);
29
37
  registerDockingCommands(program);
30
38
  registerPredictCommands(program);
@@ -32,7 +40,26 @@ registerChemblCommands(program);
32
40
  registerSandboxCommands(program);
33
41
  registerDesignCommands(program);
34
42
  registerDataCommands(program);
35
- program.parse();
43
+ registerFoodbCommands(program);
44
+ registerAdmetCommands(program);
45
+ registerMdCommands(program);
46
+ registerZincCommands(program);
47
+ registerCreditsCommands(program);
48
+ registerPricingCommands(program);
49
+ // Catch async errors from all command actions
50
+ program.parseAsync().catch((err) => {
51
+ const message = err.message || String(err);
52
+ if (message.includes('API error') || message.includes('MCP')) {
53
+ process.stderr.write(`Error: ${message}\n`);
54
+ }
55
+ else if (message.includes('fetch failed') || message.includes('ECONNREFUSED')) {
56
+ process.stderr.write(`Error: Could not reach M3TRIQ API. Check your connection and config.\n`);
57
+ }
58
+ else {
59
+ process.stderr.write(`Error: ${message}\n`);
60
+ }
61
+ process.exit(1);
62
+ });
36
63
  /** Create an API client from current config. Call lazily in command handlers. */
37
64
  export function createClient() {
38
65
  const config = getEffectiveConfig();
package/dist/client.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Job, Project, DockingParams, BatchDockingParams, DiffDockParams, StructurePredictionParams, SandboxParams, McpCallResult } from './types.js';
1
+ import type { Job, Project, ChatSession, DockingParams, BatchDockingParams, DiffDockParams, StructurePredictionParams, SandboxParams, McpCallResult, CreditQuota, CreditLogResponse, Boltz2JobParams, PricingCatalog } from './types.js';
2
2
  export declare class M3triqClient {
3
3
  private baseUrl;
4
4
  private apiKey;
@@ -6,6 +6,9 @@ export declare class M3triqClient {
6
6
  private get headers();
7
7
  private request;
8
8
  listProjects(): Promise<Project[]>;
9
+ getProject(projectId: string): Promise<Project>;
10
+ listSessions(projectId: string, limit?: number): Promise<ChatSession[]>;
11
+ getSession(sessionId: string): Promise<ChatSession>;
9
12
  getJob(jobId: string): Promise<Job>;
10
13
  listJobs(projectId: string, limit?: number): Promise<Job[]>;
11
14
  createDockingJob(params: DockingParams): Promise<{
@@ -36,9 +39,21 @@ export declare class M3triqClient {
36
39
  createStructurePrediction(params: StructurePredictionParams): Promise<{
37
40
  job_id: string;
38
41
  }>;
42
+ createBoltz2Job(params: Boltz2JobParams): Promise<{
43
+ job_id: string;
44
+ }>;
39
45
  createSandboxJob(params: SandboxParams): Promise<{
40
46
  job_id: string;
41
47
  }>;
48
+ getCredits(): Promise<CreditQuota>;
49
+ getCreditLog(params?: {
50
+ page?: number;
51
+ pageSize?: number;
52
+ event?: string;
53
+ since?: string;
54
+ until?: string;
55
+ }): Promise<CreditLogResponse>;
56
+ getPricing(): Promise<PricingCatalog>;
42
57
  }
43
58
  /**
44
59
  * Client for the agents service (MCP tool calls).
package/dist/client.js CHANGED
@@ -28,6 +28,17 @@ export class M3triqClient {
28
28
  const data = await this.request('GET', '/api/membership/projects/');
29
29
  return Array.isArray(data) ? data : (data.results || []);
30
30
  }
31
+ async getProject(projectId) {
32
+ return this.request('GET', `/api/membership/projects/${projectId}/`);
33
+ }
34
+ // ── Chat Sessions ─────────────────────────────────────────────
35
+ async listSessions(projectId, limit = 20) {
36
+ const data = await this.request('GET', `/api/membership/sessions/?project=${projectId}&page_size=${limit}`);
37
+ return Array.isArray(data) ? data : (data.results || []);
38
+ }
39
+ async getSession(sessionId) {
40
+ return this.request('GET', `/api/membership/sessions/${sessionId}/`);
41
+ }
31
42
  async getJob(jobId) {
32
43
  return this.request('GET', `/api/jobs/${jobId}/`);
33
44
  }
@@ -94,9 +105,55 @@ export class M3triqClient {
94
105
  : '/api/jobs/create_esmfold_prediction/';
95
106
  return this.request('POST', endpoint, params);
96
107
  }
108
+ async createBoltz2Job(params) {
109
+ // Boltz-2 prediction is run client-side via the agents MCP, then saved here as a completed job.
110
+ // Mirrors the RFantibody internal flow but with status='completed' and result_data already populated.
111
+ const url = `${this.baseUrl}/api/jobs/create_prediction_internal/`;
112
+ const res = await fetch(url, {
113
+ method: 'POST',
114
+ headers: {
115
+ ...this.headers,
116
+ 'X-Internal-Service': 'true',
117
+ },
118
+ body: JSON.stringify({
119
+ project_id: params.project_id,
120
+ job_type: 'boltz2_prediction',
121
+ title: params.title,
122
+ description: params.description ?? '',
123
+ result_data: params.result_data,
124
+ }),
125
+ });
126
+ if (!res.ok) {
127
+ const text = await res.text();
128
+ throw new Error(`API error ${res.status}: ${text.substring(0, 200)}`);
129
+ }
130
+ return res.json();
131
+ }
97
132
  async createSandboxJob(params) {
98
133
  return this.request('POST', '/api/jobs/create_sandbox_job/', params);
99
134
  }
135
+ // ── Credits ──────────────────────────────────────────────────
136
+ async getCredits() {
137
+ return this.request('GET', '/api/membership/user/credits/');
138
+ }
139
+ async getCreditLog(params = {}) {
140
+ const q = new URLSearchParams();
141
+ if (params.page)
142
+ q.set('page', String(params.page));
143
+ if (params.pageSize)
144
+ q.set('page_size', String(params.pageSize));
145
+ if (params.event)
146
+ q.set('event', params.event);
147
+ if (params.since)
148
+ q.set('since', params.since);
149
+ if (params.until)
150
+ q.set('until', params.until);
151
+ const qs = q.toString();
152
+ return this.request('GET', `/api/membership/user/credits/log/${qs ? `?${qs}` : ''}`);
153
+ }
154
+ async getPricing() {
155
+ return this.request('GET', '/api/membership/billing/pricing/');
156
+ }
100
157
  }
101
158
  /**
102
159
  * Client for the agents service (MCP tool calls).
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerAdmetCommands(program: Command): void;
@@ -0,0 +1,134 @@
1
+ import { output } from '../output.js';
2
+ import { createAgentsClient } from '../cli.js';
3
+ import { requireApiKey } from '../config.js';
4
+ export function registerAdmetCommands(program) {
5
+ const admet = program
6
+ .command('admet')
7
+ .description('Predict ADMET properties (self-hosted ADMET-AI, 41 properties)')
8
+ .hook('preAction', () => { requireApiKey(); });
9
+ // ── m3t admet predict <smiles> ────────────────────────────────
10
+ admet
11
+ .command('predict')
12
+ .argument('<smiles>', 'SMILES string of compound')
13
+ .description('Predict ADMET properties for a single compound')
14
+ .action(async (smiles) => {
15
+ const agents = createAgentsClient();
16
+ process.stderr.write('Predicting ADMET properties...\n');
17
+ const data = await agents.callMcpTool('admet', 'predict_admet', { smiles });
18
+ if (typeof data.result === 'string') {
19
+ output(data, data.result);
20
+ return;
21
+ }
22
+ // Try to format structured ADMET results
23
+ const props = extractProperties(data);
24
+ if (props) {
25
+ output(data, formatAdmetResult(smiles, props));
26
+ }
27
+ else {
28
+ output(data, JSON.stringify(data, null, 2));
29
+ }
30
+ });
31
+ // ── m3t admet batch <smiles...> ───────────────────────────────
32
+ admet
33
+ .command('batch')
34
+ .argument('<smiles...>', 'SMILES strings (space-separated, max 100)')
35
+ .description('Predict ADMET for multiple compounds')
36
+ .action(async (smilesList) => {
37
+ if (smilesList.length > 100) {
38
+ process.stderr.write('Error: Maximum 100 compounds per batch.\n');
39
+ process.exit(1);
40
+ }
41
+ const agents = createAgentsClient();
42
+ process.stderr.write(`Predicting ADMET for ${smilesList.length} compounds...\n`);
43
+ const data = await agents.callMcpTool('admet', 'predict_admet_batch', {
44
+ smiles_list: smilesList,
45
+ });
46
+ if (typeof data.result === 'string') {
47
+ output(data, data.result);
48
+ }
49
+ else {
50
+ output(data, JSON.stringify(data, null, 2));
51
+ }
52
+ });
53
+ // ── m3t admet compare <smiles1> <smiles2> ─────────────────────
54
+ admet
55
+ .command('compare')
56
+ .argument('<smiles1>', 'SMILES of first compound')
57
+ .argument('<smiles2>', 'SMILES of second compound')
58
+ .option('--name1 <n>', 'Name for compound 1', 'Compound 1')
59
+ .option('--name2 <n>', 'Name for compound 2', 'Compound 2')
60
+ .description('Compare ADMET profiles of two compounds side-by-side')
61
+ .action(async (smiles1, smiles2, opts) => {
62
+ const agents = createAgentsClient();
63
+ process.stderr.write(`Comparing ${opts.name1} vs ${opts.name2}...\n`);
64
+ const data = await agents.callMcpTool('admet', 'compare_admet', {
65
+ smiles1,
66
+ smiles2,
67
+ name1: opts.name1,
68
+ name2: opts.name2,
69
+ });
70
+ if (typeof data.result === 'string') {
71
+ output(data, data.result);
72
+ }
73
+ else {
74
+ output(data, JSON.stringify(data, null, 2));
75
+ }
76
+ });
77
+ }
78
+ // ── Helpers ───────────────────────────────────────────────────────
79
+ function extractProperties(data) {
80
+ if (data.predictions)
81
+ return data.predictions;
82
+ if (data.properties)
83
+ return data.properties;
84
+ if (data.admet)
85
+ return data.admet;
86
+ if (typeof data.result === 'object' && data.result !== null) {
87
+ return extractProperties(data.result);
88
+ }
89
+ return null;
90
+ }
91
+ function formatAdmetResult(smiles, props) {
92
+ const lines = [`ADMET Prediction: ${smiles}`, ''];
93
+ // Group properties by category if they follow ADMET naming
94
+ const categories = {};
95
+ for (const [key, value] of Object.entries(props)) {
96
+ const category = categorize(key);
97
+ if (!categories[category])
98
+ categories[category] = [];
99
+ categories[category].push([key, formatValue(value)]);
100
+ }
101
+ for (const [category, entries] of Object.entries(categories)) {
102
+ lines.push(`── ${category} ──`);
103
+ for (const [key, val] of entries) {
104
+ lines.push(` ${key.padEnd(35)} ${val}`);
105
+ }
106
+ lines.push('');
107
+ }
108
+ return lines.join('\n');
109
+ }
110
+ function categorize(key) {
111
+ const k = key.toLowerCase();
112
+ if (k.includes('caco') || k.includes('pampa') || k.includes('hia') || k.includes('pgp') || k.includes('bioavail') || k.includes('solub') || k.includes('logd') || k.includes('hydra'))
113
+ return 'Absorption';
114
+ if (k.includes('bbb') || k.includes('ppb') || k.includes('vd') || k.includes('distribution'))
115
+ return 'Distribution';
116
+ if (k.includes('cyp') || k.includes('metabol'))
117
+ return 'Metabolism';
118
+ if (k.includes('half_life') || k.includes('clearance') || k.includes('excret'))
119
+ return 'Excretion';
120
+ if (k.includes('herg') || k.includes('ames') || k.includes('dili') || k.includes('ld50') || k.includes('toxic') || k.includes('carcin') || k.includes('skin') || k.includes('nr_'))
121
+ return 'Toxicity';
122
+ if (k.includes('mw') || k.includes('logp') || k.includes('hba') || k.includes('hbd') || k.includes('tpsa') || k.includes('lipinski') || k.includes('qed'))
123
+ return 'Physicochemical';
124
+ return 'Other';
125
+ }
126
+ function formatValue(val) {
127
+ if (val === null || val === undefined)
128
+ return '-';
129
+ if (typeof val === 'number')
130
+ return val.toFixed(4);
131
+ if (typeof val === 'boolean')
132
+ return val ? 'PASS' : 'FAIL';
133
+ return String(val);
134
+ }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerCreditsCommands(program: Command): void;
@@ -0,0 +1,97 @@
1
+ import { createClient } from '../cli.js';
2
+ import { output, formatTable, error } from '../output.js';
3
+ function formatPercentage(pct) {
4
+ return `${pct.toFixed(1)}%`;
5
+ }
6
+ function progressBar(used, total, width = 30) {
7
+ if (total <= 0)
8
+ return '';
9
+ const ratio = Math.min(1, used / total);
10
+ const filled = Math.round(ratio * width);
11
+ const empty = width - filled;
12
+ return `[${'█'.repeat(filled)}${'░'.repeat(empty)}]`;
13
+ }
14
+ function formatCredits(n) {
15
+ return n.toLocaleString();
16
+ }
17
+ function shortJobId(jobId) {
18
+ if (!jobId)
19
+ return '';
20
+ return jobId.split('-')[0] || jobId.substring(0, 8);
21
+ }
22
+ export function registerCreditsCommands(program) {
23
+ const credits = program
24
+ .command('credits')
25
+ .description('Show your account credit balance')
26
+ .action(async () => {
27
+ const client = createClient();
28
+ let q;
29
+ try {
30
+ q = await client.getCredits();
31
+ }
32
+ catch (e) {
33
+ error(e.message);
34
+ process.exit(1);
35
+ return;
36
+ }
37
+ const remainingClass = q.is_quota_exceeded ? '!! EXHAUSTED' : '';
38
+ const periodEnd = q.period_end ? new Date(q.period_end).toLocaleDateString() : '—';
39
+ const lines = [
40
+ `Tier: ${q.tier}`,
41
+ `Used: ${formatCredits(q.credits_used)} / ${formatCredits(q.monthly_credits)} ${progressBar(q.credits_used, q.monthly_credits)} ${formatPercentage(q.usage_percentage || 0)}`,
42
+ `Top-up: ${formatCredits(q.topup_credits)}`,
43
+ `Remaining: ${formatCredits(q.credits_remaining)} ${remainingClass}`.trimEnd(),
44
+ `Resets: ${periodEnd}`,
45
+ ];
46
+ output(q, lines.join('\n'));
47
+ });
48
+ credits
49
+ .command('log')
50
+ .description('Show your credit ledger (paginated)')
51
+ .option('--event <type>', 'Filter to event type: reserve|tokens|refund|reconcile|topup|reset')
52
+ .option('--since <date>', 'Events on or after this ISO date (yyyy-mm-dd)')
53
+ .option('--until <date>', 'Events on or before this ISO date (yyyy-mm-dd)')
54
+ .option('--page <n>', 'Page number (default 1)', '1')
55
+ .option('--limit <n>', 'Results per page (default 25, max 200)', '25')
56
+ .action(async (opts) => {
57
+ const client = createClient();
58
+ let response;
59
+ try {
60
+ response = await client.getCreditLog({
61
+ page: parseInt(opts.page, 10),
62
+ pageSize: parseInt(opts.limit, 10),
63
+ event: opts.event,
64
+ since: opts.since,
65
+ until: opts.until,
66
+ });
67
+ }
68
+ catch (e) {
69
+ error(e.message);
70
+ process.exit(1);
71
+ return;
72
+ }
73
+ if (response.results.length === 0) {
74
+ output(response, 'No credit events match these filters.');
75
+ return;
76
+ }
77
+ const rows = response.results.map(evt => {
78
+ const detail = evt.note
79
+ || (evt.metadata && (evt.metadata.job_type || evt.metadata.model))
80
+ || '';
81
+ const sign = evt.credits > 0 ? '-' : '+';
82
+ const date = new Date(evt.created_at).toISOString().replace('T', ' ').substring(0, 16);
83
+ return [
84
+ date,
85
+ evt.event,
86
+ `${sign}${Math.abs(evt.credits).toLocaleString()}`,
87
+ detail.toString().substring(0, 50),
88
+ shortJobId(evt.job_id),
89
+ ];
90
+ });
91
+ const page = parseInt(opts.page, 10);
92
+ const limit = parseInt(opts.limit, 10);
93
+ const totalPages = Math.max(1, Math.ceil(response.count / limit));
94
+ const footer = `\nPage ${page} of ${totalPages} • ${response.count.toLocaleString()} total events`;
95
+ output(response, formatTable(['Date (UTC)', 'Event', 'Credits', 'Detail', 'Job'], rows) + footer);
96
+ });
97
+ }
@@ -52,7 +52,7 @@ export function registerDockingCommands(program) {
52
52
  .argument('<smiles>', 'SMILES string of the ligand')
53
53
  .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
54
54
  .option('--exhaustiveness <n>', 'Search exhaustiveness', parseInt)
55
- .description('Dock with GNINA CNN scoring (Azure T4 GPU, ~6s/compound)')).action(async (smiles, pdb, opts) => {
55
+ .description('Dock with GNINA CNN scoring (GCP T4 GPU, ~3-4 min/job incl. GPU spin-up)')).action(async (smiles, pdb, opts) => {
56
56
  await runSiteDock(smiles, pdb, 'gnina', opts);
57
57
  });
58
58
  // m3t dock vina
@@ -60,7 +60,7 @@ export function registerDockingCommands(program) {
60
60
  .argument('<smiles>', 'SMILES string of the ligand')
61
61
  .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
62
62
  .option('--exhaustiveness <n>', 'Search exhaustiveness', parseInt)
63
- .description('Dock with AutoDock Vina (Cloud Run CPU, ~30s/compound)')).action(async (smiles, pdb, opts) => {
63
+ .description('Dock with AutoDock Vina (Cloud Run CPU, ~2-3 min/compound)')).action(async (smiles, pdb, opts) => {
64
64
  await runSiteDock(smiles, pdb, 'vina', opts);
65
65
  });
66
66
  // m3t dock diffdock
@@ -91,14 +91,14 @@ export function registerDockingCommands(program) {
91
91
  addSiteOptions(batch.command('gnina')
92
92
  .argument('<file>', 'Compounds file (CSV/JSON, - for stdin)')
93
93
  .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
94
- .description('Batch dock with GNINA (~6s/compound)')).action(async (file, pdb, opts) => {
94
+ .description('Batch dock with GNINA (GCP T4 GPU, ~3-4 min/job for small batches)')).action(async (file, pdb, opts) => {
95
95
  await runBatchDock(file, pdb, 'gnina', opts);
96
96
  });
97
97
  // m3t batch vina
98
98
  addSiteOptions(batch.command('vina')
99
99
  .argument('<file>', 'Compounds file (CSV/JSON, - for stdin)')
100
100
  .argument('<pdb>', 'PDB ID (e.g., 5NJ8) or path to .pdb file')
101
- .description('Batch dock with Vina (~30s/compound)')).action(async (file, pdb, opts) => {
101
+ .description('Batch dock with Vina (Cloud Run CPU, ~2-3 min/compound)')).action(async (file, pdb, opts) => {
102
102
  await runBatchDock(file, pdb, 'vina', opts);
103
103
  });
104
104
  }
@@ -124,7 +124,7 @@ async function runSiteDock(smiles, pdb, method, opts) {
124
124
  });
125
125
  const url = jobUrl(consoleUrl, project.id, result.job_id);
126
126
  maybeOpenBrowser(url);
127
- const timeEst = method === 'gnina' ? '~6s' : '~30s';
127
+ const timeEst = method === 'gnina' ? '~3-4 min (incl. GPU spin-up)' : '~2-3 min';
128
128
  const data = { job_id: result.job_id, method, url };
129
129
  output(data, `${method.toUpperCase()} docking job created\nJob ID: ${result.job_id.substring(0, 8)}\nEstimated: ${timeEst}`);
130
130
  }
@@ -150,8 +150,11 @@ async function runBatchDock(file, pdb, method, opts) {
150
150
  });
151
151
  const url = jobUrl(consoleUrl, project.id, result.job_id);
152
152
  maybeOpenBrowser(url);
153
- const perCompound = method === 'gnina' ? 6 : 30;
154
- const totalMin = Math.ceil((compounds.length * perCompound) / 60);
153
+ // GNINA is provisioning-bound (~constant per job for small batches); Vina
154
+ // scales per compound on CPU.
155
+ const totalMin = method === 'gnina'
156
+ ? Math.max(4, Math.ceil(compounds.length * 0.05))
157
+ : Math.ceil(compounds.length * 2.5);
155
158
  const data = { job_id: result.job_id, compounds: compounds.length, method, url };
156
159
  output(data, `${method.toUpperCase()} batch docking created\nJob ID: ${result.job_id.substring(0, 8)}\nCompounds: ${compounds.length}\nEstimated: ~${totalMin} minutes`);
157
160
  }
@@ -0,0 +1,2 @@
1
+ import type { Command } from 'commander';
2
+ export declare function registerFoodbCommands(program: Command): void;