{ "cells": [ { "cell_type": "markdown", "id": "0", "metadata": {}, "source": [ "# Theory VI: Graph operations\n", "\n", "\n", "We'll look at the theory on how to solve such questions with a slightly less pathological example: a Fusion system code, inspired by Freidberg's systems code[^1].\n", "\n", "This is such a simple systems code that it can be solved by hand, but we'll not do that; instead, we'll use the `cottax` machinery to solve.\n", "\n", "[^1]: See https://pubs.aip.org/aip/pop/article/22/7/070901/264353/Designing-a-tokamak-fusion-reactor-How-does-plasma \n" ] }, { "cell_type": "code", "execution_count": null, "id": "1", "metadata": { "mystnb": { "code_prompt_hide": "Hide the imports and physical constants", "code_prompt_show": "Show the imports and physical constants" }, "tags": [ "hide-input" ] }, "outputs": [], "source": [ "from functools import partial\n", "from math import pi\n", "\n", "import jax\n", "import jax.numpy as jnp\n", "\n", "jax.config.update(\"jax_enable_x64\", True)\n", "from cottax.core.frozen import OrderedMap\n", "from cottax.core import Plan, Combine, Nest, GraphWithValues\n", "from cottax.relational import RelationalCondition\n", "from cottax.core.realisation import ImplementDrivers\n", "from cottax.consumers.execution import run\n", "from cottax.consumers.visualization.sequencing import entries\n", "\n", "import cottax.assemblies.jax as cx\n", "from cottax.assemblies.jax import N, flat\n", "from cottax.consumers.visualization.xdsm import PlainFormat, spell_plain\n", "from cottax.relational.contracts import (\n", " FixedPointIteration, FixedPointIterator, RootFinding, RootFinder,\n", ")\n", "from cottax.consumers.execution.jax_jit import JitGraph\n", "from cottax.relational.consumers.execution.jax_solvers import Newton, Picard\n", "from cottax.consumers.visualization import render_html\n", "\n", "xdsm = partial(render_html, formatter = PlainFormat(),\n", " xdsm = {\"input_label\": \"Input\", \"output_label\": \"Output\", \"collapse_names\" :True}, views=(\"n2\", \"xdsm\"))\n", "\n", "\n", "MU0 = 4e-7 * pi # vacuum permeability [H/m]\n", "E_FUSION = 17.6e6 * 1.602176634e-19 # energy per DT reaction [J]\n", "KEV = 1.602176634e-16 # one keV [J]\n", "ION_MASS = 2.5 # average DT ion mass [amu]" ] }, { "cell_type": "code", "execution_count": null, "id": "2", "metadata": { "mystnb": { "code_prompt_hide": "Hide the model definitions", "code_prompt_show": "Show the model definitions" }, "tags": [ "hide-input" ] }, "outputs": [], "source": [ "@flat.function('plasma_volume', 'wall_area', 'inverse_aspect_ratio')\n", "def Geometry(major_radius, minor_radius, elongation):\n", " \"\"\"Plasma volume, first-wall area and inverse aspect ratio.\"\"\"\n", " return (2*pi**2*major_radius*minor_radius**2*elongation, # m^3\n", " 4*pi**2*major_radius*minor_radius*elongation, # m^2\n", " minor_radius/major_radius)\n", "\n", "\n", "@flat.function('plasma_current')\n", "def Current(driven_current, bootstrap_fraction):\n", " \"\"\"Total plasma current: what is driven externally, topped up by what bootstraps.\"\"\"\n", " return driven_current/(1.0 - bootstrap_fraction) # MA\n", "\n", "\n", "@flat.function('beta_poloidal', 'beta_normalised', 'safety_factor')\n", "def MHD(density, temperature, field_on_axis, plasma_current,\n", " minor_radius, major_radius, elongation):\n", " \"\"\"Poloidal and normalised beta, and the kink safety factor.\"\"\"\n", " pressure = 2.0*density*temperature*KEV # Pa\n", " shaping = 0.5*(1 + elongation**2)\n", " field_pol = (MU0*plasma_current*1e6\n", " / (2*pi*minor_radius*jnp.sqrt(shaping))) # T\n", " beta_tor = 2*MU0*pressure/field_on_axis**2\n", "\n", " return (2*MU0*pressure/field_pol**2,\n", " 100*beta_tor*minor_radius*field_on_axis/plasma_current,\n", " 2*pi*minor_radius**2*field_on_axis*shaping\n", " / (MU0*major_radius*plasma_current*1e6))\n", "\n", "\n", "@flat.function('bootstrap_fraction')\n", "def Bootstrap(inverse_aspect_ratio, beta_poloidal, bootstrap_coeff):\n", " \"\"\"The fraction of the plasma current the plasma drives itself.\"\"\"\n", " return bootstrap_coeff*jnp.sqrt(inverse_aspect_ratio)*beta_poloidal\n", "\n", "\n", "@flat.function('fusion_power', 'alpha_power', 'neutron_power')\n", "def Fusion(density, temperature, plasma_volume):\n", " \"\"\"DT fusion power, split into its alpha and neutron halves.\n", "\n", " ` = 1.1e-24 T^2` is the usual fit over 10 - 20 keV.\n", " \"\"\"\n", " reactivity = 1.1e-24*temperature**2 # m^3/s\n", " fusion_power = (0.25*density**2*reactivity\n", " * E_FUSION*plasma_volume/1e6) # MW\n", " return fusion_power, fusion_power/5.0, 4.0*fusion_power/5.0\n", "\n", "\n", "@flat.function('radiated_power')\n", "def Radiation(density, temperature, plasma_volume, Z_eff):\n", " \"\"\"Bremsstrahlung loss out of the plasma.\"\"\"\n", " return (5.35e-37*Z_eff*density**2\n", " * jnp.sqrt(temperature)*plasma_volume/1e6) # MW\n", "\n", "\n", "@flat.function('heating_power')\n", "def Heating(alpha_power, radiated_power, auxiliary_power):\n", " \"\"\"What is left over to heat the plasma.\"\"\"\n", " return alpha_power + auxiliary_power - radiated_power # MW\n", "\n", "\n", "@flat.function('confinement_time')\n", "def Confinement(plasma_current, field_on_axis, density, major_radius,\n", " inverse_aspect_ratio, heating_power, elongation, h_factor):\n", " \"\"\"Energy confinement time, ITER98(y,2) ELMy H-mode scaling.\"\"\"\n", " return (0.0562*h_factor\n", " * plasma_current**0.93 * field_on_axis**0.15\n", " * heating_power**-0.69 * (density/1e19)**0.41\n", " * ION_MASS**0.19 * major_radius**1.97\n", " * inverse_aspect_ratio**0.58 * elongation**0.78) # s\n", "\n", "\n", "@flat.function('temperature')\n", "def Transport(density, plasma_volume, confinement_time, heating_power):\n", " \"\"\"The temperature the plasma settles at.\n", "\n", " In steady state the stored energy `3 n T V` is what the confinement time holds\n", " against the heating power, so the temperature follows from the other two.\n", " \"\"\"\n", " stored_energy = heating_power*1e6*confinement_time # J\n", " return stored_energy/(3.0*density*plasma_volume)/KEV # keV\n", "\n", "\n", "@flat.function('wall_load')\n", "def WallLoad(neutron_power, wall_area):\n", " \"\"\"Neutron power per unit area of first wall.\"\"\"\n", " return neutron_power/wall_area # MW/m^2\n", "\n", "\n", "@flat.residual('blanket_thickness')\n", "def Shield(blanket_thickness, wall_load,\n", " attenuation_length, allowable_coil_heating):\n", " \"\"\"How thick the blanket and shield have to be.\n", "\n", " Neutron flux attenuates as `exp(-thickness/attenuation_length)`, and the shield\n", " is sized so that what reaches the TF coil is exactly what the coil tolerates.\n", " \"\"\"\n", " reaching_coil = wall_load*jnp.exp(-blanket_thickness/attenuation_length)\n", " return reaching_coil/allowable_coil_heating - 1.0\n", "\n", "@flat.function('peak_field', 'magnet_energy')\n", "def Magnet(major_radius, minor_radius, blanket_thickness, field_on_axis):\n", " \"\"\"Peak field at the TF coil, and the field energy it has to store. \n", " \"\"\"\n", " bore = minor_radius + blanket_thickness # m\n", " peak_field = field_on_axis*major_radius/(major_radius - bore) # T\n", " bore_volume = 2*pi**2*major_radius*bore**2 # m^3\n", " return peak_field, peak_field**2*bore_volume/(2*MU0) # J\n", "\n", "\n", "@flat.function('electric_power')\n", "def Plant(fusion_power, auxiliary_power,\n", " eta_thermal, eta_current_drive):\n", " \"\"\"Net electric power out of the plant: what the turbines make, less what the\n", " current drive draws back in.\"\"\"\n", " return (eta_thermal*(fusion_power + auxiliary_power)\n", " - auxiliary_power/eta_current_drive) # MW\n", "\n", "\n", "def spell_flatter(iters):\n", " return [spell_plain(i) for i in iters]\n", "\n", "def print_spell_flatter(iters, end = \" \"):\n", " for i in spell_flatter(iters):\n", " print(i, end=end)\n", " print('')\n", "\n", "def print_graph_nodes(graph, start = '\\t'):\n", " print(start, end = \"\")\n", " print_spell_flatter(list(graph.definitions.keys()))" ] }, { "cell_type": "markdown", "id": "3", "metadata": {}, "source": [ "We'll immediately generate an N2 that is SCC'd (a xdsm starts to get complex with this size)" ] }, { "cell_type": "code", "execution_count": null, "id": "4", "metadata": {}, "outputs": [], "source": [ "reactor = cx.absolute.declare(Geometry, Current, MHD, Bootstrap, Fusion, Radiation,\n", " Heating, Confinement, Transport, WallLoad, Shield,\n", " Magnet, Plant).graph\n", "\n", "xdsm(reactor, blocks=True, views =(\"n2\", \"xdsm\"),\n", " xdsm = {\"input_label\": \"Input\", \"output_label\": \"Output\", \"collapse_names\": True})" ] }, { "cell_type": "markdown", "id": "5", "metadata": {}, "source": [ "Beyond the numerical ordering, this also gives a view of the reactor: what depends on what? Apparently the magnet can only be computed after you've computed all other parameters!\n", "\n", "We can through the graph also see what is actually an input the user has to provide (and isn't an output of the model):" ] }, { "cell_type": "code", "execution_count": null, "id": "6", "metadata": {}, "outputs": [], "source": [ "print(\"Unowned inputs:\")\n", "for i in reactor.graph.boundary_inputs:\n", " print(\"\\t\", spell_plain(i))" ] }, { "cell_type": "markdown", "id": "7", "metadata": {}, "source": [ "What do we need to make this thing solvable? Well, every block needs to be solvable!" ] }, { "cell_type": "code", "execution_count": null, "id": "8", "metadata": {}, "outputs": [], "source": [ "def all_acyclic(graph):\n", " for graph_i in (e.subgraph for e in entries(reactor)):\n", " print(\"Block:\")\n", " print_graph_nodes(graph_i)\n", " print(\"\\tIs acyclic: \", graph_i.graph.is_acyclic)\n", "\n", "\n", "all_acyclic(reactor)" ] }, { "cell_type": "markdown", "id": "9", "metadata": {}, "source": [ "Two of them are not, so those are the two that need solving. The graph can say which\n", "variables a solver could iterate on: a variable is **feedback** when some reader of it\n", "can reach its own producer, so assuming it is what opens the loop.\n" ] }, { "cell_type": "code", "execution_count": null, "id": "10", "metadata": {}, "outputs": [], "source": [ "\n", "for block in (e.subgraph for e in entries(reactor)):\n", " if block.graph.is_acyclic:\n", " continue\n", " print_graph_nodes(block, start =\"Block: \")\n", " for var in block.graph.feedback_variables:\n", " readers = block.graph.closing_readers(var)\n", " print(f\" {spell_plain(var):22s} closed by \", end =\"\")\n", " print_spell_flatter(readers)" ] }, { "cell_type": "markdown", "id": "11", "metadata": {}, "source": [ "What we now have to do is Cut the cycle in some way. This means that you assume the variable, and let a reader use that while the solver iterates for a condition.\n", "\n", "Of course, the block itself that the solver drives then has to be acyclic: otherwise it cannot run it.\n", "\n", "Let's now do it with the large cycle block. The basic Cut type is a FixedPointCut: you assume a value and the solver has to drive that assumption and what is actually computed to the same value.\n", "\n", "Applying a `GraphOp` (which in the case of a FixedPointCut is multiple operations) to a Graph yields a Plan: a Graph and its operations. \n", "\n", "This Plan is useful because it keeps a reference ot the original graph and checks whether the operations are viable and can then cheaply built the graph with the operations applied." ] }, { "cell_type": "code", "execution_count": null, "id": "12", "metadata": {}, "outputs": [], "source": [ "plan_fpc = Plan(reactor) + cx.absolute.fixed_point_cut(flat.place(\"bootstrap_fraction\"), [N[\"Current\"]])\n", "\n", "# Plotting just the cycle block!\n", "cycle_entry = entries(plan_fpc.graph)[1]\n", "cycle_block = cycle_entry.subgraph\n", "xdsm( cycle_block, blocks=True)" ] }, { "cell_type": "markdown", "id": "13", "metadata": {}, "source": [ "Is this enough? We can check that, but from the figure we know the answer: the block that solve(bootstrap_fraction) drives isn't acyclic, it still has coupling below the diagonal..." ] }, { "cell_type": "code", "execution_count": null, "id": "14", "metadata": {}, "outputs": [], "source": [ "cycle_entry.interior.graph.is_acyclic" ] }, { "cell_type": "markdown", "id": "15", "metadata": {}, "source": [ "(`Solve.interior` is that particular block with its problem type removed: in other words, exactly what the solver requires)" ] }, { "cell_type": "markdown", "id": "16", "metadata": {}, "source": [ "We have to make a second cut!\n", "\n", "Let's use the interior block's feedback variables again to determine what we can cut:" ] }, { "cell_type": "code", "execution_count": null, "id": "17", "metadata": {}, "outputs": [], "source": [ "print_spell_flatter(cycle_entry.interior.graph.feedback_variables)" ] }, { "cell_type": "markdown", "id": "18", "metadata": {}, "source": [ "Let's pick `temperature`, and find out which readers need to get the assumed value:" ] }, { "cell_type": "code", "execution_count": null, "id": "19", "metadata": {}, "outputs": [], "source": [ "print_spell_flatter(cycle_entry.interior.graph.closing_readers(cycle_entry.interior.graph.feedback_variables[-1]))" ] }, { "cell_type": "markdown", "id": "20", "metadata": {}, "source": [ "Both `Fusion` and `Radiation`! This can also be seen in the graph: cutting just Fusion actually doesn't remove the cycle." ] }, { "cell_type": "code", "execution_count": null, "id": "21", "metadata": {}, "outputs": [], "source": [ "plan_2fpc = plan_fpc + cx.absolute.fixed_point_cut(flat.place(\"temperature\"), [N[\"Fusion\"], N[\"Radiation\"]])\n", "\n", "xdsm(plan_2fpc.graph, blocks=True)" ] }, { "cell_type": "markdown", "id": "22", "metadata": {}, "source": [ "As we can see in the XDSM, the large block has no longer any feedback variable that\n", "*isn't* answered by a solver. \n", "\n", "However, we have decision to make: do we nest these solvers, and if so which way round, or are the two really one solve?" ] }, { "cell_type": "markdown", "id": "23", "metadata": {}, "source": [ "## Option 1: combining the two problems\n", "\n", "Combining these problems is possible: both are FixedPoints and thus they combine normally. " ] }, { "cell_type": "code", "execution_count": null, "id": "24", "metadata": {}, "outputs": [], "source": [ "\n", "#We name it 'mda'\n", "combine = Combine(cx.absolute.node(cx.solve(N[\"mda\"])), (plan_2fpc.ops[0].at, plan_2fpc.ops[1].at), RelationalCondition)\n", "\n", "plan_combined = plan_2fpc + combine\n", "\n", "xdsm(plan_combined.graph, blocks=True)" ] }, { "cell_type": "markdown", "id": "25", "metadata": {}, "source": [ "There's now only one problem in the cycle, and that works![^2]\n", "\n", "[^2]: You could argue this is a lot of effort for just a fixed point iteration on a block. That's true; but it can be automated: a total fixed point i sjust all variables assumed (wasteful). If you don't want to do it yourself, agentic AI can do this very well as well!" ] }, { "cell_type": "markdown", "id": "26", "metadata": {}, "source": [ "As a side-note, this could have been done in one go as well:" ] }, { "cell_type": "code", "execution_count": null, "id": "27", "metadata": {}, "outputs": [], "source": [ "mda = flat.fixed_point_cut({\"bootstrap_fraction\": N[\"Current\"],\n", " \"temperature\": [N[\"Fusion\"], N[\"Radiation\"]]},\n", " at = cx.solve(N[\"mda\"]))\n", "\n", "plan_double = Plan(reactor) + mda\n", "\n", "print(\"Same graph: \", plan_combined.graph == plan_double.graph)" ] }, { "cell_type": "code", "execution_count": null, "id": "28", "metadata": {}, "outputs": [], "source": [ "solvers = ImplementDrivers({FixedPointIterator: Picard(rtol = 1e-10, atol = 1e-10),\n", " RootFinder: Newton(rtol = 1e-10, atol = 1e-10)})\n", "\n", "plan_total = plan_double + cx.absolute.assign(mda.at, FixedPointIteration(steps = False))\n", "plan_total = plan_total + cx.absolute.assign(cx.solve(N[\"Shield\"]), RootFinding(steps = False))\n", "plan_total = plan_total + solvers\n", "\n", "xdsm(plan_total.graph)" ] }, { "cell_type": "markdown", "id": "29", "metadata": {}, "source": [ "A `Plan` is the whole derivation, so it can be drawn as one: the slider above the diagram steps from the bare reactor through each operation, and highlights what that operation changed." ] }, { "cell_type": "code", "execution_count": null, "id": "30", "metadata": {}, "outputs": [], "source": [ "xdsm(plan_total, blocks=True)" ] }, { "cell_type": "markdown", "id": "31", "metadata": {}, "source": [ "The solvers require some extra data: a initial guess (see `start(bootstrap_fraction)`). In `cottax`, these are first-class Graph citizens: we could hand-initialise, but also wire the output of a different solver to it (for example a lower fidelity solver or surrogate)\n", "\n", "Let's see what inputs we now actually need to give. We'll first give the Plan to a `JitGraph`, which checks that we can actually run it as is (internally, it's just a blocking with more checks!) and provides distinct steps [^3]\n", "\n", "[^3]: This might seem a lot of obfuscation: a blocking already gives the same information. True, but a ExecutableGraph can be build piece-by-piece and is not necessarily a validated runner. Furthermore, a `JitGraph` nests: a `Solve` step's interior is one too." ] }, { "cell_type": "code", "execution_count": null, "id": "32", "metadata": {}, "outputs": [], "source": [ "# A `JitGraph` is the graph proved runnable under jit: one step per component.\n", "jit_combined = JitGraph(plan_total.graph)\n", "\n", "print(\"Steps:\")\n", "for step in jit_combined.steps:\n", " print(step)" ] }, { "cell_type": "markdown", "id": "33", "metadata": {}, "source": [ "This notation is `cottax` internal notation: `^` is a new (minted) location in the namespace that only cottax can create; that way, we avoid name-clashes (e.g. a hat location in a user-defined PyTree). `^copy` is a cut's copy, the value assumed (drawn with a hat), `^cond` means a condition that has to be satisfied, and `^solve` means a problem has to be solved. The dictionary-like names for everything is because we are using a flat, dictionary-like interface to cottax. \n", "\n", "\n", "Let's now check what inputs we need to give:" ] }, { "cell_type": "code", "execution_count": null, "id": "34", "metadata": {}, "outputs": [], "source": [ "print_spell_flatter(jit_combined.graph.graph.boundary_inputs, end='\\n')" ] }, { "cell_type": "markdown", "id": "35", "metadata": {}, "source": [ "We now create a initial input, including the guesses for the solvers:" ] }, { "cell_type": "code", "execution_count": null, "id": "36", "metadata": {}, "outputs": [], "source": [ "design_point = dict(\n", " major_radius = 6.2, \n", " minor_radius = 2.0, \n", " elongation = 1.7,\n", " field_on_axis = 5.3,\n", " density = 1.0e20, \n", " driven_current = 15.0, \n", " bootstrap_coeff = 0.7, \n", " Z_eff = 1.6,\n", " auxiliary_power = 50.0,\n", " h_factor = 1.0, \n", " attenuation_length = 0.15,\n", " allowable_coil_heating = 1e-3, \n", " eta_thermal = 0.35, \n", " eta_current_drive = 0.4,\n", ") # All boundary inputs\n", "\n", "# The guesses:\n", "# A closure's unknown is its copy (^copy), so its start is the copy's: ^start.^copy.temperature.\n", "place = flat.place\n", "guesses = {place(cx.start(cx.copy('temperature'))): 12.0,\n", " place(cx.start(cx.copy('bootstrap_fraction'))): 0.3,\n", " place(cx.start('blanket_thickness')): 1.0} # an implicit model's own unknown\n", "\n", "design_point_varpath = {flat.place(k): v for k, v in design_point.items()}" ] }, { "cell_type": "markdown", "id": "37", "metadata": {}, "source": [ "And the running of the schedule can remain as before (but now `cottax` handles the topological ordering and evaluation and solving)" ] }, { "cell_type": "code", "execution_count": null, "id": "38", "metadata": {}, "outputs": [], "source": [ "def run_on(jit_graph, design_point, guesses):\n", " return run(GraphWithValues(jit_graph, OrderedMap({**design_point, **guesses})))\n", "\n", "\n", "def print_results(total_datastructure):\n", " for name in ('temperature', 'bootstrap_fraction', 'blanket_thickness',\n", " 'fusion_power', 'electric_power', 'peak_field'):\n", " print(f\"{name:22s} {total_datastructure[flat.place(name)]:.4g}\")" ] }, { "cell_type": "code", "execution_count": null, "id": "39", "metadata": {}, "outputs": [], "source": [ "print_results(run_on(jit_combined, design_point_varpath, guesses))" ] }, { "cell_type": "markdown", "id": "40", "metadata": {}, "source": [ "Done, works! [^4]\n", "\n", "[^4]: This doesn't JIT jet; the problem is that the VarPath PyTree index (which all dictionaries have here) are not sortable and therefore not flattenable. This is *on purpose*: the above function modifies total_dict in place, which can be dangerous in some cases. It would not JIT anyway. Prefer to use a boundary evaluation instead." ] }, { "cell_type": "markdown", "id": "41", "metadata": {}, "source": [ "## Option 3: not combining at all\n", "Alternatively, we can assign both FixedPoint problems a solver and nest the them:" ] }, { "cell_type": "code", "execution_count": null, "id": "42", "metadata": {}, "outputs": [], "source": [ "\n", "bootstrap_current_problem = plan_2fpc.ops[0].at\n", "temperature_problem = plan_2fpc.ops[1].at\n", "\n", "plan_nested = plan_2fpc + cx.absolute.assign(bootstrap_current_problem, FixedPointIteration(steps = False))\n", "plan_nested = plan_nested + cx.absolute.assign(cx.solve(N[\"Shield\"]), RootFinding(steps = False))\n", "plan_nested = plan_nested + cx.absolute.assign(temperature_problem, FixedPointIteration(steps = False))\n", "# The bootstrap problem is the outer one: every other problem on its cycle is answered\n", "# inside its iteration, which `Nest` records on the graph, one pair at a time.\n", "block = next(e for e in entries(plan_nested.graph) if bootstrap_current_problem in e.nodes)\n", "for p in block.subgraph.outermost_problems:\n", " if p != bootstrap_current_problem:\n", " plan_nested = plan_nested + Nest(p, bootstrap_current_problem)\n", "jit_nested = JitGraph((plan_nested + solvers).graph)\n", "\n", "xdsm(jit_nested, blocks=True)" ] }, { "cell_type": "markdown", "id": "43", "metadata": {}, "source": [ "The nested route, step by step:" ] }, { "cell_type": "code", "execution_count": null, "id": "44", "metadata": {}, "outputs": [], "source": [ "xdsm(plan_nested + solvers, blocks=True)" ] }, { "cell_type": "markdown", "id": "45", "metadata": {}, "source": [ "We can see we have a nested evaluation and solver.\n", "\n", "We can re-use the above functions to run this schedule:" ] }, { "cell_type": "code", "execution_count": null, "id": "46", "metadata": {}, "outputs": [], "source": [ "print_results(run_on(jit_nested, design_point_varpath, guesses))" ] }, { "cell_type": "markdown", "id": "47", "metadata": {}, "source": [ "Naturally, this gives the exact same results." ] }, { "cell_type": "markdown", "id": "48", "metadata": {}, "source": [ "## Summary\n", "\n", "In this notebook, a simple cyclic tokamak model with cycles has been solved. Graph operations were used to convert these cycles to fixed point problems, and they can be assigned drivers, combined, and solved. " ] } ], "metadata": { "kernelspec": { "display_name": "process_port", "language": "python", "name": "python3" }, "language_info": { "codemirror_mode": { "name": "ipython", "version": 3 }, "file_extension": ".py", "mimetype": "text/x-python", "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", "version": "3.12.13" } }, "nbformat": 4, "nbformat_minor": 5 }