{ "cells": [ { "cell_type": "markdown", "id": "1ee71176-df4c-4692-b0f1-457780083eca", "metadata": {}, "source": [ "# Tutorial\n", "This is a thorough introduction to the basics of puzzlepiece. You can read through this Notebook here, but it's probably nicer to run it yourself! You can download it from https://github.com/jdranczewski/puzzlepiece/blob/main/docs/source/tutorial.ipynb\n", "\n", "You need puzzlepiece and some version of Qt (PySide6 or PyQt6 for example) installed to run this. Note that Anaconda installation comes with PyQt5 already!\n", "\n", "```pip install puzzlepiece```\n", "\n", "Other requirements include `numpy` and `tqdm`.\n", "\n", "Docs are at https://puzzlepiece.readthedocs.io/en/stable/index.html - you can also press shift-tab when your cursor is \"inside\" at method (for example `pzp.param.spinb|ox` or `pzp.param.spinbox(|`) to bring up the help text for that specific function. Good luck!" ] }, { "cell_type": "code", "execution_count": 1, "id": "b3f67270-598a-4913-b506-357e00d9e6e2", "metadata": {}, "outputs": [], "source": [ "# Enable the GUI integration for this Notebook\n", "%gui qt" ] }, { "cell_type": "code", "execution_count": 2, "id": "5b35ecdb-554d-4abc-8e13-58ead857dce9", "metadata": {}, "outputs": [], "source": [ "# Main GUI framework\n", "import puzzlepiece as pzp\n", "# Plotting framework\n", "import pyqtgraph as pg\n", "# Progress bar library\n", "from tqdm import tqdm\n", "# A way to access Qt Widgets (independent of whether the user has PyQt or PySide installed)\n", "from qtpy import QtWidgets\n", "# Other libraries\n", "import numpy as np\n", "import time" ] }, { "cell_type": "markdown", "id": "25a3a3ab-f9ab-4b41-b479-f615468d5ee7", "metadata": {}, "source": [ "## Our first Piece\n", "This one is pretty boring and empty." ] }, { "cell_type": "code", "execution_count": 3, "id": "de86e228-ec60-4d25-bff6-92f36608d3ea", "metadata": {}, "outputs": [], "source": [ "class Piece(pzp.Piece):\n", " pass" ] }, { "cell_type": "markdown", "id": "dd7245f6-26cc-4893-a6f5-f5e5eaeeee75", "metadata": {}, "source": [ "Let's add it to a Puzzle. A tiny new window should appear. You can close this window when moving on to the next section." ] }, { "cell_type": "code", "execution_count": 4, "id": "1008ffda-fda3-40dd-ae01-4af99e3d718c", "metadata": {}, "outputs": [], "source": [ "puzzle = pzp.Puzzle()\n", "# The Pieces are added on a grid\n", "puzzle.add_piece(\"piece_name\", Piece, row=0, column=0)\n", "# In Qt you need to show a Widget for it to appear\n", "puzzle.show()" ] }, { "cell_type": "markdown", "id": "c2edb301-125d-4bd8-96a0-70ffd667418d", "metadata": {}, "source": [ "## Adding params\n", "This Piece will show a text box that you can edit" ] }, { "cell_type": "code", "execution_count": 5, "id": "acaa5648-bb66-4e48-aa01-8a9ef4862ef3", "metadata": {}, "outputs": [], "source": [ "class Piece(pzp.Piece):\n", " def define_params(self):\n", " # You will define your params in here\n", " # The (None) indicates that this param has no getter or setter (we'll get to these)\n", " pzp.param.text(self, \"name\", \"Jakub\")(None)\n", "\n", "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", "puzzle.show()" ] }, { "cell_type": "markdown", "id": "62af433f-ee16-4b6d-93b8-21d112543cde", "metadata": {}, "source": [ "## Params with getters\n", "Some params can call a function to get a value (intensity from a powermeter, say).\n", "\n", "Click the \"refresh\" button to get a value." ] }, { "cell_type": "code", "execution_count": 6, "id": "5bbd6f4a-af0f-4eea-b5e7-e66da0b9de9e", "metadata": {}, "outputs": [], "source": [ "class Piece(pzp.Piece):\n", " def define_params(self):\n", " pzp.param.text(self, \"name\", \"Jakub\")(None)\n", "\n", " # A spinbox is a number input\n", " pzp.param.spinbox(self, \"born\", 1999)(None)\n", " pzp.param.spinbox(self, \"now\", 2024)(None)\n", "\n", " # This param has a getter - a function to obtain its value\n", " # This is achieved by using `readout` as a \"decorator\" on a function (spot the @, and lack of (None))\n", " @pzp.param.readout(self, \"age\")\n", " def age(self):\n", " # This method accesses the other two params to compute an age\n", " return self[\"now\"].value - self[\"born\"].value\n", "\n", "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", "puzzle.show()" ] }, { "cell_type": "markdown", "id": "1ea423d8-d1a0-42b0-8304-45b45f84b395", "metadata": {}, "source": [ "## Params with setters\n", "Some params call a function to set a value (for example the integration time).\n", "\n", "Note that the text field gets red when you edit it - this indicates that the text in the box changed, but the setter has not yet been called." ] }, { "cell_type": "code", "execution_count": 7, "id": "3209b136-3981-44be-a752-fefd0ce1cd9d", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The user's name is now Jakub\n" ] } ], "source": [ "class Piece(pzp.Piece):\n", " def define_params(self):\n", " # Notice that we're using `text` as a decorator again - whether this makes the method below a getter or a setter depends on the input type.\n", " # Text boxes and spinboxes have setters by default, readouts and arrays have getters. Check https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html\n", " # for all available input decorators and their default behaviour\n", " @pzp.param.text(self, \"name\", \"Jakub\")\n", " def name(self, value):\n", " # `value` is the new value of the param\n", " print(\"The user's name is now\", value)\n", "\n", " pzp.param.spinbox(self, \"born\", 1999)(None)\n", " pzp.param.spinbox(self, \"now\", 2024)(None)\n", "\n", " @pzp.param.readout(self, \"age\")\n", " def age(self):\n", " return self[\"now\"].value - self[\"born\"].value\n", "\n", "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", "puzzle.show()" ] }, { "cell_type": "markdown", "id": "86e994b0-9840-46e3-9212-2bfe14a89d9d", "metadata": {}, "source": [ "## Params with getters and setters\n", "Some params may have a getter and a setter simultaneously (you can set an integration time, but you can also ask the device what it is)." ] }, { "cell_type": "code", "execution_count": 8, "id": "ca11d976-9c37-47b9-b667-15d8c4322a9c", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The user's name is now William\n" ] } ], "source": [ "class Piece(pzp.Piece):\n", " def define_params(self):\n", " @pzp.param.text(self, \"name\", \"Jakub\")\n", " def name(self, value):\n", " print(\"The user's name is now\", value)\n", " # We need to return the value here to acknowledge that we've set it, otherwise the getter will be called\n", " # to double check it. See https://puzzlepiece.readthedocs.io/en/stable/puzzlepiece.param.html#puzzlepiece.param.BaseParam.set_value\n", " # for the details of this logic.\n", " return value\n", "\n", " # Here, we're using the `set_getter` method of the name param to add a getter to it (it already has a setter) \n", " @name.set_getter(self)\n", " def name(self):\n", " return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])\n", "\n", " pzp.param.spinbox(self, \"born\", 1999)(None)\n", " pzp.param.spinbox(self, \"now\", 2024)(None)\n", "\n", " @pzp.param.readout(self, \"age\")\n", " def age(self):\n", " return self[\"now\"].value - self[\"born\"].value\n", "\n", "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", "puzzle.show()" ] }, { "cell_type": "markdown", "id": "3c88ce74-73aa-4186-89a5-b2aeaa24798e", "metadata": {}, "source": [ "## Actions\n", "Sometimes you need to do something, like save an image from a camera.\n", "\n", "Note that the gretting prints \"Hello None\" until the name is explicitly set - params with setters hold no value internally until the setter is called." ] }, { "cell_type": "code", "execution_count": 9, "id": "511b2cd8-eaca-4543-959d-321e6d93e0cf", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello None your age is 25\n", "The user's name is now Jakub\n", "Hello Jakub your age is 25\n" ] } ], "source": [ "class Piece(pzp.Piece):\n", " def define_params(self):\n", " @pzp.param.text(self, \"name\", \"Jakub\")\n", " def name(self, value):\n", " print(\"The user's name is now\", value)\n", " return value\n", " \n", " @name.set_getter(self)\n", " def name(self):\n", " return np.random.choice(['James', 'John', 'Robert', 'Michael', 'William', 'David'])\n", "\n", " pzp.param.spinbox(self, \"born\", 1999)(None)\n", " pzp.param.spinbox(self, \"now\", 2024)(None)\n", "\n", " @pzp.param.readout(self, \"age\")\n", " def age(self):\n", " return self[\"now\"].value - self[\"born\"].value\n", "\n", " def define_actions(self):\n", " # we define our actions here, using decorators on the functions\n", " @pzp.action.define(self, \"Greet\")\n", " def greet(self):\n", " # Note the difference between .value and .get_value()\n", " # .value accesses the interally stored param value, not calling the getter (which would return a random name here)\n", " # .get_value calls the getter if there's one (in this case to calculate the age)\n", " print(\"Hello\", self[\"name\"].value, \"your age is\", self[\"age\"].get_value())\n", "\n", "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"piece_name\", Piece, 0, 0)\n", "puzzle.show()" ] }, { "cell_type": "markdown", "id": "6f9e2ada-30bd-47d2-960f-2697c3263179", "metadata": {}, "source": [ "## Accessing params and actions from code\n", "You've seen glimpses of this already, but there's two ways to interact with a Piece. We can click through the GUI, or we can use the API from code to set, get, and run actions.\n", "\n", "Keep the Puzzle created below open while you run the subsequent cells and observe how it changes." ] }, { "cell_type": "code", "execution_count": 10, "id": "b63aa431-619f-465a-af93-565d4f6bc276", "metadata": {}, "outputs": [], "source": [ "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"piece1\", Piece, 0, 0)\n", "puzzle.add_piece(\"piece2\", Piece, 0, column=1)\n", "puzzle.show()" ] }, { "cell_type": "code", "execution_count": 11, "id": "a0391f4a-fcf7-40bf-84b4-31d727258e27", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The user's name is now James\n" ] }, { "data": { "text/plain": [ "'James'" ] }, "execution_count": 11, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# Note that this will also return the new value\n", "puzzle[\"piece1\"][\"name\"].set_value(\"James\")" ] }, { "cell_type": "code", "execution_count": 12, "id": "dca1d95a-6b37-48bb-8bb0-d1b3a439b01f", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The user's name is now John\n" ] }, { "data": { "text/plain": [ "'John'" ] }, "execution_count": 12, "metadata": {}, "output_type": "execute_result" } ], "source": [ "puzzle[\"piece2\"][\"name\"].set_value(\"John\")" ] }, { "cell_type": "code", "execution_count": 13, "id": "9b899070-d391-410e-86ee-f9d6084e0be8", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'James'" ] }, "execution_count": 13, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# doesn't call the getter\n", "puzzle[\"piece1\"][\"name\"].value" ] }, { "cell_type": "code", "execution_count": 14, "id": "66393a1c-5378-4b7a-956c-4cb377bb79bd", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'William'" ] }, "execution_count": 14, "metadata": {}, "output_type": "execute_result" } ], "source": [ "# does call the getter\n", "puzzle[\"piece1\"][\"name\"].get_value()" ] }, { "cell_type": "code", "execution_count": 15, "id": "667aece3-9369-4c37-825a-cd2ab46b53f1", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "1999" ] }, "execution_count": 15, "metadata": {}, "output_type": "execute_result" } ], "source": [ "puzzle[\"piece2\"][\"born\"].get_value()" ] }, { "cell_type": "code", "execution_count": 16, "id": "ad1d50ca-e9b9-4ea7-a2d8-0175fbaddc9a", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "124" ] }, "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ "puzzle[\"piece2\"][\"born\"].set_value(1900)\n", "puzzle[\"piece2\"][\"age\"].get_value()" ] }, { "cell_type": "code", "execution_count": 17, "id": "f900da6d-2d95-46f3-abe1-e5e263544b0f", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "124\n", "114\n", "104\n", "94\n", "84\n", "74\n", "64\n" ] } ], "source": [ "for year in range(1900, 1961, 10):\n", " puzzle[\"piece2\"][\"born\"].set_value(year)\n", " print(puzzle[\"piece2\"][\"age\"].get_value())" ] }, { "cell_type": "code", "execution_count": 18, "id": "ead8a687-4b9c-4ec1-8e12-b8b669a6eb64", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "124\n", "114\n", "104\n", "94\n", "84\n", "74\n", "64\n" ] } ], "source": [ "for year in range(1900, 1961, 10):\n", " puzzle[\"piece2\"][\"born\"].set_value(year)\n", " print(puzzle[\"piece2\"][\"age\"].get_value())\n", " # Note that while a function or Notebook cell is running, the Puzzle will only\n", " # update the GUI if you explicitly tell it too\n", " puzzle.process_events()\n", " # delay added to make changes visible\n", " time.sleep(.1)" ] }, { "cell_type": "code", "execution_count": 19, "id": "29d81b2f-323a-437b-8736-162d306536b6", "metadata": {}, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Hello William your age is 25\n", "Hello John your age is 64\n" ] } ], "source": [ "puzzle[\"piece1\"].actions[\"Greet\"]()\n", "puzzle[\"piece2\"].actions[\"Greet\"]()" ] }, { "cell_type": "markdown", "id": "6e0ce313-aa3e-406a-9d13-cc2b9a909261", "metadata": {}, "source": [ "## Custom layouts\n", "You can make any Qt Layout appear within your Piece. https://www.pythonguis.com/ is a decent primer on how these work. Here's a TL;DR:" ] }, { "cell_type": "markdown", "id": "bf5bb99d-8f91-473f-89df-90789ac82df1", "metadata": {}, "source": [ "* every GUI component in Qt (a button, a text box, a label) is a 'Widget'\n", "* Widgets go into Layouts - the Layout describes how the Widgets are laid out\n", "* a Widget is actually a very general concept - any element of your app that's on screen is probably a Widget. For example, a form can be a Widget that contains multiple input box Widgets. It all nests into multiple layers of Widgets containing other Widgets\n", "* a Widget can contain a Layout as well, which is how this nesting is achieved. So a Widget has a Layout, and other Widgets are placed within this Layout\n", "* nearly everything in puzzlepiece is secretly a Widget - for example the param objects are Widgets so that they can be displayed inside a Piece\n", "* Widgets can have Signals and you can 'connect' functions to those signals - the function is then called when the Signal is 'emitted'. For example, the params in puzzlepiece have a 'changed' Signal, which is emitted whenever the param value changes. You can connect functions to this Signal so that they are called each time the param value changes." ] }, { "cell_type": "code", "execution_count": 20, "id": "797d800b-9cc3-45e7-965a-cba20e753ec6", "metadata": {}, "outputs": [], "source": [ "class RandomPiece(pzp.Piece):\n", " def define_params(self):\n", " pzp.param.spinbox(self, \"N\", 100)(None)\n", " @pzp.param.array(self, \"random\")\n", " def random(self):\n", " return np.random.random(self[\"N\"].value)\n", "\n", " def custom_layout(self):\n", " # this method should return a QT Layout that will be placed inside the Piece\n", " layout = QtWidgets.QVBoxLayout()\n", "\n", " # We create a plot Widget (from pyqtgraph) and add it to the Layout\n", " pw = pg.PlotWidget()\n", " layout.addWidget(pw)\n", " # pyqtgraph thinks of things as \"Items\" - the plot is an item, the lines within it are Items,\n", " # images are ImageItems, etc - for a list see https://pyqtgraph.readthedocs.io/en/latest/api_reference/graphicsItems/index.html\n", " self.plot = pw.getPlotItem()\n", " # Add an empty line to the plot\n", " self.plot_line = self.plot.plot([], [], symbol='o', symbolSize=3)\n", "\n", " def update_plot():\n", " self.plot_line.setData(self[\"random\"].value)\n", " # We connect `update_plot` to a `Signal` here - whenever the value of the `random`\n", " # param changes, update_plot is called to update the plot.\n", " # Click the refresh button next to `random` to see it happen, and change N to see what happens.\n", " self[\"random\"].changed.connect(update_plot)\n", " # for bonus points, we should really do\n", " # self[\"random\"].changed.connect(pzp.threads.CallLater(update_plot))\n", " # which would only update the plot once when the GUI refreshes\n", "\n", " return layout\n", "\n", "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"piece_name\", RandomPiece, 0, 0)\n", "puzzle.show()" ] }, { "cell_type": "markdown", "id": "2908e15c-e3b9-4221-a5e9-8656c605c374", "metadata": {}, "source": [ "## Developing your measurement\n", "You can of course just develop your measurement as a Python method to be run from a Notebook. Notice how the GUI updates only once the measurement is done - we'd need to add a `puzzle.process_events()` to refresh it explicitly." ] }, { "cell_type": "code", "execution_count": 21, "id": "5e659941-cd1d-4b48-bb2f-935f992f264e", "metadata": {}, "outputs": [], "source": [ "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"random_numbers\", RandomPiece, 0, 0)\n", "puzzle.show()" ] }, { "cell_type": "code", "execution_count": 22, "id": "bf4d9b9c-455a-4315-8a97-5a43757237dd", "metadata": {}, "outputs": [], "source": [ "def measure(M):\n", " a = []\n", " for i in tqdm(range(M)):\n", " a.append(puzzle[\"random_numbers\"][\"random\"].get_value())\n", " time.sleep(.1)\n", " return np.asarray(a)" ] }, { "cell_type": "code", "execution_count": 23, "id": "ada53eaf-9f39-4f3d-9eee-d68f0d739f13", "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ "100%|██████████████████████████████████████████████████████████████████████████████████| 12/12 [00:01<00:00, 9.69it/s]\n" ] }, { "data": { "text/plain": [ "(12, 100)" ] }, "execution_count": 23, "metadata": {}, "output_type": "execute_result" } ], "source": [ "measure(12).shape" ] }, { "cell_type": "markdown", "id": "5e86bed0-560d-42d8-b550-8e719e094da6", "metadata": {}, "source": [ "You can alternatively put your measurement into a Piece, or have a bunch of Pieces to perform various functions:" ] }, { "cell_type": "code", "execution_count": 24, "id": "49a7a88c-6179-4dd8-925e-653c7bf03efd", "metadata": {}, "outputs": [ { "data": { "text/plain": [ "'test_data_sample{metadata:sample}_{metadata:angle}deg.csv'" ] }, "execution_count": 24, "metadata": {}, "output_type": "execute_result" } ], "source": [ "class Measurement(pzp.Piece):\n", " def define_params(self):\n", " pzp.param.spinbox(self, \"M\", 500)(None)\n", " pzp.param.checkbox(self, \"gui_update\", 1)(None)\n", " pzp.param.progress(self, \"progress\")(None)\n", " pzp.param.text(self, \"filename\", \"\")(None)\n", " pzp.param.array(self, \"result\")(None)\n", "\n", " def define_actions(self):\n", " @pzp.action.define(self, \"Measure\")\n", " def measure(self):\n", " a = []\n", " # Reset the stop flag\n", " self.stop = False\n", " # Indicate progress by using the bar's `iter` method\n", " for i in self[\"progress\"].iter(range(self[\"M\"].value)):\n", " a.append(puzzle[\"random_numbers\"][\"random\"].get_value())\n", " # Break is STOP pressed\n", " if self.stop:\n", " break\n", " # Update the GUI if set to do that\n", " if self[\"gui_update\"].value:\n", " puzzle.process_events()\n", " result = self[\"result\"].set_value(a)\n", " return result\n", "\n", " @pzp.action.define(self, \"Save\")\n", " def save(self):\n", " # Use `format` to include metadata in the filename\n", " fname = pzp.parse.format(self[\"filename\"].value, self.puzzle)\n", " np.savetxt(\n", " fname,\n", " self[\"result\"].value\n", " )\n", " puzzle.run(\"prompt:File saved as \" + fname)\n", "\n", "class Metadata(pzp.Piece):\n", " # By making a Metadata Piece, you decouple the exact metadata you want to save (in the filename\n", " # or wherever) from the Measurement.\n", " def define_params(self):\n", " pzp.param.dropdown(self, \"sample\", \"A\")([\"A\", \"B\", \"C\"])\n", " pzp.param.spinbox(self, \"angle\", 0, v_step=10)(None)\n", "\n", "class FilenameHelper(pzp.Piece):\n", " def define_params(self):\n", " @pzp.param.text(self, \"filename\", \"\")\n", " def filename(self, value):\n", " self.puzzle[\"measurement\"][\"filename\"].set_value(value)\n", "\n", "puzzle = pzp.Puzzle()\n", "puzzle.add_piece(\"random_numbers\", RandomPiece, 0, 0, rowspan=2)\n", "puzzle.add_piece(\"measurement\", Measurement, 0, 1)\n", "puzzle.add_piece(\"metadata\", Metadata, 1, 1)\n", "puzzle.add_piece(\"filename\", FilenameHelper, 2, 0, colspan=2)\n", "puzzle.show()\n", "\n", "puzzle[\"filename\"][\"filename\"].set_value(\"test_data_sample{metadata:sample}_{metadata:angle}deg.csv\")" ] }, { "cell_type": "markdown", "id": "9bfddc00-c47e-42fa-a9b2-2876695fdf97", "metadata": {}, "source": [ "* Try to Measure and then Save.\n", "* Note how `pzp.parse.format` is used to replace the `{}` expressions in the filename with values from the metadata Piece\n", "* The filename Piece is there mostly to give us a wider textfield compared to the tiny one in the measurement Piece.\n", "* Notice how `self[\"progress\"].iter` wraps the `range` in the measurement iteration - similar to how `tqdm` can normally be used for progress bars (https://tqdm.github.io/)\n", "* Note how `self.stop` is used to integrate with the built-in STOP button. A result is still saved to the `result` param if you press STOP!\n", "* Notice how `puzzle.process_events()` is used to make the plot and progress bar update every iteration - the GUI could freeze without that, but the measurement would run a bit faster. Try either option by toggling the `gui_update` checkbox before measuring." ] }, { "cell_type": "markdown", "id": "8fe7cbcd-db32-4809-b2d6-edfd985ab471", "metadata": {}, "source": [ "My advice generally would be to use simple Notebook functions during the development, where the exact measurement you want to do is not clear-cut and you may want to change things about how exactly it works.\n", "\n", "Once you have a well-defined measurement, you can put it in a Piece for repeat use!" ] } ], "metadata": { "kernelspec": { "display_name": "Python 3 (ipykernel)", "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.11.4" } }, "nbformat": 4, "nbformat_minor": 5 }