promptmc 0.3.0__py3-none-any.whl
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.
- promptmc/__init__.py +22 -0
- promptmc/_typing.py +5 -0
- promptmc/assistant.py +460 -0
- promptmc/batch.py +478 -0
- promptmc/benchmarks/__init__.py +13 -0
- promptmc/benchmarks/_types.py +21 -0
- promptmc/benchmarks/godiva.py +52 -0
- promptmc/benchmarks/pwr_pin.py +117 -0
- promptmc/cli.py +97 -0
- promptmc/commands/__init__.py +3 -0
- promptmc/commands/analyze.py +39 -0
- promptmc/commands/batch.py +77 -0
- promptmc/commands/common.py +36 -0
- promptmc/commands/configure.py +68 -0
- promptmc/commands/info.py +88 -0
- promptmc/commands/plan.py +83 -0
- promptmc/commands/run.py +103 -0
- promptmc/commands/templates.py +81 -0
- promptmc/commands/validate.py +84 -0
- promptmc/errors.py +234 -0
- promptmc/examples/uo2_criticality/README.md +46 -0
- promptmc/examples/uo2_criticality/geometry.xml +7 -0
- promptmc/examples/uo2_criticality/materials.xml +14 -0
- promptmc/examples/uo2_criticality/settings.xml +12 -0
- promptmc/geometry/__init__.py +53 -0
- promptmc/geometry/materials.py +52 -0
- promptmc/geometry/primitives.py +216 -0
- promptmc/geometry/tallies.py +50 -0
- promptmc/geometry/xml_serializer.py +312 -0
- promptmc/mcp/__init__.py +11 -0
- promptmc/mcp/resources.py +86 -0
- promptmc/mcp/schemas.py +225 -0
- promptmc/mcp/server.py +113 -0
- promptmc/mcp/tools.py +778 -0
- promptmc/openmc_integration.py +290 -0
- promptmc/progress.py +587 -0
- promptmc/resources.py +270 -0
- promptmc/schema.py +431 -0
- promptmc/telemetry.py +337 -0
- promptmc/templates.py +376 -0
- promptmc/visualization.py +355 -0
- promptmc-0.3.0.dist-info/METADATA +154 -0
- promptmc-0.3.0.dist-info/RECORD +46 -0
- promptmc-0.3.0.dist-info/WHEEL +4 -0
- promptmc-0.3.0.dist-info/entry_points.txt +4 -0
- promptmc-0.3.0.dist-info/licenses/LICENSE +21 -0
promptmc/__init__.py
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""PromptMC: AI Assistant and CLI for OpenMC workflows."""
|
|
2
|
+
|
|
3
|
+
from promptmc.batch import BatchRunner, ParallelConfig, ParallelMode
|
|
4
|
+
from promptmc.openmc_integration import (
|
|
5
|
+
ExecutionMode,
|
|
6
|
+
OpenMCInstaller,
|
|
7
|
+
OpenMCRunner,
|
|
8
|
+
OpenMCValidator,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
__version__ = "0.3.0"
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"__version__",
|
|
15
|
+
"BatchRunner",
|
|
16
|
+
"ExecutionMode",
|
|
17
|
+
"OpenMCInstaller",
|
|
18
|
+
"OpenMCRunner",
|
|
19
|
+
"OpenMCValidator",
|
|
20
|
+
"ParallelConfig",
|
|
21
|
+
"ParallelMode",
|
|
22
|
+
]
|
promptmc/_typing.py
ADDED
promptmc/assistant.py
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
"""Natural-language assistant for OpenMC configuration planning."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import re
|
|
8
|
+
import shlex
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, TypeVar
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from promptmc.templates import TemplateType, get_template
|
|
16
|
+
|
|
17
|
+
DEFAULT_GEMINI_MODEL = "gemini-3.5-flash"
|
|
18
|
+
|
|
19
|
+
SUPPORTED_TEMPLATE_TYPES = {
|
|
20
|
+
TemplateType.CRITICALITY,
|
|
21
|
+
TemplateType.FIXED_SOURCE,
|
|
22
|
+
TemplateType.SHIELDING,
|
|
23
|
+
TemplateType.REACTOR_PIN,
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class NaturalLanguagePlan:
|
|
29
|
+
"""A simulation plan derived from natural language input."""
|
|
30
|
+
|
|
31
|
+
prompt: str
|
|
32
|
+
template_type: TemplateType
|
|
33
|
+
particles: int
|
|
34
|
+
batches: int
|
|
35
|
+
inactive: int
|
|
36
|
+
confidence: float
|
|
37
|
+
summary: str
|
|
38
|
+
rationale: list[str] = field(default_factory=list)
|
|
39
|
+
warnings: list[str] = field(default_factory=list)
|
|
40
|
+
next_steps: list[str] = field(default_factory=list)
|
|
41
|
+
source: str = "local"
|
|
42
|
+
|
|
43
|
+
def command(self, output_path: str | Path = "settings.xml") -> str:
|
|
44
|
+
"""Generate the CLI command to execute this plan."""
|
|
45
|
+
parts = [
|
|
46
|
+
"promptmc",
|
|
47
|
+
"template",
|
|
48
|
+
self.template_type.value,
|
|
49
|
+
"--output",
|
|
50
|
+
str(output_path),
|
|
51
|
+
"--particles",
|
|
52
|
+
str(self.particles),
|
|
53
|
+
"--batches",
|
|
54
|
+
str(self.batches),
|
|
55
|
+
]
|
|
56
|
+
if self.inactive:
|
|
57
|
+
parts.extend(["--inactive", str(self.inactive)])
|
|
58
|
+
return shlex.join(parts)
|
|
59
|
+
|
|
60
|
+
def render(self, output_path: str | Path) -> Path:
|
|
61
|
+
"""Render the plan to an XML settings file."""
|
|
62
|
+
template = get_template(self.template_type)
|
|
63
|
+
return template.render(
|
|
64
|
+
output_path=output_path,
|
|
65
|
+
particles=self.particles,
|
|
66
|
+
batches=self.batches,
|
|
67
|
+
inactive=self.inactive,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
T = TypeVar("T", bound=BaseModel)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class GeminiPlanResponse(BaseModel):
|
|
75
|
+
"""Pydantic schema for the Gemini structured output plan."""
|
|
76
|
+
|
|
77
|
+
template_type: str = Field(
|
|
78
|
+
description="One of 'criticality', 'fixed_source', 'shielding', or 'reactor_pin'"
|
|
79
|
+
)
|
|
80
|
+
particles: int = Field(
|
|
81
|
+
description="Number of particles to simulate, must be a positive integer"
|
|
82
|
+
)
|
|
83
|
+
batches: int = Field(
|
|
84
|
+
description="Number of batches to simulate, must be a positive integer"
|
|
85
|
+
)
|
|
86
|
+
inactive: int = Field(
|
|
87
|
+
description="Number of inactive batches to simulate (must be 0 for fixed_source and shielding)"
|
|
88
|
+
)
|
|
89
|
+
confidence: float = Field(
|
|
90
|
+
description="Confidence score between 0.0 and 1.0"
|
|
91
|
+
)
|
|
92
|
+
summary: str = Field(description="A short summary of the plan")
|
|
93
|
+
rationale: list[str] = Field(
|
|
94
|
+
default_factory=list, description="Reasoning behind the chosen plan"
|
|
95
|
+
)
|
|
96
|
+
warnings: list[str] = Field(
|
|
97
|
+
default_factory=list,
|
|
98
|
+
description="Any warnings or potential issues detected",
|
|
99
|
+
)
|
|
100
|
+
next_steps: list[str] = Field(
|
|
101
|
+
default_factory=list, description="Next steps for the user to follow"
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class GeminiClient:
|
|
106
|
+
"""A thin client wrapper for Google Gemini API."""
|
|
107
|
+
|
|
108
|
+
def __init__(
|
|
109
|
+
self,
|
|
110
|
+
api_key: str | None = None,
|
|
111
|
+
model: str | None = None,
|
|
112
|
+
) -> None:
|
|
113
|
+
self.api_key: str | None = api_key or os.getenv("GEMINI_API_KEY")
|
|
114
|
+
self.model: str = (
|
|
115
|
+
model or os.getenv("GEMINI_MODEL") or DEFAULT_GEMINI_MODEL
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
@property
|
|
119
|
+
def configured(self) -> bool:
|
|
120
|
+
"""Whether the client has an API key configured."""
|
|
121
|
+
return bool(self.api_key)
|
|
122
|
+
|
|
123
|
+
def generate_structured(
|
|
124
|
+
self,
|
|
125
|
+
system_prompt: str,
|
|
126
|
+
user_prompt: str,
|
|
127
|
+
response_schema: type[T],
|
|
128
|
+
) -> T:
|
|
129
|
+
"""Call Gemini to generate structured output conforming to the response_schema."""
|
|
130
|
+
if not self.api_key:
|
|
131
|
+
raise ValueError(
|
|
132
|
+
"GEMINI_API_KEY environment variable is not set. "
|
|
133
|
+
"Set this variable to use the Gemini LLM planner, "
|
|
134
|
+
"or omit the --llm flag to use the default local planner."
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
# Lazy import: import google-genai ONLY inside the seam that makes the call
|
|
138
|
+
from google import genai
|
|
139
|
+
from google.genai import types
|
|
140
|
+
|
|
141
|
+
client = genai.Client(api_key=self.api_key)
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
response = client.models.generate_content(
|
|
145
|
+
model=self.model,
|
|
146
|
+
contents=user_prompt,
|
|
147
|
+
config=types.GenerateContentConfig(
|
|
148
|
+
system_instruction=system_prompt,
|
|
149
|
+
response_mime_type="application/json",
|
|
150
|
+
response_schema=response_schema,
|
|
151
|
+
temperature=0.1,
|
|
152
|
+
),
|
|
153
|
+
)
|
|
154
|
+
except Exception as e:
|
|
155
|
+
raise RuntimeError(f"Gemini API request failed: {e}") from e
|
|
156
|
+
|
|
157
|
+
if not response.text:
|
|
158
|
+
raise RuntimeError("Gemini returned an empty response")
|
|
159
|
+
|
|
160
|
+
try:
|
|
161
|
+
return response_schema.model_validate_json(response.text)
|
|
162
|
+
except Exception as e:
|
|
163
|
+
raise RuntimeError(
|
|
164
|
+
f"Failed to parse Gemini response as {response_schema.__name__}: {e}. "
|
|
165
|
+
f"Raw response: {response.text}"
|
|
166
|
+
) from e
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class NaturalLanguageAssistant:
|
|
170
|
+
"""Translates natural language into OpenMC simulation plans."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, llm_client: GeminiClient | None = None) -> None:
|
|
173
|
+
self.llm_client = llm_client or GeminiClient()
|
|
174
|
+
|
|
175
|
+
def plan(
|
|
176
|
+
self,
|
|
177
|
+
prompt: str,
|
|
178
|
+
use_llm: bool = False,
|
|
179
|
+
model: str | None = None,
|
|
180
|
+
) -> NaturalLanguagePlan:
|
|
181
|
+
"""Generate a simulation plan from a prompt."""
|
|
182
|
+
local_plan = self._local_plan(prompt)
|
|
183
|
+
if not use_llm:
|
|
184
|
+
return local_plan
|
|
185
|
+
|
|
186
|
+
client = self.llm_client
|
|
187
|
+
if model:
|
|
188
|
+
client = GeminiClient(model=model)
|
|
189
|
+
|
|
190
|
+
if not client.configured:
|
|
191
|
+
raise ValueError(
|
|
192
|
+
"GEMINI_API_KEY environment variable is not set. "
|
|
193
|
+
"Set this variable to use the Gemini LLM planner, "
|
|
194
|
+
"or omit the --llm flag to use the default local planner."
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
return self._llm_plan(prompt, local_plan, client)
|
|
198
|
+
|
|
199
|
+
def _local_plan(self, prompt: str) -> NaturalLanguagePlan:
|
|
200
|
+
normalized = prompt.lower()
|
|
201
|
+
template_type, confidence, rationale = self._infer_template(normalized)
|
|
202
|
+
template = get_template(template_type)
|
|
203
|
+
particles = self._extract_labeled_count(
|
|
204
|
+
normalized,
|
|
205
|
+
(
|
|
206
|
+
"particles",
|
|
207
|
+
"particle",
|
|
208
|
+
"histories",
|
|
209
|
+
"history",
|
|
210
|
+
"neutrons",
|
|
211
|
+
"photons",
|
|
212
|
+
),
|
|
213
|
+
template.metadata.default_particles,
|
|
214
|
+
)
|
|
215
|
+
batches = self._extract_labeled_count(
|
|
216
|
+
normalized,
|
|
217
|
+
("batches", "batch", "cycles", "cycle"),
|
|
218
|
+
template.metadata.default_batches,
|
|
219
|
+
)
|
|
220
|
+
inactive = self._extract_labeled_count(
|
|
221
|
+
normalized,
|
|
222
|
+
("inactive", "skip", "skipped"),
|
|
223
|
+
template.metadata.default_inactive,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
warnings: list[str] = []
|
|
227
|
+
if "depletion" in normalized or "burnup" in normalized:
|
|
228
|
+
warnings.append(
|
|
229
|
+
"Depletion was detected, but the built-in"
|
|
230
|
+
" depletion template is not implemented"
|
|
231
|
+
" yet. Use the generated plan as a"
|
|
232
|
+
" starting point and add depletion"
|
|
233
|
+
" settings manually."
|
|
234
|
+
)
|
|
235
|
+
if (
|
|
236
|
+
template_type in {TemplateType.FIXED_SOURCE, TemplateType.SHIELDING}
|
|
237
|
+
and inactive
|
|
238
|
+
):
|
|
239
|
+
inactive = 0
|
|
240
|
+
warnings.append(
|
|
241
|
+
"Inactive batches are not used for"
|
|
242
|
+
" fixed-source-style calculations."
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
next_steps = [
|
|
246
|
+
"Generate settings.xml from the recommended template.",
|
|
247
|
+
"Add or verify materials.xml and geometry.xml for the physical model.",
|
|
248
|
+
"Run schema validation before launching OpenMC.",
|
|
249
|
+
]
|
|
250
|
+
summary = self._summary(template_type, particles, batches, inactive)
|
|
251
|
+
|
|
252
|
+
return NaturalLanguagePlan(
|
|
253
|
+
prompt=prompt,
|
|
254
|
+
template_type=template_type,
|
|
255
|
+
particles=particles,
|
|
256
|
+
batches=batches,
|
|
257
|
+
inactive=inactive,
|
|
258
|
+
confidence=confidence,
|
|
259
|
+
summary=summary,
|
|
260
|
+
rationale=rationale,
|
|
261
|
+
warnings=warnings,
|
|
262
|
+
next_steps=next_steps,
|
|
263
|
+
source="local",
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
def _llm_plan(
|
|
267
|
+
self,
|
|
268
|
+
prompt: str,
|
|
269
|
+
fallback: NaturalLanguagePlan,
|
|
270
|
+
client: GeminiClient,
|
|
271
|
+
) -> NaturalLanguagePlan:
|
|
272
|
+
system_prompt = (
|
|
273
|
+
"You translate OpenMC simulation requests into a simulation plan JSON object. "
|
|
274
|
+
"Valid template_type values are 'criticality', 'fixed_source', 'shielding', and 'reactor_pin'. "
|
|
275
|
+
"Use positive integers for particles and batches. "
|
|
276
|
+
"Use inactive=0 for fixed_source and shielding."
|
|
277
|
+
)
|
|
278
|
+
user_prompt = json.dumps(
|
|
279
|
+
{
|
|
280
|
+
"request": prompt,
|
|
281
|
+
"fallback_plan": {
|
|
282
|
+
"template_type": fallback.template_type.value,
|
|
283
|
+
"particles": fallback.particles,
|
|
284
|
+
"batches": fallback.batches,
|
|
285
|
+
"inactive": fallback.inactive,
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
plan_response = client.generate_structured(
|
|
291
|
+
system_prompt, user_prompt, GeminiPlanResponse
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
try:
|
|
295
|
+
template_type = TemplateType(plan_response.template_type)
|
|
296
|
+
except ValueError:
|
|
297
|
+
template_type = fallback.template_type
|
|
298
|
+
|
|
299
|
+
if template_type not in SUPPORTED_TEMPLATE_TYPES:
|
|
300
|
+
template_type = fallback.template_type
|
|
301
|
+
|
|
302
|
+
return NaturalLanguagePlan(
|
|
303
|
+
prompt=prompt,
|
|
304
|
+
template_type=template_type,
|
|
305
|
+
particles=max(1, plan_response.particles),
|
|
306
|
+
batches=max(1, plan_response.batches),
|
|
307
|
+
inactive=max(0, plan_response.inactive),
|
|
308
|
+
confidence=max(0.0, min(1.0, plan_response.confidence)),
|
|
309
|
+
summary=plan_response.summary,
|
|
310
|
+
rationale=plan_response.rationale,
|
|
311
|
+
warnings=plan_response.warnings,
|
|
312
|
+
next_steps=plan_response.next_steps,
|
|
313
|
+
source="llm",
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
KEYWORD_TEMPLATES: dict[TemplateType, tuple[list[re.Pattern[str]], str]] = {
|
|
317
|
+
TemplateType.SHIELDING: (
|
|
318
|
+
[
|
|
319
|
+
re.compile(rf"\b{kw}\b", re.IGNORECASE)
|
|
320
|
+
for kw in (
|
|
321
|
+
"shield",
|
|
322
|
+
"shielding",
|
|
323
|
+
"dose",
|
|
324
|
+
"attenuation",
|
|
325
|
+
"concrete",
|
|
326
|
+
"lead",
|
|
327
|
+
"barrier",
|
|
328
|
+
)
|
|
329
|
+
],
|
|
330
|
+
"Shielding/dose keywords suggest a shielding calculation.",
|
|
331
|
+
),
|
|
332
|
+
TemplateType.REACTOR_PIN: (
|
|
333
|
+
[
|
|
334
|
+
re.compile(rf"\b{kw}\b", re.IGNORECASE)
|
|
335
|
+
for kw in (
|
|
336
|
+
"pin",
|
|
337
|
+
"pin-cell",
|
|
338
|
+
"pincell",
|
|
339
|
+
"fuel rod",
|
|
340
|
+
"fuel pellet",
|
|
341
|
+
"cladding",
|
|
342
|
+
)
|
|
343
|
+
],
|
|
344
|
+
"Pin-cell keywords suggest the reactor pin template.",
|
|
345
|
+
),
|
|
346
|
+
TemplateType.FIXED_SOURCE: (
|
|
347
|
+
[
|
|
348
|
+
re.compile(rf"\b{kw}\b", re.IGNORECASE)
|
|
349
|
+
for kw in (
|
|
350
|
+
"fixed source",
|
|
351
|
+
"source",
|
|
352
|
+
"beam",
|
|
353
|
+
"dosimetry",
|
|
354
|
+
"14 mev",
|
|
355
|
+
"photon",
|
|
356
|
+
"gamma",
|
|
357
|
+
)
|
|
358
|
+
],
|
|
359
|
+
"Source/dosimetry keywords suggest a fixed-source calculation.",
|
|
360
|
+
),
|
|
361
|
+
TemplateType.CRITICALITY: (
|
|
362
|
+
[
|
|
363
|
+
re.compile(rf"\b{kw}\b", re.IGNORECASE)
|
|
364
|
+
for kw in (
|
|
365
|
+
"criticality",
|
|
366
|
+
"keff",
|
|
367
|
+
"k-effective",
|
|
368
|
+
"eigenvalue",
|
|
369
|
+
"reactor",
|
|
370
|
+
"multiplication",
|
|
371
|
+
)
|
|
372
|
+
],
|
|
373
|
+
"Criticality/eigenvalue keywords suggest a criticality calculation.",
|
|
374
|
+
),
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
def _infer_template(
|
|
378
|
+
self, normalized: str
|
|
379
|
+
) -> tuple[TemplateType, float, list[str]]:
|
|
380
|
+
matches: list[tuple[int, TemplateType, str]] = []
|
|
381
|
+
for template_type, (patterns, reason) in self.KEYWORD_TEMPLATES.items():
|
|
382
|
+
score = sum(1 for p in patterns if p.search(normalized))
|
|
383
|
+
if score:
|
|
384
|
+
matches.append((score, template_type, reason))
|
|
385
|
+
|
|
386
|
+
if not matches:
|
|
387
|
+
return (
|
|
388
|
+
TemplateType.CRITICALITY,
|
|
389
|
+
0.45,
|
|
390
|
+
[
|
|
391
|
+
"No strong domain keywords were found, so criticality was chosen as a safe default."
|
|
392
|
+
],
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
score, template_type, reason = sorted(
|
|
396
|
+
matches, key=lambda item: item[0], reverse=True
|
|
397
|
+
)[0]
|
|
398
|
+
confidence = min(0.95, 0.55 + 0.1 * score)
|
|
399
|
+
return template_type, confidence, [reason]
|
|
400
|
+
|
|
401
|
+
def _extract_labeled_count(
|
|
402
|
+
self,
|
|
403
|
+
normalized: str,
|
|
404
|
+
labels: tuple[str, ...],
|
|
405
|
+
default: int,
|
|
406
|
+
) -> int:
|
|
407
|
+
label_group = "|".join(re.escape(label) for label in labels)
|
|
408
|
+
after_pattern = re.compile( # noqa: E501
|
|
409
|
+
rf"(?:{label_group})\D{{0,24}}(?P<number>\d[\d,]*(?:\.\d+)?(?:e[+-]?\d+)?)(?P<suffix>\s*(?:k|m|thousand|million))?",
|
|
410
|
+
re.IGNORECASE,
|
|
411
|
+
)
|
|
412
|
+
before_pattern = re.compile( # noqa: E501
|
|
413
|
+
rf"(?P<number>\d[\d,]*(?:\.\d+)?(?:e[+-]?\d+)?)(?P<suffix>\s*(?:k|m|thousand|million))?\s+(?:{label_group})",
|
|
414
|
+
re.IGNORECASE,
|
|
415
|
+
)
|
|
416
|
+
for pattern in (before_pattern, after_pattern):
|
|
417
|
+
match = pattern.search(normalized)
|
|
418
|
+
if match:
|
|
419
|
+
return max(
|
|
420
|
+
1,
|
|
421
|
+
self._parse_number(
|
|
422
|
+
match.group("number"), match.group("suffix") or ""
|
|
423
|
+
),
|
|
424
|
+
)
|
|
425
|
+
return default
|
|
426
|
+
|
|
427
|
+
@staticmethod
|
|
428
|
+
def _parse_number(number: str, suffix: str) -> int:
|
|
429
|
+
value = float(number.replace(",", ""))
|
|
430
|
+
suffix = suffix.strip().lower()
|
|
431
|
+
if suffix in {"k", "thousand"}:
|
|
432
|
+
value *= 1_000
|
|
433
|
+
elif suffix in {"m", "million"}:
|
|
434
|
+
value *= 1_000_000
|
|
435
|
+
return int(value)
|
|
436
|
+
|
|
437
|
+
@staticmethod
|
|
438
|
+
def _summary(
|
|
439
|
+
template_type: TemplateType,
|
|
440
|
+
particles: int,
|
|
441
|
+
batches: int,
|
|
442
|
+
inactive: int,
|
|
443
|
+
) -> str:
|
|
444
|
+
if inactive:
|
|
445
|
+
return (
|
|
446
|
+
f"Use the {template_type.value} template with {particles:,} "
|
|
447
|
+
f"particles, {batches} batches, and {inactive} inactive batches."
|
|
448
|
+
)
|
|
449
|
+
return (
|
|
450
|
+
f"Use the {template_type.value} template with {particles:,} "
|
|
451
|
+
f"particles and {batches} batches."
|
|
452
|
+
)
|
|
453
|
+
|
|
454
|
+
@staticmethod
|
|
455
|
+
def _string_list(value: Any) -> list[str]:
|
|
456
|
+
if isinstance(value, list):
|
|
457
|
+
return [str(item) for item in value]
|
|
458
|
+
if value is None:
|
|
459
|
+
return []
|
|
460
|
+
return [str(value)]
|