{ "cells": [ { "cell_type": "markdown", "source": [ "# Vehicle routing with Timefold in a Kotlin notebook\n", "\n", "This notebook solves a simple Capacitated Vehicle Routing Problem (CVRP) in Kotlin with Timefold, the open source solver AI.\n", "\n", "Input:\n", "* A set of visits with a location and a load\n", "* A set of vehicles with a home location and a capacity\n", "\n", "Output:\n", "* Each visit assigned to a vehicle\n", "* Per vehicle the order in which to travel to the visits assigned to it\n", "\n", "Constraints:\n", "* Hard: Do not exceed the capacity of each visit.\n", "* Soft: Minimize the travel distance.\n", "\n", "## Dependencies\n", "\n", "Let's use Timefold to optimize the vehicle routing problem and jackson to read the input JSON file:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "@file:DependsOn(\"ai.timefold.solver:timefold-solver-core:1.11.0\")\n", "@file:DependsOn(\"com.fasterxml.jackson.module:jackson-module-kotlin:2.17.1\")" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:52.817802Z", "start_time": "2024-06-29T07:40:52.750516Z" } }, "outputs": [], "execution_count": 16 }, { "cell_type": "markdown", "source": [ "## Data classes\n", "\n", "### Location\n", "\n", "A location is a point on the earth, specified by a latitude and a longitude:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "import com.fasterxml.jackson.annotation.JsonFormat\n", "\n", "@JsonFormat(shape = JsonFormat.Shape.ARRAY)\n", "data class Location(\n", " val latitude: Double,\n", " val longitude: Double) {\n", " \n", " fun calcEuclideanDistanceTo(other: Location): Double {\n", " val xDifference = latitude - other.latitude\n", " val yDifference = longitude - other.longitude\n", " return Math.sqrt(xDifference * xDifference + yDifference * yDifference)\n", " }\n", " \n", "}" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:52.863991Z", "start_time": "2024-06-29T07:40:52.818570Z" } }, "outputs": [], "execution_count": 17 }, { "cell_type": "markdown", "source": [ "### Visit\n", "\n", "Each visit has a name, a location and a load to be delivered:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "import java.time.Duration\n", "\n", "data class Visit(\n", " val name: String,\n", " val location: Location,\n", " val load: Int) {\n", "\n", " override fun toString(): String = name\n", "}" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:52.896663Z", "start_time": "2024-06-29T07:40:52.864679Z" } }, "outputs": [], "execution_count": 18 }, { "cell_type": "markdown", "source": [ "### Vehicle\n", "\n", "Each vehicle has a name, a home location and a capacity.\n", "\n", "The solver assigns each visit to a vehicle and decides the best order of the visits per vehicle.\n", "Therefor, the `Vehicle` class is a `@PlanningEntity`, because it changes during solving.\n", "Its `visits` field is a `@PlanningListVariable`, because the solver fills it in.\n" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "import ai.timefold.solver.core.api.domain.entity.PlanningEntity\n", "import ai.timefold.solver.core.api.domain.variable.PlanningListVariable\n", "\n", "\n", "@PlanningEntity\n", "data class Vehicle(\n", " val name: String,\n", " val homeLocation: Location,\n", " val capacity: Int) {\n", "\n", " @PlanningListVariable\n", " var visits: MutableList = ArrayList()\n", " \n", " // No-arg constructor required for Timefold\n", " constructor() : this(\"\", Location(0.0, 0.0), 0)\n", "\n", " override fun toString(): String = name\n", "}" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:52.945792Z", "start_time": "2024-06-29T07:40:52.897423Z" } }, "outputs": [], "execution_count": 19 }, { "cell_type": "markdown", "source": [ "## Constraints\n", "\n", "There are hard and soft constraints:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "import ai.timefold.solver.core.api.score.buildin.hardsoftlong.HardSoftLongScore\n", "import ai.timefold.solver.core.api.score.stream.Constraint\n", "import ai.timefold.solver.core.api.score.stream.ConstraintFactory\n", "import ai.timefold.solver.core.api.score.stream.ConstraintProvider\n", "import ai.timefold.solver.core.api.score.stream.Joiners\n", "import java.time.Duration\n", "\n", "class VehicleRoutingConstraintProvider : ConstraintProvider {\n", "\n", " override fun defineConstraints(constraintFactory: ConstraintFactory): Array? {\n", " return arrayOf(\n", " // Hard constraints\n", " capacity(constraintFactory),\n", " // Soft constraints\n", " minimizeDistance(constraintFactory)\n", " )\n", " }\n", " \n", " fun capacity(constraintFactory: ConstraintFactory): Constraint {\n", " // TODO Not the most efficient implementation\n", " return constraintFactory\n", " .forEach(Vehicle::class.java)\n", " .expand({ vehicle -> vehicle.visits.sumOf { it.load } })\n", " .filter({ vehicle, load -> load > vehicle.capacity })\n", " .penalizeLong(HardSoftLongScore.ONE_HARD,\n", " { vehicle, load -> (load - vehicle.capacity).toLong() })\n", " .asConstraint(\"vehicle-routing\", \"Capacity\");\n", " }\n", " \n", " fun minimizeDistance(constraintFactory: ConstraintFactory): Constraint {\n", " // TODO Not the most efficient implementation\n", " return constraintFactory\n", " .forEach(Vehicle::class.java)\n", " .penalizeLong(HardSoftLongScore.ONE_SOFT, { vehicle ->\n", " var distance: Double = 0.0\n", " var previousLocation: Location = vehicle.homeLocation\n", " for (visit in vehicle.visits) {\n", " distance += previousLocation.calcEuclideanDistanceTo(visit.location)\n", " previousLocation = visit.location\n", " }\n", " distance += previousLocation.calcEuclideanDistanceTo(vehicle.homeLocation)\n", " (distance * 1_000_000.0).toLong()\n", " })\n", " .asConstraint(\"vehicle-routing\", \"Minimize distance\");\n", " }\n", "\n", "}" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:53.054735Z", "start_time": "2024-06-29T07:40:52.946507Z" } }, "outputs": [], "execution_count": 20 }, { "cell_type": "markdown", "source": [ "## Schedule\n", "\n", "The `Schedule` class holds the entire dataset.\n", "It contains the list of all vehicles (the entities the solver must fill in) and a list of all visits (the values it needs to assign to those entities)." ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "import ai.timefold.solver.core.api.domain.solution.PlanningEntityCollectionProperty\n", "import ai.timefold.solver.core.api.domain.solution.PlanningScore\n", "import ai.timefold.solver.core.api.domain.solution.PlanningSolution\n", "import ai.timefold.solver.core.api.domain.solution.ProblemFactCollectionProperty\n", "import ai.timefold.solver.core.api.domain.valuerange.ValueRangeProvider\n", "import ai.timefold.solver.core.api.score.buildin.hardsoftlong.HardSoftLongScore\n", "\n", "\n", "@PlanningSolution\n", "data class Schedule(\n", " val name: String,\n", " @PlanningEntityCollectionProperty\n", " val vehicles: List,\n", " @ProblemFactCollectionProperty\n", " @ValueRangeProvider\n", " val visits: List) {\n", "\n", " @PlanningScore\n", " var score: HardSoftLongScore? = null\n", "\n", " // No-arg constructor required for Timefold\n", " constructor() : this(\"\", emptyList(), emptyList())\n", "\n", "}" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:53.105289Z", "start_time": "2024-06-29T07:40:53.055746Z" } }, "outputs": [], "execution_count": 21 }, { "cell_type": "markdown", "source": [ "## Read the input data\n", "\n", "Read the input dataset from a JSON file into a `Schedule` instance:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "import java.io.File\n", "import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper\n", "import com.fasterxml.jackson.module.kotlin.readValue\n", "\n", "val mapper = jacksonObjectMapper()\n", "val problem: Schedule = mapper.readValue(File(\"vehicle-routing-data.json\"))" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:53.152111Z", "start_time": "2024-06-29T07:40:53.105944Z" } }, "outputs": [], "execution_count": 22 }, { "cell_type": "markdown", "source": [ "## Solve\n", "\n", "Let's solve for 30 seconds:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "import ai.timefold.solver.core.config.solver.SolverConfig\n", "import ai.timefold.solver.core.api.solver.SolverFactory\n", "import ai.timefold.solver.core.api.solver.Solver\n", "\n", "val solverFactory: SolverFactory = SolverFactory.create(SolverConfig()\n", " .withSolutionClass(Schedule::class.java)\n", " .withEntityClasses(Vehicle::class.java)\n", " .withConstraintProviderClass(VehicleRoutingConstraintProvider::class.java)\n", " // The solver runs only for 5 seconds on this small dataset.\n", " // It's recommended to run for at least 5 minutes (\"5m\") otherwise.\n", " .withTerminationSpentLimit(Duration.ofSeconds(5)))\n", "\n", "println(\"Solving the problem ...\")\n", "val solver: Solver = solverFactory.buildSolver()\n", "val solution: Schedule = solver.solve(problem)\n", "println(\"Solving finished with score (${solution.score}).\")" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:58.203503Z", "start_time": "2024-06-29T07:40:53.152916Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "Solving the problem ...\n", "Solving finished with score (0hard/-24094376soft).\n" ] } ], "execution_count": 23 }, { "cell_type": "markdown", "source": [ "## Print the schedule\n", "\n", "Print the visits per vehicle:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "HTML(buildString {\n", " append(\"

Score: ${solution.score}

\")\n", " append(\"
    \")\n", " for (vehicle in solution.vehicles) {\n", " append(\"
  • ${vehicle.name}: ${vehicle.visits.joinToString(\", \")}
  • \")\n", " }\n", " append(\"
\")\n", "})" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:40:58.238623Z", "start_time": "2024-06-29T07:40:58.204010Z" } }, "outputs": [ { "data": { "text/html": [ "

Score: 0hard/-24094376soft

  • Vehicle A: JURBISE, CHIEVRES, MAFFLE, GONDREGNIES, PEPINGEN
  • Vehicle B: KOMEN, ELVERDINGE, RENINGE, BULSKAMP, LISSEWEGE, DAMME, LANDEGEM, NIEUWKERKEN-WAAS
  • Vehicle C: LES_BONS_VILLERS, HEPPIGNIES, ORET, MONT_NAM., EVELETTE, WARNANT-DREYE
  • Vehicle D: FONTAINE-L'EVEQUE, SOMZEE, SAINT-MARTIN, VOSSEM
  • Vehicle E: OLLIGNIES, MAULDE, GUIGNIES, BAVIKHOVE, WETTEREN
  • Vehicle F: DONSTIENNES, BOURLERS, BERSILLIES-L'ABBAYE, NAAST, SINT-KWINTENS-LENNIK
  • Vehicle G: AVE-ET-AUFFE, POUPEHAN, VAUX-LEZ-ROSIERES, SELANGE, TAILLES, ANTHISNES
  • Vehicle H: HUMBEEK, HOFSTADE_BT., HAREN_BRUSSEL
  • Vehicle I: GELINDEN, VILLERS-L'EVEQUE, ROSMEER, MERKSPLAS
  • Vehicle J: TILFF, XHENDELESSE, BLERET
" ] }, "execution_count": 24, "metadata": {}, "output_type": "execute_result" } ], "execution_count": 24 }, { "cell_type": "markdown", "source": [ "## Visualization\n", "\n", "Visualize the solution:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "%use lets-plot\n", "%use lets-plot-gt" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:41:09.507058Z", "start_time": "2024-06-29T07:40:58.239054Z" } }, "outputs": [ { "data": { "text/html": [ "
\n", " " ] }, "metadata": {}, "output_type": "display_data" } ], "execution_count": 25 }, { "cell_type": "markdown", "source": [ "### Map" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "val locations = mutableListOf()\n", "solution.vehicles.forEach { vehicle ->\n", " locations.add(vehicle.homeLocation)\n", " locations.addAll(vehicle.visits.map { it.location })\n", " locations.add(vehicle.homeLocation)\n", "}\n", "\n", "val dataset = mapOf(\n", " \"latitude\" to locations.map { it.latitude },\n", " \"longitude\" to locations.map { it.longitude },\n", ")\n", "\n", "print(\"The notebook must be trusted for the map to render.\")\n", "letsPlot(dataset) + geomPath() { x = \"longitude\"; y = \"latitude\" }" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:41:09.594765Z", "start_time": "2024-06-29T07:41:09.508077Z" } }, "outputs": [ { "name": "stdout", "output_type": "stream", "text": [ "The notebook must be trusted for the map to render." ] }, { "data": { "text/html": [ "
\n", " " ] }, "execution_count": 26, "metadata": {}, "output_type": "execute_result" } ], "execution_count": 26 }, { "cell_type": "markdown", "source": [ "## Statistics\n", "\n", "For a big dataset, a schedule visualization is often too verbose.\n", "Let's visualize the solution through statistics:" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "%use kandy" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:41:15.392707Z", "start_time": "2024-06-29T07:41:09.595478Z" } }, "outputs": [], "execution_count": 27 }, { "cell_type": "markdown", "source": [ "### Visits per vehicle" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "val vehicles = solution.vehicles.map { it.name }\n", "val visitCounts = solution.vehicles.map { it.visits.size }\n", "\n", "plot {\n", " layout.title = \"Visits per vehicle\"\n", " bars {\n", " x(vehicles) { axis.name = \"Vehicle\" }\n", " y(visitCounts) { axis.name = \"Visits\" }\n", " }\n", "}" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:41:15.459461Z", "start_time": "2024-06-29T07:41:15.393382Z" } }, "outputs": [ { "data": { "text/html": [ " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle A\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle B\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle C\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle D\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle E\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle F\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle G\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle H\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle I\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle J\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " 0\n", " \n", " \n", " \n", " \n", " \n", " \n", " 2\n", " \n", " \n", " \n", " \n", " \n", " \n", " 4\n", " \n", " \n", " \n", " \n", " \n", " \n", " 6\n", " \n", " \n", " \n", " \n", " \n", " \n", " 8\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Visits per vehicle\n", " \n", " \n", " \n", " \n", " Visits\n", " \n", " \n", " \n", " \n", " Vehicle\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "\n", " " ], "application/plot+json": { "output_type": "lets_plot_spec", "output": { "ggtitle": { "text": "Visits per vehicle" }, "mapping": {}, "data": {}, "kind": "plot", "scales": [ { "aesthetic": "x", "discrete": true, "name": "Vehicle" }, { "aesthetic": "y", "name": "Visits", "limits": [ null, null ] } ], "layers": [ { "mapping": { "x": "x", "y": "y" }, "stat": "identity", "data": { "x": [ "Vehicle A", "Vehicle B", "Vehicle C", "Vehicle D", "Vehicle E", "Vehicle F", "Vehicle G", "Vehicle H", "Vehicle I", "Vehicle J" ], "y": [ 5.0, 8.0, 6.0, 4.0, 5.0, 5.0, 6.0, 3.0, 4.0, 3.0 ] }, "sampling": "none", "position": "dodge", "geom": "bar" } ] }, "apply_color_scheme": true, "swing_enabled": true } }, "execution_count": 28, "metadata": {}, "output_type": "execute_result" } ], "execution_count": 28 }, { "cell_type": "markdown", "source": [ "### Load per vehicle" ], "metadata": { "collapsed": false } }, { "cell_type": "code", "source": [ "val vehicles = solution.vehicles.map { it.name }\n", "val load = solution.vehicles.map { it.visits.sumOf { it.load } }\n", "\n", "plot {\n", " layout.title = \"Load per vehicle\"\n", " bars {\n", " x(vehicles) { axis.name = \"Vehicle\" }\n", " y(load) { axis.name = \"Load\" }\n", " }\n", "}" ], "metadata": { "collapsed": false, "ExecuteTime": { "end_time": "2024-06-29T07:41:15.535780Z", "start_time": "2024-06-29T07:41:15.459976Z" } }, "outputs": [ { "data": { "text/html": [ " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle A\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle B\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle C\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle D\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle E\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle F\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle G\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle H\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle I\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Vehicle J\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " 0\n", " \n", " \n", " \n", " \n", " \n", " \n", " 20\n", " \n", " \n", " \n", " \n", " \n", " \n", " 40\n", " \n", " \n", " \n", " \n", " \n", " \n", " 60\n", " \n", " \n", " \n", " \n", " \n", " \n", " 80\n", " \n", " \n", " \n", " \n", " \n", " \n", " 100\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", " Load per vehicle\n", " \n", " \n", " \n", " \n", " Load\n", " \n", " \n", " \n", " \n", " Vehicle\n", " \n", " \n", " \n", " \n", " \n", " \n", " \n", "\n", " " ], "application/plot+json": { "output_type": "lets_plot_spec", "output": { "ggtitle": { "text": "Load per vehicle" }, "mapping": {}, "data": {}, "kind": "plot", "scales": [ { "aesthetic": "x", "discrete": true, "name": "Vehicle" }, { "aesthetic": "y", "name": "Load", "limits": [ null, null ] } ], "layers": [ { "mapping": { "x": "x", "y": "y" }, "stat": "identity", "data": { "x": [ "Vehicle A", "Vehicle B", "Vehicle C", "Vehicle D", "Vehicle E", "Vehicle F", "Vehicle G", "Vehicle H", "Vehicle I", "Vehicle J" ], "y": [ 89.0, 98.0, 100.0, 98.0, 93.0, 88.0, 100.0, 76.0, 94.0, 98.0 ] }, "sampling": "none", "position": "dodge", "geom": "bar" } ] }, "apply_color_scheme": true, "swing_enabled": true } }, "execution_count": 29, "metadata": {}, "output_type": "execute_result" } ], "execution_count": 29 }, { "metadata": {}, "cell_type": "markdown", "source": [ "## Analyze the score\n", "\n", "Break down the score per constraint and print it:" ] }, { "metadata": { "ExecuteTime": { "end_time": "2024-06-29T07:41:15.592177Z", "start_time": "2024-06-29T07:41:15.536631Z" } }, "cell_type": "code", "source": [ "import ai.timefold.solver.core.api.solver.SolutionManager\n", "\n", "val solutionManager = SolutionManager.create(solverFactory)\n", "val scoreAnalysis = solutionManager.analyze(solution)\n", "\n", "HTML(buildString {\n", " append(\"

Score: ${scoreAnalysis.score}

\")\n", " append(\"
    \")\n", " for (constraint in scoreAnalysis.constraintMap().values) {\n", " append(\"
  • ${constraint.constraintRef().constraintName}: ${constraint.score.toShortString()}
  • \")\n", " }\n", " append(\"
\")\n", "})" ], "outputs": [ { "data": { "text/html": [ "

Score: 0hard/-24094376soft

  • Minimize distance: -24094376soft
  • Capacity: 0
" ] }, "execution_count": 30, "metadata": {}, "output_type": "execute_result" } ], "execution_count": 30 }, { "cell_type": "markdown", "source": [ "## Conclusion\n", "\n", "To learn more about planning optimization, visit [timefold.ai](https://timefold.ai)." ], "metadata": { "collapsed": false } } ], "metadata": { "kernelspec": { "display_name": "Kotlin", "language": "kotlin", "name": "kotlin" }, "language_info": { "name": "kotlin", "version": "1.8.0", "mimetype": "text/x-kotlin", "file_extension": ".kt", "pygments_lexer": "kotlin", "codemirror_mode": "text/x-kotlin", "nbconvert_exporter": "" } }, "nbformat": 4, "nbformat_minor": 0 }