|
| 1 | +# Copyright (c) 2025, NVIDIA CORPORATION. All rights reserved. |
| 2 | +# |
| 3 | +# Licensed under the Apache License, Version 2.0 (the "License"); |
| 4 | +# you may not use this file except in compliance with the License. |
| 5 | +# You may obtain a copy of the License at |
| 6 | +# |
| 7 | +# http://www.apache.org/licenses/LICENSE-2.0 |
| 8 | +# |
| 9 | +# Unless required by applicable law or agreed to in writing, software |
| 10 | +# distributed under the License is distributed on an "AS IS" BASIS, |
| 11 | +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
| 12 | +# See the License for the specific language governing permissions and |
| 13 | +# limitations under the License. |
| 14 | + |
| 15 | +"""Radioactive decay MCP tool for nuclear decay chain calculations. |
| 16 | +
|
| 17 | +Wraps the ``radioactivedecay`` library to provide nuclide information and |
| 18 | +time-evolution of decay chains. No API key required. |
| 19 | +
|
| 20 | +Prerequisites: |
| 21 | + pip install radioactivedecay |
| 22 | +
|
| 23 | +Usage: |
| 24 | + ++tool_modules=[nemo_skills.mcp.servers.radioactivedecay_tool::RadioactivedecayTool] |
| 25 | +""" |
| 26 | + |
| 27 | +import logging |
| 28 | +import math |
| 29 | +from typing import Annotated, Any |
| 30 | + |
| 31 | +from pydantic import Field |
| 32 | + |
| 33 | +from nemo_skills.mcp.tool_manager import Tool |
| 34 | + |
| 35 | +logger = logging.getLogger(__name__) |
| 36 | + |
| 37 | +VALID_TIME_UNITS = {"ps", "ns", "us", "ms", "s", "m", "h", "d", "y", "ky", "My", "Gy", "Ty"} |
| 38 | + |
| 39 | + |
| 40 | +def nuclide_info( |
| 41 | + nuclide: Annotated[str, Field(description="Nuclide in standard notation (e.g. 'H-3', 'U-238', 'Co-60').")], |
| 42 | +) -> str: |
| 43 | + """Look up a radioactive nuclide. Returns half-life, decay modes, progeny, and branching fractions.""" |
| 44 | + import radioactivedecay as rd |
| 45 | + |
| 46 | + try: |
| 47 | + nuc = rd.Nuclide(nuclide) |
| 48 | + except ValueError: |
| 49 | + return f"Nuclide '{nuclide}' not found. Use notation like 'H-3', 'U-238', 'Co-60'." |
| 50 | + |
| 51 | + lines = [f"**{nuc.nuclide}**"] |
| 52 | + lines.append(f"Atomic number (Z): {nuc.Z}") |
| 53 | + lines.append(f"Mass number (A): {nuc.A}") |
| 54 | + |
| 55 | + half_life_s = nuc.half_life() |
| 56 | + if half_life_s == float("inf"): |
| 57 | + lines.append("Half-life: stable") |
| 58 | + else: |
| 59 | + for unit in ["y", "d", "h", "m", "s"]: |
| 60 | + hl = nuc.half_life(unit) |
| 61 | + if hl >= 1.0: |
| 62 | + lines.append(f"Half-life: {hl:.6g} {unit}") |
| 63 | + break |
| 64 | + else: |
| 65 | + lines.append(f"Half-life: {half_life_s:.6g} s") |
| 66 | + |
| 67 | + modes = nuc.decay_modes() |
| 68 | + if modes: |
| 69 | + lines.append(f"Decay modes: {', '.join(str(m) for m in modes)}") |
| 70 | + |
| 71 | + progeny = nuc.progeny() |
| 72 | + branching = nuc.branching_fractions() |
| 73 | + if progeny: |
| 74 | + lines.append("Progeny:") |
| 75 | + for daughter, bf in zip(progeny, branching): |
| 76 | + lines.append(f" {daughter} (branching fraction: {bf:.6g})") |
| 77 | + |
| 78 | + return "\n".join(lines) |
| 79 | + |
| 80 | + |
| 81 | +def decay_chain( |
| 82 | + nuclide: Annotated[str, Field(description="Starting nuclide (e.g. 'U-238', 'Co-60').")], |
| 83 | + time: Annotated[float, Field(description="Elapsed time for decay calculation.")], |
| 84 | + time_unit: Annotated[str, Field(description="Time unit: s, m, h, d, y, ky, My, Gy, Ty, ps, ns, us, ms.")] = "s", |
| 85 | +) -> str: |
| 86 | + """Calculate the decay chain products and activities after a given time.""" |
| 87 | + if time_unit not in VALID_TIME_UNITS: |
| 88 | + return f"Invalid time unit '{time_unit}'. Valid units: {', '.join(sorted(VALID_TIME_UNITS))}" |
| 89 | + |
| 90 | + if not math.isfinite(time): |
| 91 | + return "Time must be a finite number." |
| 92 | + if time < 0: |
| 93 | + return "Time must be non-negative." |
| 94 | + |
| 95 | + import radioactivedecay as rd |
| 96 | + |
| 97 | + try: |
| 98 | + rd.Nuclide(nuclide) |
| 99 | + except ValueError: |
| 100 | + return f"Nuclide '{nuclide}' not found. Use notation like 'H-3', 'U-238', 'Co-60'." |
| 101 | + |
| 102 | + inv = rd.Inventory({nuclide: 1.0}, "Bq") |
| 103 | + decayed = inv.decay(time, time_unit) |
| 104 | + activities = decayed.activities("Bq") |
| 105 | + |
| 106 | + lines = [f"**Decay of {nuclide} after {time} {time_unit}**", ""] |
| 107 | + lines.append(f"{'Nuclide':<12} {'Activity (Bq)':>15}") |
| 108 | + lines.append("-" * 28) |
| 109 | + for nuc_name, activity in sorted(activities.items(), key=lambda x: -x[1]): |
| 110 | + if activity > 1e-15: |
| 111 | + lines.append(f"{str(nuc_name):<12} {activity:>15.6e}") |
| 112 | + |
| 113 | + return "\n".join(lines) |
| 114 | + |
| 115 | + |
| 116 | +class RadioactivedecayTool(Tool): |
| 117 | + def __init__(self) -> None: |
| 118 | + self._config: dict[str, Any] = {"time_unit": "s"} |
| 119 | + |
| 120 | + def default_config(self) -> dict[str, Any]: |
| 121 | + return dict(self._config) |
| 122 | + |
| 123 | + def configure(self, overrides: dict[str, Any] | None = None, context: dict[str, Any] | None = None) -> None: |
| 124 | + if not overrides: |
| 125 | + return |
| 126 | + |
| 127 | + allowed = {"time_unit"} |
| 128 | + unknown = set(overrides) - allowed |
| 129 | + if unknown: |
| 130 | + raise ValueError(f"Unsupported RadioactivedecayTool override(s): {sorted(unknown)}") |
| 131 | + |
| 132 | + time_unit = overrides.get("time_unit", self._config["time_unit"]) |
| 133 | + if time_unit not in VALID_TIME_UNITS: |
| 134 | + raise ValueError(f"Invalid time unit '{time_unit}'. Valid units: {', '.join(sorted(VALID_TIME_UNITS))}") |
| 135 | + self._config["time_unit"] = time_unit |
| 136 | + |
| 137 | + async def list_tools(self) -> list[dict[str, Any]]: |
| 138 | + return [ |
| 139 | + { |
| 140 | + "name": "nuclide-info", |
| 141 | + "description": "Look up half-life, decay modes, progeny, and branching fractions for a nuclide.", |
| 142 | + "input_schema": { |
| 143 | + "type": "object", |
| 144 | + "properties": { |
| 145 | + "nuclide": {"type": "string", "description": "Nuclide notation, e.g. H-3, U-238, Co-60."} |
| 146 | + }, |
| 147 | + "required": ["nuclide"], |
| 148 | + }, |
| 149 | + }, |
| 150 | + { |
| 151 | + "name": "decay-chain", |
| 152 | + "description": "Calculate decay-chain product activities after an elapsed time.", |
| 153 | + "input_schema": { |
| 154 | + "type": "object", |
| 155 | + "properties": { |
| 156 | + "nuclide": {"type": "string", "description": "Starting nuclide."}, |
| 157 | + "time": {"type": "number", "description": "Elapsed time for the decay calculation."}, |
| 158 | + }, |
| 159 | + "required": ["nuclide", "time"], |
| 160 | + }, |
| 161 | + }, |
| 162 | + ] |
| 163 | + |
| 164 | + async def execute(self, tool_name: str, arguments: dict[str, Any], extra_args: dict[str, Any] | None = None): |
| 165 | + arguments = dict(arguments or {}) |
| 166 | + if tool_name == "nuclide-info": |
| 167 | + return nuclide_info(**arguments) |
| 168 | + if tool_name == "decay-chain": |
| 169 | + arguments.setdefault("time_unit", self._config["time_unit"]) |
| 170 | + return decay_chain(**arguments) |
| 171 | + return f"Error: unknown tool '{tool_name}'" |
0 commit comments