diff --git a/docs/multistage_example.ipynb b/docs/multistage_example.ipynb new file mode 100644 index 000000000..5830763c4 --- /dev/null +++ b/docs/multistage_example.ipynb @@ -0,0 +1,710 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "7fb27b941602401d91542211134fc71a", + "metadata": {}, + "source": [ + "# Full-Scale Multistage Mission Example (MissionExecutor API)\n", + "\n", + "This notebook demonstrates a full mission setup using the new mission architecture:\n", + "\n", + "- **Mission** stores complete mission topology and per-item simulation inputs.\n", + "- **MissionExecutor** runs all configured stages/deployables in sequence.\n", + "- Stage/deployable bodies use full RocketPy `Rocket` models (motor, aerodynamics, parachutes).\n" + ] + }, + { + "cell_type": "markdown", + "id": "acae54e37e7d407bbb7b55eff062a284", + "metadata": {}, + "source": [ + "## 1) Imports\n" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "9a63283cbaf04dbcab1f6479b197f3a8", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:26.682661Z", + "iopub.status.busy": "2026-07-12T18:58:26.682410Z", + "iopub.status.idle": "2026-07-12T18:58:28.250548Z", + "shell.execute_reply": "2026-07-12T18:58:28.249705Z" + } + }, + "outputs": [], + "source": [ + "import datetime as dt\n", + "from pathlib import Path\n", + "\n", + "import rocketpy as rp\n", + "from rocketpy import (\n", + " Attachment,\n", + " Deployable,\n", + " DeploymentEvent,\n", + " Environment,\n", + " FlightBody,\n", + " IgnitionEvent,\n", + " Mission,\n", + " MissionExecutor,\n", + " Rocket,\n", + " RocketAdapter,\n", + " SolidMotor,\n", + " Stage,\n", + " StageSeparationEvent,\n", + ")\n", + "from rocketpy.plots.compare import CompareFlights" + ] + }, + { + "cell_type": "markdown", + "id": "8dd0d8092fe74a7c96281538738b07e2", + "metadata": {}, + "source": [ + "## 2) Environment setup\n", + "\n", + "This uses a deterministic custom atmosphere (no external download) while keeping a\n", + "full 6-DOF flight setup.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "72eea5119410473aa328ad9291626812", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.252789Z", + "iopub.status.busy": "2026-07-12T18:58:28.252520Z", + "iopub.status.idle": "2026-07-12T18:58:28.264304Z", + "shell.execute_reply": "2026-07-12T18:58:28.263591Z" + } + }, + "outputs": [], + "source": [ + "env = Environment(\n", + " latitude=32.990254,\n", + " longitude=-106.974998,\n", + " elevation=1400,\n", + " datum=\"WGS84\",\n", + ")\n", + "\n", + "tomorrow = dt.date.today() + dt.timedelta(days=1)\n", + "env.set_date((tomorrow.year, tomorrow.month, tomorrow.day, 12), timezone=\"UTC\")\n", + "env.set_atmospheric_model(\n", + " type=\"custom_atmosphere\",\n", + " pressure=101325,\n", + " temperature=298.15,\n", + " wind_u=[(0, 3.0), (2000, 5.0), (5000, 8.0)],\n", + " wind_v=[(0, -1.0), (2000, -2.0), (5000, -3.5)],\n", + ")\n", + "env.max_expected_height = 10000" + ] + }, + { + "cell_type": "markdown", + "id": "8edb47106e1a46a883d545849b8ab81b", + "metadata": {}, + "source": [ + "## 3) Build the motors and bodies\n", + "\n", + "This example keeps every object definition inline so the notebook is easy to\n", + "follow and easy to copy into a local desktop workflow. The data files are\n", + "resolved from the installed RocketPy package location instead of assuming the\n", + "notebook is launched from the repository root.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "10185d26023b46108eb7d9f57d49d2b3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.266133Z", + "iopub.status.busy": "2026-07-12T18:58:28.265982Z", + "iopub.status.idle": "2026-07-12T18:58:28.338925Z", + "shell.execute_reply": "2026-07-12T18:58:28.338299Z" + } + }, + "outputs": [ + { + "data": { + "text/plain": [ + "" + ] + }, + "execution_count": 3, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data_directory = Path(rp.__file__).resolve().parent.parent / \"data\"\n", + "motor_file = data_directory / \"motors\" / \"cesaroni\" / \"Cesaroni_M1670.eng\"\n", + "power_off_drag_file = data_directory / \"rockets\" / \"calisto\" / \"powerOffDragCurve.csv\"\n", + "power_on_drag_file = data_directory / \"rockets\" / \"calisto\" / \"powerOnDragCurve.csv\"\n", + "\n", + "first_stage_motor = SolidMotor(\n", + " thrust_source=str(motor_file),\n", + " burn_time=3.9,\n", + " dry_mass=1.815,\n", + " dry_inertia=(0.125, 0.125, 0.002),\n", + " center_of_dry_mass_position=0.317,\n", + " nozzle_position=0,\n", + " grain_number=5,\n", + " grain_density=1815,\n", + " nozzle_radius=33 / 1000,\n", + " throat_radius=11 / 1000,\n", + " grain_separation=5 / 1000,\n", + " grain_outer_radius=33 / 1000,\n", + " grain_initial_height=120 / 1000,\n", + " grains_center_of_mass_position=0.397,\n", + " grain_initial_inner_radius=15 / 1000,\n", + " interpolation_method=\"linear\",\n", + " coordinate_system_orientation=\"nozzle_to_combustion_chamber\",\n", + ")\n", + "\n", + "second_stage_motor = SolidMotor(\n", + " thrust_source=str(motor_file),\n", + " burn_time=3.9,\n", + " dry_mass=1.815,\n", + " dry_inertia=(0.125, 0.125, 0.002),\n", + " center_of_dry_mass_position=0.317,\n", + " nozzle_position=0,\n", + " grain_number=5,\n", + " grain_density=1815,\n", + " nozzle_radius=33 / 1000,\n", + " throat_radius=11 / 1000,\n", + " grain_separation=5 / 1000,\n", + " grain_outer_radius=33 / 1000,\n", + " grain_initial_height=120 / 1000,\n", + " grains_center_of_mass_position=0.397,\n", + " grain_initial_inner_radius=15 / 1000,\n", + " interpolation_method=\"linear\",\n", + " coordinate_system_orientation=\"nozzle_to_combustion_chamber\",\n", + ")\n", + "\n", + "first_stage_rocket = Rocket(\n", + " radius=0.0635,\n", + " mass=14.426,\n", + " inertia=(6.321, 6.321, 0.034),\n", + " power_off_drag=str(power_off_drag_file),\n", + " power_on_drag=str(power_on_drag_file),\n", + " center_of_mass_without_motor=0,\n", + " coordinate_system_orientation=\"tail_to_nose\",\n", + ")\n", + "first_stage_rocket.name = \"First Stage\"\n", + "first_stage_rocket.add_motor(first_stage_motor, position=-1.255)\n", + "first_stage_rocket.set_rail_buttons(\n", + " upper_button_position=0.0818,\n", + " lower_button_position=-0.618,\n", + " angular_position=45,\n", + ")\n", + "first_stage_rocket.add_nose(length=0.55829, kind=\"von karman\", position=1.278)\n", + "first_stage_rocket.add_trapezoidal_fins(\n", + " n=4,\n", + " root_chord=0.120,\n", + " tip_chord=0.060,\n", + " span=0.110,\n", + " position=-1.04956,\n", + " cant_angle=0.5,\n", + ")\n", + "first_stage_rocket.add_tail(\n", + " top_radius=0.0635,\n", + " bottom_radius=0.0435,\n", + " length=0.060,\n", + " position=-1.194656,\n", + ")\n", + "\n", + "second_stage_rocket = Rocket(\n", + " radius=0.0635,\n", + " mass=9.500,\n", + " inertia=(6.321, 6.321, 0.034),\n", + " power_off_drag=str(power_off_drag_file),\n", + " power_on_drag=str(power_on_drag_file),\n", + " center_of_mass_without_motor=0,\n", + " coordinate_system_orientation=\"tail_to_nose\",\n", + ")\n", + "second_stage_rocket.name = \"Second Stage\"\n", + "second_stage_rocket.add_motor(second_stage_motor, position=-1.000)\n", + "second_stage_rocket.add_nose(length=0.55829, kind=\"von karman\", position=1.050)\n", + "second_stage_rocket.add_trapezoidal_fins(\n", + " n=4,\n", + " root_chord=0.120,\n", + " tip_chord=0.060,\n", + " span=0.110,\n", + " position=-0.920,\n", + " cant_angle=0.5,\n", + ")\n", + "second_stage_rocket.add_tail(\n", + " top_radius=0.0635,\n", + " bottom_radius=0.0435,\n", + " length=0.060,\n", + " position=-1.194656,\n", + ")\n", + "\n", + "# The deployable payload is an unpowered Rocket (no add_motor() call, so it\n", + "# keeps RocketPy's default EmptyMotor) with its own nose cone and recovery\n", + "# system, wrapped in RocketAdapter so MissionExecutor can fly it like any\n", + "# other mission body.\n", + "payload_rocket = Rocket(\n", + " radius=0.055,\n", + " mass=4.5,\n", + " inertia=(0.25, 0.25, 0.03),\n", + " power_off_drag=str(power_off_drag_file),\n", + " power_on_drag=str(power_on_drag_file),\n", + " center_of_mass_without_motor=0,\n", + " coordinate_system_orientation=\"tail_to_nose\",\n", + ")\n", + "payload_rocket.name = \"Payload\"\n", + "payload_rocket.add_nose(length=0.20, kind=\"von karman\", position=0.30)\n", + "# NOTE: this drogue uses a direct vz<=0 trigger rather than the \"apogee\"\n", + "# preset. The payload's Flight is chained to start exactly at first_stage's\n", + "# apogee (vz≈0), so there is no local positive-to-negative vz crossing left\n", + "# for the built-in \"apogee\" trigger to detect within this Flight's own\n", + "# history; a direct velocity check deploys correctly from that starting state.\n", + "payload_rocket.add_parachute(\n", + " \"Payload Drogue\",\n", + " cd_s=0.35,\n", + " trigger=lambda **kwargs: kwargs[\"state\"][5] <= 0,\n", + " sampling_rate=105,\n", + " lag=1.0,\n", + " noise=(0, 4.0, 0.2),\n", + ")\n", + "payload_rocket.add_parachute(\n", + " \"Payload Main\",\n", + " cd_s=3.5,\n", + " trigger=700,\n", + " sampling_rate=105,\n", + " lag=1.0,\n", + " noise=(0, 4.0, 0.2),\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "8763a12b2bbd4a93a75aff182afb95dc", + "metadata": {}, + "source": [ + "## 4) Configure the stage rockets and the payload rocket\n", + "\n", + "All three mission bodies – both powered stages and the deployable payload –\n", + "use full RocketPy `Rocket` models, wrapped in `RocketAdapter` so\n", + "`MissionExecutor` can fly them uniformly. A `FlightBody` value object is\n", + "still available for lightweight bookkeeping (see the end of this notebook),\n", + "but it cannot yet drive a real `Flight` simulation directly, so it is not\n", + "used for any mission item that needs to be executed here.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "7623eae2785240b9bd12b16a66d81610", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.340702Z", + "iopub.status.busy": "2026-07-12T18:58:28.340550Z", + "iopub.status.idle": "2026-07-12T18:58:28.344242Z", + "shell.execute_reply": "2026-07-12T18:58:28.343635Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Using motor file: /Users/dubeyishan371/Desktop/Rocketpy-fork/RocketPy/data/motors/cesaroni/Cesaroni_M1670.eng\n", + "Using drag curve files: powerOffDragCurve.csv, powerOnDragCurve.csv\n", + "Payload body type: Rocket\n", + "Payload dry mass at t=0 s: 4.50 kg\n", + "Payload recovery systems: 2\n" + ] + } + ], + "source": [ + "# Deploy second-stage parachute. Like the payload's drogue, this uses a\n", + "# direct vz<=0 trigger rather than the \"apogee\" preset, since second_stage's\n", + "# Flight is chained to start at first_stage's apogee (vz≈0) and has no local\n", + "# positive-to-negative vz crossing left for \"apogee\" to detect.\n", + "second_stage_rocket.add_parachute(\n", + " \"Second Stage Main\",\n", + " cd_s=5.0,\n", + " trigger=lambda **kwargs: kwargs[\"state\"][5] <= 0,\n", + " sampling_rate=105,\n", + " lag=1.2,\n", + " noise=(0, 6.0, 0.25),\n", + ")\n", + "\n", + "print(f\"Using motor file: {motor_file}\")\n", + "print(f\"Using drag curve files: {power_off_drag_file.name}, {power_on_drag_file.name}\")\n", + "print(f\"Payload body type: {type(payload_rocket).__name__}\")\n", + "print(f\"Payload dry mass at t=0 s: {payload_rocket.total_mass(0):.2f} kg\")\n", + "print(f\"Payload recovery systems: {len(payload_rocket.parachutes)}\")" + ] + }, + { + "cell_type": "markdown", + "id": "7cdc8c89c7104fffa095e18ddfef8986", + "metadata": {}, + "source": [ + "## 5) Build mission topology\n", + "\n", + "The mission contains two stages and one deployable. All bodies are wrapped in\n", + "`RocketAdapter`, so they can be managed by mission abstractions.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b118ea5561624da68c537baed56e602f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.345736Z", + "iopub.status.busy": "2026-07-12T18:58:28.345614Z", + "iopub.status.idle": "2026-07-12T18:58:28.350366Z", + "shell.execute_reply": "2026-07-12T18:58:28.349767Z" + } + }, + "outputs": [], + "source": [ + "mission = Mission(name=\"Calisto Two-Stage + Deployable Mission\")\n", + "\n", + "first_stage_attachment = Attachment(\n", + " parent_frame_position=[0.0, 0.0, 0.0],\n", + " child_frame_position=[0.0, 0.0, 0.0],\n", + " constraints=\"rigid\",\n", + ")\n", + "\n", + "second_stage_attachment = Attachment(\n", + " parent_frame_position=[0.0, 0.0, 0.85],\n", + " child_frame_position=[0.0, 0.0, -0.45],\n", + " constraints=\"rigid\",\n", + ")\n", + "\n", + "# In this tail_to_nose frame, z=0.55 m places the payload approximately\n", + "# halfway inserted into the first-stage airframe.\n", + "payload_attachment = Attachment(\n", + " parent_frame_position=[0.0, 0.0, 0.55],\n", + " child_frame_position=[0.0, 0.0, -0.30],\n", + " constraints=\"rigid\",\n", + ")\n", + "\n", + "# Mission-lifecycle event triggers follow the same trigger(**kwargs) -> bool\n", + "# convention as any rocketpy.simulation.events.Event. In RocketPy state\n", + "# vectors, state[5] is vz (vertical velocity in m/s); vz <= 0 is used here\n", + "# as a descent/apogee crossing condition.\n", + "second_stage_sep = StageSeparationEvent(\n", + " name=\"Second Stage Separation\",\n", + " trigger=lambda **kwargs: kwargs[\"state\"][5] <= 0,\n", + ")\n", + "second_stage_ignition = IgnitionEvent(\n", + " name=\"Second Stage Ignition\",\n", + " trigger=lambda **kwargs: kwargs[\"state\"][5] <= 0,\n", + ")\n", + "payload_deploy_event = DeploymentEvent(\n", + " name=\"Payload Deployment\",\n", + " trigger=lambda **kwargs: kwargs[\"state\"][5] <= 0,\n", + ")\n", + "\n", + "first_stage = Stage(\n", + " name=\"first_stage\",\n", + " body=RocketAdapter(first_stage_rocket),\n", + " attachment=first_stage_attachment,\n", + ")\n", + "\n", + "second_stage = Stage(\n", + " name=\"second_stage\",\n", + " body=RocketAdapter(second_stage_rocket),\n", + " attachment=second_stage_attachment,\n", + " separation_event=second_stage_sep,\n", + " ignition_event=second_stage_ignition,\n", + ")\n", + "\n", + "payload = Deployable(\n", + " name=\"payload\",\n", + " body=RocketAdapter(payload_rocket),\n", + " attachment=payload_attachment,\n", + " deployment_event=payload_deploy_event,\n", + ")\n", + "\n", + "mission.add_stage(first_stage)\n", + "mission.add_stage(second_stage)\n", + "mission.add_deployable(payload)" + ] + }, + { + "cell_type": "markdown", + "id": "938c804e27f84196a10c8828c723f798", + "metadata": {}, + "source": [ + "## 6) Configure mission flight inputs\n", + "\n", + "These values are stored in `Mission` and consumed by `MissionExecutor`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "504fb2a444614c0babb325280ed9130a", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.351804Z", + "iopub.status.busy": "2026-07-12T18:58:28.351691Z", + "iopub.status.idle": "2026-07-12T18:58:28.354012Z", + "shell.execute_reply": "2026-07-12T18:58:28.353565Z" + } + }, + "outputs": [], + "source": [ + "mission.set_flight_inputs(\n", + " \"first_stage\",\n", + " inclination=85,\n", + " heading=20,\n", + " terminate_on_apogee=True,\n", + " max_time=240,\n", + ")\n", + "\n", + "mission.set_flight_inputs(\n", + " \"second_stage\",\n", + " terminate_on_apogee=False,\n", + " max_time=240,\n", + ")\n", + "\n", + "mission.set_flight_inputs(\n", + " \"payload\",\n", + " terminate_on_apogee=False,\n", + " max_time=700,\n", + ")" + ] + }, + { + "cell_type": "markdown", + "id": "59bbdb311c014d738909a11f9e486628", + "metadata": {}, + "source": [ + "## 7) Inspect mission-level metadata\n" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b43b363d81ae4b689946ece5c682cd59", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.355712Z", + "iopub.status.busy": "2026-07-12T18:58:28.355572Z", + "iopub.status.idle": "2026-07-12T18:58:28.358141Z", + "shell.execute_reply": "2026-07-12T18:58:28.357607Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Mission Name: Calisto Two-Stage + Deployable Mission\n", + "Number of stages: 2\n", + "Connection map:\n", + " - first_stage: {'parent_frame_position': [0.0, 0.0, 0.0], 'child_frame_position': [0.0, 0.0, 0.0], 'orientation': None, 'constraints': 'rigid'}\n", + " - second_stage: {'parent_frame_position': [0.0, 0.0, 0.85], 'child_frame_position': [0.0, 0.0, -0.45], 'orientation': None, 'constraints': 'rigid'}\n", + " - payload: {'parent_frame_position': [0.0, 0.0, 0.55], 'child_frame_position': [0.0, 0.0, -0.3], 'orientation': None, 'constraints': 'rigid'}\n" + ] + } + ], + "source": [ + "print(\"Mission Name:\", mission.name)\n", + "print(\"Number of stages:\", mission.number_of_stages)\n", + "print(\"Connection map:\")\n", + "for item_name, metadata in mission.connection_map().items():\n", + " print(f\" - {item_name}: {metadata}\")" + ] + }, + { + "cell_type": "markdown", + "id": "8a65eabff63a45729fe45fb5ade58bdc", + "metadata": {}, + "source": [ + "## 8) Execute the full mission (including payload)\n", + "\n", + "The same mission object configured above is executed directly by `MissionExecutor`.\n", + "This run includes both powered stages and the deployable payload, chaining each\n", + "item's flight from the previous one's final state.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "c3933fab20d04ec698c2621248eb3be0", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.359712Z", + "iopub.status.busy": "2026-07-12T18:58:28.359592Z", + "iopub.status.idle": "2026-07-12T18:58:28.667716Z", + "shell.execute_reply": "2026-07-12T18:58:28.666945Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "first_stage: apogee = 4443.71 m, impact velocity = 0.00 m/s\n", + "second_stage: apogee = 0.00 m, impact velocity = 0.00 m/s\n", + "payload: apogee = 0.00 m, impact velocity = -4.61 m/s\n" + ] + } + ], + "source": [ + "executor = MissionExecutor(\n", + " mission=mission,\n", + " environment=env,\n", + " rail_length=5.2,\n", + " default_flight_inputs={\"time_overshoot\": True},\n", + ")\n", + "\n", + "results = executor.execute()\n", + "flights_by_name = {result.item_name: result.flight for result in results}\n", + "\n", + "for result in results:\n", + " print(\n", + " f\"{result.item_name}: apogee = {result.flight.apogee:.2f} m, \"\n", + " f\"impact velocity = {result.flight.impact_velocity:.2f} m/s\"\n", + " )" + ] + }, + { + "cell_type": "markdown", + "id": "4dd4641cc4064e0191573fe9c69df29b", + "metadata": {}, + "source": [ + "## 9) Compare all mission trajectories\n" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "8309879909854d7188b41380fd92a7c3", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.669442Z", + "iopub.status.busy": "2026-07-12T18:58:28.669290Z", + "iopub.status.idle": "2026-07-12T18:58:28.950327Z", + "shell.execute_reply": "2026-07-12T18:58:28.949563Z" + } + }, + "outputs": [ + { + "data": { + "image/png": "iVBORw0KGgoAAAANSUhEUgAAArQAAAK6CAYAAADINM+qAAAAOnRFWHRTb2Z0d2FyZQBNYXRwbG90bGliIHZlcnNpb24zLjEwLjgsIGh0dHBzOi8vbWF0cGxvdGxpYi5vcmcvwVt1zgAAAAlwSFlzAAAPYQAAD2EBqD+naQABAABJREFUeJzsnQeYVEXWhk/P9AQySM45BxEFVAyoGBCza9pVMf9m0VUxoCJmd815V9ew6rq6a1gxISgqQUBEcs6ZIcfJ/T9fjaepabp7Otzbfav6vM9z6Z6enqbr3rpVX506wRcIBAIkCIIgCIIgCIaSle4vIAiCIAiCIAjJIIJWEARBEARBMBoRtIIgCIIgCILRiKAVBEEQBEEQjEYErSAIgiAIgmA0/nR/AUEQBEEQBFsoLy+n4uLidH8N68jNzaWsrMh2WBG0giAIgiAIDgAhu3z5ciVqBWeBmG3btq0StuHwSR5aQRAEQRCE5EBa/1WrVlFJSQk1a9YsqjVRiA8sENatW0c5OTnUqlUr8vl8B7xHLLSCIAiCIAhJUlpaSnv37lVitnr16nI+HaZhw4ZK1OI8Q9iGIssHQRAEQRCEJCkrK1OPkbbEheTg88rnORQRtIIgCIIgCA4RbjtccP+8iqAVBEEQBEEQDmDgwIE0bNiw4M9t2rShZ599lmJlxYoVSoj+9ttv5DYiaAVBEARBEDKUyy67TInO0GPJkiUHvHfatGl0zTXXOPr/v/XWW1S3bt2kP0eCwgRBEARBEDKYU045hd58880DgrBCCfeaVxALrSAIgiAIQgaTl5dHTZo0qXRkZ2cf8L5Ql4MFCxbQUUcdRfn5+dStWzcaO3assu5++umnlf5u2bJldNxxx6nsDwcffDBNnjxZvT5+/Hi6/PLLaceOHUHL8MiRIxNqgwhaQRAEQRAEIS6QbeCss85SInXKlCn0t7/9je69996w78Xrt99+u/Kl7dSpE1100UUq/daRRx6pBHLt2rVp/fr16sD7EkFcDgRBEARBEDKY0aNHU82aNYM/Dx48mD766KOof/Ptt9/S0qVLlZUVFl3wyCOP0IknnnjAeyFShwwZop4/+OCD1L17d+Wj26VLF6pTp46yzPJnJIoIWkEQBEEQBBc4/YUJVLCrKOXntmGtPPr8pqNifj/cAV555ZXgzzVq1KjybxYuXEgtW7asJET79esX9r29evUKPm/atKl63LRpkxK0TiGCVhAEQRAEwQUgZjfsLPT8ua1RowZ16NDBtc/XK3txPlmUs3USEbSCIAiCIAguWUpt/X87d+5Mq1evpo0bN1Ljxo2Dab0SqQAWqfpXPIigFQRBEARBcIF4tv1N48QTT6T27dvT0KFD6cknn6Rdu3bRiBEj4q6WhswJu3fvpnHjxqkMCAgywxEvkuVAEARBEARBiAuk9UJ6LojRvn370lVXXRXMcoA0XrGCTAfXXnstXXDBBSrPLcRxIvgCgUAgob8UBEEQBEEQFIWFhbR8+XJq27ZtXILOJiZOnKjy0iKDAay3qTy/4nIgCIIgCIIgxM0nn3yi0n117NhRidhbbrmFBgwY4LiYjQURtIIgCIIgCELcwG92+PDhtGrVKmrQoAENGjSInnrqKUoH4nIgCIIgCIKQJOJykN7zK0FhgiAIgiAIgtGIoBUEQRAEQRCMRgStIAiCIAiCYDQiaAVBEARBEASjEUErCIIgCIIgGI0IWkEQBEEQBMFoRNAKgiAIgiAIBzBw4EAaNmxY8Oc2bdrQs88+S7GyYsUK8vl89Ntvv5HbiKAVBEEQBEHIUC677DIlOkMPVP4KZdq0aXTNNdc4+v+/9dZbVLdu3aQ/RyqFCYIgCIIgZDCnnHIKvfnmm5Vea9iw4QHvC/eaVxALrSAIgiAIQgaTl5dHTZo0qXRkZ2cf8L5Ql4MFCxbQUUcdpSp3devWjcaOHausu59++mmlv1u2bBkdd9xxVL16dTr44INp8uTJ6vXx48fT5ZdfTjt27AhahkeOHJlQG0TQCoIgCIIgCHFRVlZGZ511lhKpU6ZMob/97W907733hn0vXr/99tuVL22nTp3ooosuotLSUjryyCOVQK5duzatX79eHXhfIoigFYwCq8Nwvj76oa8e2SEdfxfps/AeJ/2Q4A+UTr+nqg6n2qsT7TxnArjmaD+uiZfBpHPddddR9+7dlc9abm4uNWrUiI499lh6+OGHadWqVen+isbB95UgmMzo0aOpZs2aweO8886r8m++/fZbWrp0Kb3zzjvK6gpL7SOPPBL2vRCpQ4YMUWL2wQcfpJUrVyofXYxBderUUfcQW4bx/yeC+NAKRjJgwADq0KFD2N9h28M0MYQtl6FDh8YlhjF4hOM///kP7dmzJ+I5SnSw8DosKgKBQLq/iufYu3cvXXXVVfSvf/1L/YxJA/0HE8nmzZtp6tSp9OOPP9JDDz1E//73v5XVRRAEB3jtWKLdm1J/Kms2Ivq/H2J+O9wBXnnlleDPNWrUqPJvFi5cSC1btlTjCdOvX7+w7+3Vq1fwedOmTdXjpk2bqEuXLuQUImgFI8HknKw1bNy4cVRSUkLNmzcnU88BjlDgkwRB68Q5ihWcw/nz51NOTg5lImeffTYdfvjhSiB6DfTxk08+mSZMmKAmkldffZXOOOOMSu/B1t8nn3xC99xzjysWfJtBvxeEiEDM7lrn+RNUo0aNiEYiJ9DnBjY+lJeXO/p/iKAVMpb27dun+ytYAwYrJ1fapgEh60UxC2B1hZiFi8HEiROpbdu2B7zH7/erLcbBgwfT4sWL0/I9TSWT+70Qo6XU0v+3c+fOtHr1atq4cSM1btw4mNYrXuB2AH/cpAkIgkG0bt0a+8mBN998M6b3L1++XL0ffxfps/CeUDZv3hy46aabAi1btgzk5uYGWrVqFbjlllsC27ZtCwwdOjTsd9BfX7ZsWeDiiy8ONG7cWP19u3btAvfee2+gsLAw7HcIdxx77LFxnx/9M0O/n34uSktLA0899VSgd+/egRo1aqjXmblz5wbuv//+wJFHHhlo1qxZICcnJ3DQQQcFTjjhhMC///3vuM8z2Lt3b+Cvf/1roH///oE6deoE8vLyAp06dQrccccd6lxHYuHChYHrrrtOvbdatWqBWrVqBbp27apemz17tnrPAw88EPEchru+X3/9dWDIkCGBhg0bqrY1bdo0cP755wemTZsW9jvgOuBzvv/++8CPP/4YOO200wINGjQI+Hy+4DnGI96DPhCOtWvXBm699dZAly5dVDtq1qwZOOywwwIvvPBCoKSk5ID3o588+eSTgT59+qj34nuiL+FvcM62bNkSiIWdO3cGateurb7b008/HUiUZM7Z5MmTA6eeeqrqQ2jLMccco84j89VXXwWOP/74QN26dVVfHDRoUGD69OlR+xjO2RNPPBHo1q1bID8/P1C/fv3AeeedF5g/f37Y7zNlyhR13vr27avOI9rQqFEjdS2//fbbsH+jX1Ocb9z/uI9xP+v3JvezUNatWxe4+eabAx07dlT9Hde9RYsWqq1/+ctfIn5PtAPnF98R5xvfccyYMWHfn8iYI7jHvn37AvPmzVOPJjF06NDAmWeeGfZ36Ovo+wzuv2eeeUY9xzzSuXPnwMknnxyYOXNmYMKECYHDDz9c9clPP/200n07Y8aM4GdgHuXxAUycOFH9PHbs2EBBQUFgz549CZ1fEbSCUaRC0GIiat++vfodJuFzzjkncNZZZwXq1aunbl48jyZocfNDRODzMeljgsZkht/hb3X+/Oc/BwYMGKB+h/8Tn8HHY4895ug54nMBcX7GGWeoSQ8i9aKLLgr06tUr+L4rr7xSvQ/iCwPVBRdcEDjiiCMCWVlZ6nUIs3jOM8Rcz549g+cT5+Pss88Ofs82bdoEVqxYccDfvffee0oI8Hc+99xz1d8dfPDBSkxCyIJPPvkkeO5ZgOgHBkhmxIgR6j34e5x3tB2iHq9lZ2cH3njjjYji7Prrr1fnACLqwgsvDJx00kmB999/v0pB+8MPP6i+w23Fucd55dfwOcXFxcH3l5WVqeuC36EfDR48WH1PnDc+Z/rkEI3PPvss2N5oC4doJHPObr/99oDf7w8ccsghqh/x3+G6YhJ78cUX1TnF4gn3ChYu+D2E7+LFiyP2MdyTEHw4J7gWEG/8d5MmTTrg++B84v9BP4S4hmjEYoH7zLPPPnvA3/A1hZBv27atul64dvjbP/3pT1EF7fr169VikPsuxALaf/TRR6t7AIu6UP72t78F7zGcL5xnnBf+/JEjRx7wN4mMOYJ7ZJqgBVhEYlzAfII54/PPP1f9DovgWAUtuPbaa9XCFK/z2B6KCFrBKlIhaCGa8PrAgQMDO3bsqHQTHnXUUcEJJpKgxQHLCFavDKyJbAkNnXCrsu45LWhxwFIE62c4xo8fH1i6dOkBry9YsED9Hf4elqRYznN5eXlQsEMow2LIwMoGQY/fHXfccZX+7pdfflGCBSLq+eefVyJPBwIY79GJZCnTLYH4PSx6oRav119/Xf0O/+ecOXPCijMcL730UtjPjnQNIWwwSKMdL7/8cqV2QGDCWoe/e/DBBysJYBY1+vliYBWNVZzed9996rMg+BIh2XOGdv/zn/+s9LvbbrtN/Q6LQwhQWGUY3DNYuOD3V111VcT+Cws5LEL632FHhftgqFXyyy+/VAvVUHAvQgiiDWvWrAl7TXFAEOtjQVX9DtcTr11zzTXqHtDB4kVvM5g1a5YS/jhf77zzzgHfHWIBnxd6DRIdcwR3MFXQOgmstOhzS5YsCTiNCFrBKqJt0Yfbpo9X0EIoYVKBpSTc9iUmCfw+mqA99NBDD5jEeAWK348aNSrtgjZ00oyV1157Tf09tm9jOc8siGCZC7e1DoHXo0cP9R52IQBsBYdIiZWqBC1bPSGowoGtXfz+6quvDivOID4jEekaDh8+XL1+4403hv07iCjeWuY+8+GHH6q/wXZ1snCfwzZgIiR7zmDNDAXb93ytQvsRgLsBfgeraKT+G86iChHbvHlz9XtY92Pl7rvvDrtY4WuK6xNugRet38Gaj9c+/vjjmL4D74rA8hwO9B/8/sQTT3RkzBHcIRMF7ccff6wWWrg/4b6DHSwYMdJxfiUoTDCSSCmpkg3Q+Omnn1Tap0MPPTTsZ/Xo0UOlH5k5c2bEzzjttNPC5qXs2rWrely7di2lm3PPPTfq73fv3k1fffUVzZgxQ6V1Ki4uVq8j6TWna4mFL774Ivj/IfAolKysLDrmmGNozpw5NGnSJHV+ERyA/IbAqZrhiOJHQBSIlPnhyiuvVLkYv//++7C//8Mf/hD3/8vtv+CCCyJmh+jYsSPNmzdPBWMhR2OfPn1UhZ5//OMf6udzzjknmOYmlThxzk499dQDXjvooIOofv36tGXLlrC/x/kA69ZFjgxHirtwlY5wnp9++mmV6eOPf/xjpd/j/8P1QF/btm2byv4AOAguUp8+5JBDqF27dhQPSF308ssv01133aXGk5NOOilqujx836rO84svvqjGJ9wfoRWcTBhzBDvZtWsXDR8+XOWwbtCgAQ0aNIieeuqptHwXEbSCkbiVkmrNmjXqMVqBAPwumqBt1apV2NdRCQUUFhZSOkEifVR2icTnn3+u8uJCAERi586dMf1fKHcI7rvvPnVEo6CgQD3i/0XaMY6idQJ8Jp/3cFH+etaLSJN/IkUjuP1HH310le9F+yFg8T2eeeYZuuOOO+jGG29UR+vWremII45QwgXZCBAVHAtcdx35HtNxziLdCxB3+Pxwv69Vq5Z6LCoqCvu3yNaAIxz8Pfk+Zv7+97/TrbfeGuxX8fTpRK77JZdcohZl7733nlrMQYAiPzZy/2JhdPzxx1d6P5+/qs4zrgfOG+5hk8YcwV4uvfRSdXgBEbSCEIZolX+qqgoEq6OXqVatWsTfYWKFlWvfvn1055130p/+9Cc1oUOAoF1jxoxROU1jLV7AeQYxkVeVJg3Vq0w9b1W1HyKmqkTlsFoyN910E51//vn0v//9T6XcwvHBBx+o44EHHlCWulistthpAMuXL1dCSP8/UkFV94Jb94reP6dPn07/93//p0TlE088QaeffroSgFjU4V5GuU78PlKfTuS6o13vvvuuyusLqzAs3TiQuB4HvgPy/oZaWhPF62OOIKQCEbSCoMFFFqIll7c58TyssxCzKBSAyT+UeHOUoooMOPPMM2Ouzw3RBbGB6lbYBoYbQrLgM7ElDasfrKZ61ZpQa6qThTbQfpwzbMkddthhcf0t8jpeffXV6gALFiygK664giZPnqy2st9+++2Yqv/A4oltQZSnhJXS6+esKrZv366OcFZavjdbtGgRfO2jjz5SYhWLBCzSQnEz7y6ssjhgbcd3+O6775QrBO4zXA/shPD5QwlRnM9w/Z3Pc35+vnLZEAThQGRZJwga2BqG1QZWnUWLFh1wbuDrGM3dIBF4+xg+i+lm69at6hFb3KFgQn7//ffj+jwk6tdFRSzAanXiiScGt4rjrUQT7jzCf5dLBUcqLwyfVRaBTsHt//DDD5P+LPh0QxiD3377Laa/wZbzzTffrJ6PGjVKWWqr8p2G33Q6z1ks/POf/zzgNfh5o2wvGDhwYEx9Glvx//3vfykVYFw54YQTgr69+jXk71vVecb4FM4XXRAEEbSCUAlsr2M7EFvF1113nbJsMTt27FCvxSrMYoWtSRDL6YaDSP7zn/8EA8AAAlHuv/9+FbgVD7DM9u3bl6ZOnaqsUewnq4MAHZRj1YXovffeqyZuBMIguCb0nK9cuVItOsKdx7lz54b9Ln/+85/VI7Z8UfZYB0IC2/sQxbfccgs5BSxzsCQiUAmBEhxcpwORie1pBla8L7/8Mhi0xOAcIAArkjiLBK7bkUceqayaEKiwDoaC64stcLgo/PDDD2k9Z7FWP0NwF4P7FWIfvrOwiutBj9ynYdHW72eI2euvv75KkZ8IsL6G9k+A/58DwPRriPOH/v7pp59W6gsAbj6vvfaaeh7rLocgZCKy1BOEEDB5z5o1SwkLBGkce+yxSkxgosc27BlnnKEm8lgDc6ri8MMPp2bNminLGCLce/bsqUQCAqIgiFIJxDxEDSZjBCih7fD9nDJlioo6h2gI54oQzbcPk/SQIUOUoIBQPvjgg5UPI8QdtlJnz56tBBWC/Nj6BBH8xhtvqOC/G264gZ588kn1GoQL/gZWcgg19hEFEDF//etfVZQtgm44uAjfF9cN1tIRI0bQww8/rCzAyJSB74Gt/F9//VVZhiGsnfTlhcj+7LPP1HeDGEE7sKUM/1cskObPn6+2mvv3708XX3yx+hv0PbgGwLqK/oC+ATcQfEcIeZTYhbU1VtBPv/nmGxUpD0sx+i/+f5w7/B/wrUW5Slgy4WKgByal45xVBf5/fHecG1g2cW3x/XEe0Vexi4CteQYLqeeee07dX2gbrJz43vBDxnmFmMTvneTjjz9WmRhw7Xr37k316tVTCzf40eK6ow+wKwnAPf/SSy+pBTMCyhAUCIs8rjcWkRh/Ro4cqbIlCIIQAVeShQmC4aVvN23aFLjhhhtUIQEkNUcJXPyMHJqcDP+bb76p9DeRSuLGkm8WOVhRhQj5SLlakJulb6Oxa9euwD333KMS3yOhPkqEIi8sChmgsku0fL+ohBUO5Ah99dVXVQEFFBpAEnl8LvLT4ryGnku9DC9ydCInKapLocIS8hwiLyd+p4PchHfeeWegQ4cOwUT04a4vcuOiWhR/jyZNmqh8qaHFIsKVcY1EVbmEN27cqIocoDoVyvfi+6FvoRIUquIgsT6DhOSoCoUcsKgyhWuAKlWo5nbXXXcFVq9eHUgUlKFFsn+UD0ZBAbQfRQpQjvaRRx45oLiAW+cs2r0XKbdraOlbfF9UJkK/QPUtFGQI7RMMqsUhNyyq8eH9qOKFMrGoRhbp2sWaHzrcd0Vp32HDhgX69eunzhWuNx5RcQ/ljnfv3h32s37++efAH/7wB/VenGecb1Qqi6X0bTicznEtRCcT89B66fz68E8ksSsIQmWwbYuclLCybNy4UeXdEyrcJWClQwBMpC1/QUgGBHzBwoqtepsDMwVzgRsLXFjQT/VdApMZOHCg2mV49tlng255w4YNU0c89y12SPA5bp5fCQoThDDA5zMU+H9iGxFbh8gHKmJ2Pz///LOjeWMFQRCE1HDZZZepoMXQY8mSJQe8F+49ThW80f3xI+WWjgfxoRWEMMCnEf6PCCiBjx7ys2KFiShw+PAhWEkgev3115WPLAJXAKchEgRBEMzhlFNOoTfffDNsYZaqXvMKYqEVhDAgEAbR0gg+QlofBMDA1QCJ0vGc86tmOkj4j4pICCCDuEVQmSAIgmAWeXl51KRJk0pHuMIfcDlg9wOAAFFkT4ELAFzOxo4dq6y7MHToIJgX6f2QYxyBwcinDZD1A4YQuPGxZRgBkIkgFlpBiJAWCIdQ9VZRpNyZguAkmEgl5EMQvENZWRmdddZZatcSmXCQlo5T/YWCVIzIQtOxY0f1/KKLLlIuDUgpCIGMrDUopANQmTIRRNAKgiAIgiC4wAWjL6DN+zan/Nw2qNaA/n1aRaGRWECOa11IImUfCuJEA7tzSJcHKyssuuCRRx4JFsbRQdpCpG8EDz74oAoihqBFejqkIoRllj8jUUTQCoIgCIIguADE7Ka9mzx/bo877jiVg51BTueqgEUV7ne6EO3Xr1/Y9+qls5EHG2zatEkJWqcQQSsIgiAIguCSpdSE/7dGjRrUoUMH174PlyYHsMYCFMpxEhG0giAIgiAILhDPtr9pdO7cmVavXq1ysjdu3DiY1iteUM0Q/rjJIlkOBEEQBEEQhLiAr2z79u1VfnaU7EZpZ2QI0q2wsQZ8IiXmuHHjaPPmzbR3715KBBG0giAIgiAIQlwgrRfSc0GM9u3bl6666iqVwQDEUykNmQ6uvfZauuCCC1Se2yeffJISQUrfCoIgCIIgJImNpW/jBVZa5KVFBgNYb1N5fsWHVhAEQRAEQYibTz75RKX7Qn5ZiNhbbrmFBgwY4LiYjQURtIIgCIIgCELcoJjC8OHDadWqVdSgQQMaNGgQPfXUU5QOxOVAEARBEAQhScTlIL3nV4LCBEEQBEEQBKMRQSsIgiAIgiAYjQhaQRAEQRAEhwgEAnIu03BeRdAKgiAIgiA4kJcVFBcXy7l0AT6vfJ5DkSwHgiAIgiAISeL3+6l69epUUFBAOTk5lJUlNkOnKC8vV+cV5xfnORyS5UAQBEEQBMEhKyIi8SHABGfBAgEZDnJzc8P+XgStIAiCIAiCQ0DMituB80DIRrN6i6AVBEEQBEEQjEYcPARBEARBEASjEUErCIIgCIIgGI0IWkEQBEEQBMFoRNAKgiAIgiAIRiOCVhAEQRAEQTAaEbSCIAiCIAiC0YigFQRBEARBEIxGBK0gCIIgCIJgNCJoBUEQBEEQBKMRQSsIgiAIgiAYjQhaQRAEQRAEwWhE0AqCIAiCIAhGI4JWEARBEARBMBoRtIIgCIIgCILRiKAVBEEQBEEQjEYErSAIgiAIgmA0ImgFQRAEQRAEoxFBKwiCIAiCIBiNCFpBEARBEATBaETQCoIgCIIgCEYjglYQBEEQBEEwGhG0giAIgiAIgtGIoBUEQRAEQRCMRgStIAiCIAiCYDQiaAVBEARBEASjEUErCIIgCIIgGI0IWkEQBEEQBMFoRNAKgiAIgiAIRiOCVhAEQRAEQTAaEbSCIAiCIAiC0YigFQRBEARBEIxGBK0gCIIgCIJgNCJoBUEQBEEQBKMRQSsIgiAIgiAYjQhaQRAEQRAEwWhE0AqCIAiCIAhGI4JWEARBEARBMBoRtIIgCIIgCILRiKAVBEEQBEEQjEYErSAIgiAIgmA0ImgFQRAEQRAEoxFBKwiCIAiCIBiNCFpBEARBEATBaETQCoIgCIIgCEYjglYQBEEQBEEwGhG0giAIgiAIgtGIoBUEQRAEQRCMRgStIAiCIAiCYDQiaAVBEARBEASjEUErCIIgCIIgGI0IWkEQBEEQBMFoRNAKgiAIgiAIRiOCVhAEQRAEQTAaEbSCIAiCIAiC0YigFQRBEARBEIxGBK0gCIIgCIJgNCJoBUEQBEEQBKMRQSsIgiAIgiAYjQhaQRAEQRAEwWhE0AqCIAiCIAhGI4JWEARBEARBMBoRtIIgCIIgCILRiKAVBEEQBEEQjEYErSAIgiAIgmA0ImgFQRAEQRAEoxFBKwiCIAiCIBiNCFpBEARBEATBaETQCoIgCIIgCEYjglYQBEEQBEEwGhG0giAIgiAIgtGIoBUEQRAEQRCMRgStIAiCIAiCYDQiaAVBEARBEASjEUErCIIgCIIgGI0IWkEQBEEQBMFoRNAKgiCkgEAgoA5BEATBefwufKYgCEJGA+FaXl6ujrKysuCRk5ND2dnZlJWVRT6fTz0CPBcEQRASRwStIAiCw+K1tLQ0+DqLVTziNfyOwWssbEXkCoIgJI4vIHtggiAIjotX/WByc3MrfRY/6sOwiFxBEIT4EUErCILggngNhy5oI/2f/CgiVxAEIXZE0AqCkPG4IV7xd3v27KFdu3apY+fOncp/tk6dOlS7dm31WKNGjSo/R0SuIAhC1YigFQQho3BLvO7du1eJVhawu3fvVn9Xq1YtJWDxCD9ZvGfHjh3qPQC/Y4GLRxG5giAI8SOCVhAEa3FTvLLVNVS88gFxWq1atYg+tGzBZYGLRxx4v4hcQRCE+BBBKwhCRojXcMI1lu1+3W2AD/xdzZo1g5ZXHNWrV0/ah5ZFLgtctuSyyIUVly25sfx/4q4gCEKmIIJWEATjcEu8hroNsFtAqOU1FjGZiKANB9oDC3CoJRf+uLqrAh5DLcKR2smPEngmCIItiKAVBMFo8bpx40YqKCigXr16xS1e9YAtiEa8Hmp5jcWn1U1BG03khlpyQ4POwrk9VCVyJ06cSJ07d6b69etLCjFBEIxBCisIgmCs5dXv9werbUHMRfrMffv2HWB5ZfEK0dqsWbOg2wB/npfBd2Q/Wwbnh8U5BO6yZcvUzzhHoT65oSI3tPgDF3oAUgxCEAQTEEErCIIx4jWcpVF/jcWrbnkNFa9NmjShTp06GSNeYwVtYR/bli1bVhK5bMldunSpsuyyyNV9cvPz8w8IkuPn4Sy5UvFMEAQvIS4HgiAY6/NaWFhIq1atos2bNyuBCvGGzw7nNuAF8eqUy0Ey4Pzollx2t8jJyVHnbPv27dSmTRtq0aIF5eXliU+uIAhGIIJWEARjxKtudWXxCtGFz23btm0wj6sXxKtXBW00kQuBu2jRIvU9YenGY6hPLiy5VSGBZ4IgpBoRtIIgJAxHyrNwdVq86q4D+GyIVb1QASyxCApbv3499enTx/NX0quCVueHH36gHj16UN26dYMZFXRLLhYQoT65InIFQUg34kMrCIIr4jWSz2voZxYVFR1gecXnsnht0KABtWvXTv0cKfBLcB6c63r16qmDwXXRfXI3bNig8uayyNWtuXhNR3xyBUFwExG0giBEFa8QqxAyTojX4uLiSsIVz/G58H+FEIJ4hesALK+xilenUmoJVZ9TXOdwIle35MJaziI31F1BRK4gCG4hglYQMpxw4pWfJypeQTjLKwQtW16R5xTBR/GI12htEJwh3nOJ/nDQQQepI1TksiV33bp1Ku8vXBNY3LLQDXXDEEuuIAiJIIJWEDJYvEJksIhwQrzqAlYXrxA7rVu3Vs/FbcB+wonckpKSSpbctWvXHiBy+TFZkYv+jUfO0sBBgmLNFwR7EUErCBlseZ09ezY1btyYmjZtGrN4DXUbwAFBC7cBXbzC8orPFMzDDeGHtGCwyuMIFblsyV2zZo3KroDCD6E+ufj7WEXuihUr1Od07949+B4WtjhE5AqCfchsIwgZIl4BT+YsXvWJPpJ4DXUb0MUrIuGRxB/P0yVexepmLuFELi+YWOiuXr06KHJDLbmRRC4/x26AFIMQhMxABK0gZIh4DYf+OlvLdNcBiFcICa+IV8F+4G6A4EAcoSIXAhcHimkgtRsHE+pCl/tmqAVXKp4Jgt3IrCQIGSJedSBeIVohCrDNiy1aPGfxCnGASlFwGwi1gnkRCQqz+1xGErnsqoDqZitXrgyKXL4Xtm7dqvpy6AJMRK4g2IcIWkHIEPGqW14x8SMYB5/L4hVC1gTxKggschs2bKgOBjsK6N/Lli1TfXzmzJnqNQQn6pZcEbmCYB8iaAXBIvHKie911wH4H0K8QrDiaNasWVC8YsIPjUY3DfGhlXPKIKsBBC4ss7gXEBQGQcuW3C1bttDy5cuDIld3VQjnSiOWXEEwBxG0gmC4eNUtrxCvXLUJEzSyF+AxUslVEYOCF1m9dS9NXraNFhfspqPW/J3qlm2jFQcNoDPPvyLmz+C+jfuhUaNG6mBgvWWfXIhcWHThwgAXm1BLbmiauVhFrv4+ya4gCO4jglYQDBSvOJDDE5M1W16bNGkSVbxG+86C4AUmLd1CL/2wnH5ZuV39nEfFNCr/HfW837bPqXhTfwo0qkjFlUyfxo4FDha5XIKZRe7mzZtp6dKlQZEbaskVkSsI3kMErSB4ULzu3r27kusAxCuEKltekTs2XAL6eLHFQiui3OxzuX1vCd3/+Xz6Zt6m4Gu9fUvoLzmvBX8uJx9RVo4rfRvvjSRy2V2hoKCAlixZou7PUEuuiFxBSD8iaAUhAfGKR0xseM6/S0S84nMgXnWf1z179iihytkGIF7xHNZYNxAxKKRzoTNr7Q666YNZtGFnkfq5GhXSg7U+pT+UjKYsqlgYBnxZtHLAk9SsQaeU9Wld5OIe5M+FuwKL3I0bN9LixYuDIjfUkhua31ncFQTBPUTQCkKc4hVpriA6u3TpEvSPi0e86pZXfA6Cs9jKA+uQm+LVRgutDW3IVCYu3UI3fjCL9haXqZ9Pzp9PT1X7B9Xctzb4nvImB1PpaS9Qs4Zd0t4v8JlIbYcDLj4AYwJ819ldQRe5vCjVLbnxitwpU6aoLCT8/4lPriCERwStkNHEa3nl4A68HupHF0m86pZXWG55YkM0NotXEWWCV0iVxf7n5Vvp/977jUrKAlSbdtMzdT+iEwq/Jdr3+/fIzqOyo++gsv7XE2X5425Dqu4p/D/IfYsjksjdsGEDLVy4UI0LuOd1Sy4su9FELv6GK/rxZ0vgmSAciAhaIWNIRLyGmxTxmj7p4+/DuQ1AvHLAVtu2bT0pXkPbIgipYP76XXT9v2ZSSVk5Dc6aSo/nv0N1CrcFf1/e8ggqHfw0Beq3T/j/SOd9Fk3ksrvC+vXrlcjF+BFqydVFLotzcVcQhOiIoBWsxCnxGgr+Hj50CNJasGCBmpggXmGt5UmpTZs26jl877wkXiNhg6C1oQ2ZwtY9xXTt+79RjaLN9HTOm3Ry9i+I+FIE8mpR6XH3U3nvS4h8la2WpvcHXeQinR5/T4wlLHLXrl1L8+fPV6+zJReZFjDmYOwRn1xBiIwIWsF43BSvEKu65RWWWPwtBCwssKaJ11BM/M6Cuf2ivDxAd/x3Dh2752u6N+89qu3bG/xdWYeTqfSUJ4lqVYi9TOjb+I4o8IADBU8AxisedyB0Uelv0aJF6uDcuGzJxd9VJXJ1cS/uCoLNiKAVjMJt8aoXKYB4ZcsrjlatWqlHVCFCMvYOHTqQDXjRmmWbcBEq+ODbCXTdqvtpQM7c4CkJVG9ApSc9RuVdzsDFpEzv0+jPcDnAAZGLsaZjx45KvLLIRWAqnoNQn1y8T78nwj0XkSvYiAhaISPFK7b5wlle2W2AxSuimUM/c9u2bUZPmIIQDVf6dnkZFXz3PF04/Smqll0cfLmsx/lUesIoourOl162ZaGD64GxTRe5/DoW4eyusHr1apo7d65qdzhLrohcwXZE0AoZIV71VFm6eMWBlDgY9MOJV9sDqWya9AVv9gtfwXzK/mIYtVg/g1AbAezIbULVzn6WAu2OJzewqT9EytigW3KbN29+gJsUhO7KlSvVmCciV8gERNAK1ohXtliEug3wwA/RCvEKEYvAjEQnbZsELbCpLYKHKCum7EnPUvak58hXXqJeKg/46H95Q+iE656jQPVarv73tizW4mkLxkpeqIeKXLbkhopcWHHZkhs6Loq7gmASImgFY8VrqNsADqAP6OEGaWE/cl4EN/CtnU7+L2+lrM0Lgq8tKW9Gd5VeQ3cNvZhyXRazqcxD6zbJtkUXueFSDULorlixQj1HzIDuqoDH0J0rEbmCVxFBK7guXjdt2qQGSgyOyYjXUMsrXmfLK/zKMGCH+oq5gVhovYUtwsUKi33JXsr+8XHKnvY38gUqcnGVUTa9VHo6vVR6Fp3fvz31bF7buS+bAbixg4Kxl/1ssWuli1y25C5fvlyNtzx260I3GZFbUFCgPrNBgwbBQjU8H4R+liDEgwhawXXLK6J0kdYKA1gsn8sVdnTLK4tXiFbkcGTLa2jKmlRgk6CVyUNwrC+tnEg5X95Kvu0rgq/tPqgHnbf+TzQ/0JrqVsuhGwe2S8kJt+X+TKW1WRe5DMZzNiJA6C5btkz9zBUPExG5ELSYD+rXr39AxbNQY4eIXCEeRNAKrrsNcKnYSOJVt7yGildU2eGUNekQr5kgAm2Y/G1og5eIq48X7SL/9w9S9ox3gi+hbG3p0XfQpbP70fzAbvXazce1o7rVcyhV2HSfpqstGHPZx7Zly5aVRC5bcpcuXaosuyxydWtuaH5uNgawZRaEVl3kn8OJXH1usen6Cs4gglZw3ecVv8ffh7O84vO9LF7DIRZaQagga8lY8n99O/l2rQuekvIW/an01Gdp3Oba9Ovameq1Dg1r0AWHVQQppQLxoU2NyGUwjuuWXF3k6kFneMRcEI+7gohcIVZE0GYwbgZsoVQjD3CbN29W1W6QDJzFa6NGjVRhAq+L10ywCNrUFiFF7NtG/rEjKHvOR8GXArk1qHTgCCrvczkFyEcvfDQ1+LtbB7Unf7Z597kXMOH+hE9s3bp11REqctmSu3HjRiVyMd6zMYOFLiy5Ok6JXLHiZhYiaDOEVIhX3XUAnw+xyimycnJyqFu3bkaK11BsGiRtaIsNbTBJQGUt+Jz8Y+4i356C4GvlbQdSyeCniOpUbEuPm7+J5q2vyDrSvWktOqFzQ0olJohA263NkUTuL7/8ouYD7NixyM3LyzvAJ1dErhAvImgtxE3xWlRUVMnfFQc+m8UrAr/atWunfsaABpYsWaK+gw1i1jaXA0GImT0FSshmL/g8+FIgv46q9FXe88Jg2dry8gC9MH5Z8D03HtcuLYLMRBFok6ANB+YEiFkEhLVu3Vq9hvlDt+Ru2LBB5c1lkau7K+A1HbHkCjoiaDNEvIZzqo9VvOqWV7gOQKxigIF4bdu2rXIjYPEaDlsGYx1bBC37N5uOLdfDK1S6ZxHEM/+zCqvsvq3Bl8s6nkKlJz9JVKtJpb/9ftFmWrChIhCsR7PadFynqrObOI1t/cGmMTRUoMPPtl69eupgMIdhvmGf3PXr1wdFbmgKMRG5AiOC1iDcEq8gnOW1uLg4aHk96KCDqE2bNlWKV5tFEyMWWiFj2LOJ/N8Mp+yFXwRfClSrT6UnPUrlXc8KWmV1/jFpZfD5DQPbpk2M2SICbbLQxtoeiFzMOThCRS5bctetW6fyk8M1gcUtC93c3NxKnyeW3MxABG0Gi1ddwIaKV2wH4Xm84jUTBKBNk4tt10ZIHtUfYJWd+zH5x9xNvsJtwd+VdTmDSk96jKhGeJ/YmWt20C8rt6vn7RpUp4EdU2+dtU0E2nZ/Jnptwolc7Bjqlty1a9ceIHL5UUSu/YigtVy8QqjqVlc8x2sI1NLFKyyvGDDcwEbRZFt7TMYW4eIV8kq2U80vrqWcZWOCrwWqN6DSkx6n8q5nRP1b3Tp7xZGtKStLrk2y2CTOAeY1p+Ip2B8XR6jIZUsususgAA2FH0J9cvH3OmLJNRsRtB4Sr3zzdenSJWHxGuo2AGssi1dEmyI5Np67JV4zQdDa1h4b2uLFNuhV7/jgMqK4F8Mlnk8ryir7Xzpu3l2UW7Yn+HJZ17OUiwFVj25tXb11L42Zt0k9b1Azl87oVdm3NtP7QyKEpqayAbevTTiRy4YdFrqrV68OitxQS26iInf69OnUvXt39ZmSQiw9iKD1kOUVPyOFSegNFQ5eheqWV4hX3EzpFK+ZIABtao9NE2W60bc/+cD9jnsQE2WLFi3Ufa4nnsc2KCeeZ5Gblvt190byf30HZS/+urJV9uQnqLzL6TF9xDtTVlP577fFxf1aUl5O8u5KyWBT37apLVwpLJXgPkMQs15+nUUu7kccq1atUikoYQAKteSG3pPhRO62bduC1nTJk5seRNB6yG0Ar4ULnsJEGWp5xY3H4hU3XfPmzdXzWMRwqrFpMLaxPbaI81SC+xSClLc1cejbmhxEiXtSn7wxsULYhga5bN++XVmNcF/D/UcXuPjZNQEAcTHnI/KPvZd8hTuCLxd3OoMCg58gqr7fyhWNvcVl9Mlv69XzPH8WXdg3dVXBbN6mt9VC64X2RBK5fE9DoK5cuTIockMtuaEiF+3CDoxe0EGKQaQWEbQe8nnlld3WrVsrWV5xQ2Fr0gTxartFk7GlPV6YWLzehnCuA7g3MXlxZDXKNofbrow3yAX3OluMkKpowYIF6nWeSB11Vdi1QZWtzV6i+crWaEjTGl1EHQffpibxWPlyzgbaVViqng/p2ZjqVa8cZS5k9jjjRUEbSeQ2bNhQHQx2PnnhCZG7YsUK9Rqnr2SBC0LbJT65qUUEbRoDtjihNE+QuGFgjcUkBsGKo1mzZkaJ10wQtLa1x6a2pMp1AI+wxjo9MUOo4mjcuLH6md2QWOQ64qoAq+zsf5N/3H2VrLJl3c+l0kGP0Pofp1KnONv1r2lrg88vOqzCAp1ObOnTYqFNP8hzG07ksiV3y5YttGxZRSGRqVOnqvuRhW44lz8Rue4hglaDBSoL11jEK/8cq3jVBSysPlwNhf1eMWEdeeSRZBM25qG1BVvakqiASdR1IJXXhxe3jrgq7FpP/q/+TNlLxwZfCtRoRKWn/IXKOw1O6DvOXruT5qzbGSxz27N5hbUq3djSt21ri5cttLGCebtRo0bq4Hty7Nix1KFDB1UAgkUuXBhwH+qWXByh6TBF5DpDxgraVIlXPpAbDzcBT05NmzZVj3puPNwItlgWbLZo2tYem9qSDteBVJOQq0KdOtRw7Riq9uND5CuqEJ+grMd5VDroYaJq+6s0xcsHv6wJPr/wsBaeECs2iCYgFlozYIMNBC5bZLnaJi8+CwoKlMGKRa7ukxsu57uI3PjJCEHrtniFhUe3vEK8Qqiy5RXbh+ESO9sulGxuly3tsWHSj9SGdLoOpJporgp7NyymmuNvoerbZgTfX5LfgHYeO4rye51VaUs03n69c18JfTF7g3peMy9b+c8KziGC1qzrpO+IYEzh+5ItuXgfFp+6yF2yZIkal0ItuSJy48daQYuO8/bbb9OgQYNU53BCvEIIY5LQsw3AqgqhypZXTCh4DK0vnanCD5guFmxvjw19Dm3Qk6l7zXUgLa4KsAIt+x/5f7yffEW7gr/b0WYILWp/OW3dWU6FY8dWclWIFNwSiS/mbKR9JRXWqTMPbko18rwxpdhmobUJW65NIgsP/B5jEg598ck7LBi3Nm7cSIsXLw6K3FBLbuj4lawl15/mtJ5OYk9LwnDbbbfRJ598Qr169UpYvOqWV4hXbEXy6glO4nieiHgNB38/225424S6Te0xsZ+Fug4g8hjbeL/99ptRrgOusmMN5Xx1G2UtHx98KVCzCZUOforyO5xIvapwVZgxYwbVq1evyqwKn/y2Lvj8vD7pTdVlI2KhNcvlIJHxVBe5GLdCxzjcm7rI5R0m3ZIbj8jl3WrOByyC1hBgOUUHqOqC6eKVBSzcBvB33HkgXtny6pYI4M9FZwv1pzEZmwQgY1t7vExVrgO4NyFojzrqKCMFuqNgkpr5LvnHPUC+4t3Bl8t6XUSlJ4wiyq8T1VUBY8+YMWPUxIoxMFpWhVXbi2jmmgp/3M6Na1LXprXIK9hoFLAF266NG8UicH6QNg9HJJGLBejChQuVfsFYWEez5IYLCOVzrusM27DaQgvrDCZDHVx8WFpD3QZYvOJo166d6+I1HOG2C2zANkGrXyfTB2avXZtEsg7gvevWrTP+WiTNjtWU8+WtlLXix+BLgVpNqXTw01Te/oS4PgpBqxC60bIqfLN+/87U4C711LXLBHeOVGKrhda2fpKKuSCSyMXCM1TklpeXH2DJjSZybcF6QYutsylTpqiLPWTIECVeYf3ki82ToxdqqougNYN09xNbcCrrQMZfDwiE394h/3cjyVe8J3heyg7+E5Ue/yBRfm3Hsyrs2buPHn5xKv4XyvIRNdq3isaNW+VOAYgEsGGxaVM7dGy0DKZrMYe+gQIPOJo2bVpJ5LJRYO3atTR//nz1um7JZQOeTVgjaLHlOGfOHJo+fTr98ssv6nH58uU0atQo6t69Ox1xxBGeEq9V+dDahNesgMlik4UWpOraZFLWgZSxfVWFVXblT8GXArWaUcmpT1Og3fEJf2xV53/m+r1UsKdi9+vYjg3onFMPdr4AhGDVuMnYMm56tU0+TeSiMBN/P96Zxv0JkYvnhx9+uBpvbcGakQVuAhhADz30UHXceeedNGLECBo5ciSdfvrpZAK2+rbYJmgZG9rk1iCc6oIFNlyLuAiUU9aMt8n/3YPkK9kbfLms9yVUetwDSVtlq+Lj39YHn5/Vu6nzBSAsEhjJYEs7bHc5wHjn5evk8/nUfYaDRS7cL227DnEJ2ldeeUUdqGUMYPm8//77afDgigozGKz+/Oc/0wcffKASCp988sn08ssvBwMOwKpVq+i6666j77//Xp3coUOH0mOPPVZp1T5+/HiVoWDu3LnUsmVLJUwvu+yyqN9t2rRp6v/RL9ATTzyhLpopiMuBGdh2nZJthy0FC4xh+8rfrbITgi8FajenksGwyh7n+n+/t7iMxs7fpJ7Xqean4zvvLwmadAGINLsqeBHbzoOIdG/g+z1tV8YKWqy8H3/8cerYsWMwz+uZZ56p/FQhbm+99Vb64osv6KOPPlKD0o033kjnnHMOTZw4Uf09xCX8WDG5TZo0SQ1ol156qZrkHn30UfUeuAngPddeey299957NG7cOLrqqquUfwgEciTYf0QH211wRTAJdDBbhJKtFlqbJphE2iKuA2m0yv76Jvm/f+hAq+zxI4nykveHi+U+Hb9oczD37MndGlOuP8uRAhBOuirYIppsaYfNQW42XqeMELShW/ePPPKIstj+/PPPSuy+8cYb9P7779Pxx1f4br355pvUtWtX9Xv4aiAdzLx581TNYwxovXv3poceeoiGDx+uXAMwiL366qvUtm1beuqpp9Rn4O8nTJhAzzzzTFRBGw4TBa1t4s/WNgFb2hStHal2HUiEjJhI4Cv7xS2UtarCOAACdVpWWGXbHpvSc/rVnIrKYODUHslVBvOaq4LXsE0o2Spove5ykCkk7EMLaysssXA0RsAVgrBguUFlLqZLly7UqlUrmjx5shK0eOzZs2clFwSIVLggwL3gkEMOUe/RP4PfM2zYsPgb5/cbKWjFh9bb2ORyoA/C4jrgQeBvOOt98o+9r3Je2UOGVvjK5tVM6dfZXVRKPyzeop7Xr5FLfVvXdfz/cMJVwYZ7E9jSDtsFrY1+wRkhaGfPnq0ELK+YUYmrW7duqkoPLKIYWHQgXjdsqFjR41EXs/x7/l209+iWoXgLK5iETYOxrW2yZTDGAhTWV6R4mTlzptFZB2zqX0F2bSD/V7dR9tKxwZcCtVtQyZBnKdDmmLScx+8WFlBRKbsbNCJ/dmom8XhdFWDIgB83qp2ZnlXB6/dePNgsaE1rU8DCMTPuO71z585KvGIQ+c9//qOCun744QfyIuJy4A1sFbQmtSmS6wD819EeL7gOCPvJmvcJ+b8ZTr7C7ZXzyqLalwO+sony5eyNwedDeibnbuCmq8KWLVtU8PKiRYuMdlUwUShloqAVlwNDBS1EYocOHdRzpMdCdoHnnnuOLrjgArUqhs+TbqVFDWKuaoHHqVORjJsq/Z5/x4/8mv4ethTF+11NcznAQCsuB2bgVUEbj+vA5s2b1RYusokIHmDvFvKPuYuy538WfClQo1FFta+OJ6Xsa4QTHDv2ldCEpRXuBo1r51Gfls67GzjlqgC/WwQqIxenyVkVRNCagakuBz4P9vlkSHovBuILKbogbmHtQVaCc889V/0OJdiQpgsuCgCPCCTbtGkTNWrUSL327bffqsEFbgv8ni+//LLS/4H38GckW/rW69hmzbSxTV6z0CaTdcCGAc2GNoCsxd8oFwPfnoLga2Vdz6LSkx4nqr7fnzRdjF1QQCVlFX3+lG6NKAslwjwKC8FUZVVwsx02IRZawU3iumPvvvtulXMWgV6w+CCjAXLGfvPNN2oAuPLKK1X+WKyQMYHedNNNSogiIAycdNJJSrhecskl9OSTTyp/WeSYveGGGygvr6IuONJ1vfjii6owwhVXXEHfffcdffjhhyodWLyYaKG1TfzpbbLJ2pCudriRdcC2/mYchTvJP+4+yp71r+BLgfx6VHryE1Te7SzyCl/O2b9zdmqPih01rxKpT5uWVcGmMdNmQWuqhTajBS0sq8gbi60b3OC9evVSYvbEE09Uv0dqLVxUWGj1wgoMtjxHjx6tshpA6GI7CD64KE/LIGUXxCty2sKVAYPO66+/HnfKLha0pllobc1DaxupWHhI1oH4zpWJ+Fb8SDlfDCPfzjXB18ran0ilpz5NVLOxZ84j3A1+XrZVPW9WJ58ObuFuJbJUjjteLwBh0/jJ7nQ2tcnUhYfP5zPuOzsqaJFnNhq4uV966SV1RKJ169YHuBSEMnDgQFWsIVlMdTmw0YfW1Js+lSIqHQULbLoeRlGyVxVIyJ6+f0wN5Nak0kEPU3mvi3Bh0vr1QvvFD4s3U2l5RX8/sWtD6/uNV1wVbBszgW3tAZizxUKbftLvJOQicGPAoGMStrocAJvalex18krBAhv7m9fxrZlG/tE3Uda2ZcHXylsfRSVDniOq483gvLHz9/v1nti1Iv7ByzgtBNPlqmCboLVV+Nl2nUzFakGLlbKJFlrbBIatgjZWxHXAXYyZSEqLKHvSk5T984vkC1TswgT81aj0uPuo/NAriHzenOgLS8ropyUV2Q3qVc+hQ1rWSfdX8gSpcFWwacy0WfjZ2i7TsFrQmuhDK4LWHCJNNulwHUgG2yZNL5K1aS7lf3ULZW+uEDigvNmhVHraixSo3568Qri+MGnZVtpbXKaeH9e5QcqKKZgoMNxwVfDCGOEUtgo/E/PQBiwc9613OTAty4GteWhtu4F44eEV14FEMW0QjoRn+1Z5KeVOfYlyJz9DvvKKqoWBrBwqO/pOKjv8BqIs7w/BprkbeIlkXRVsE4C2tYeRLAfewPujaQYGhXl2cs5wQau7DpSVldH8+fNV2dhwBQvQ90zB9OviVbK2LKH8r2+h7A0zg6+VN+pOpae/SIFG3cmEe7a0rFyVuwXVc7PpyHbpz4drunCKx1UB1l7MYUhx6eUCEDZcl0yz0NqICFqPYaOgZUxrVzTXAVynhg0bKgHrFdeBRDD1e3uaQDnl/PoPypvwGPlKiype8mVRcb8biY69kyg7l0xhxuodtG1vhVHg6A71KT8nO91fyUoiuSqsXLlSpcv0egGITBe0JlpofZmetss0THU5ME34xXrjeLld8boOTJw4Ub1WvXp1Mh0vX5dY8NKg7NuxivK/vo38a34OvlZerx3tG/wslTftQ7kGiVnw7fxNwecndGlIpmC6cGJXBYwxGJdQnMjLBSAy5bpkWrtMw2pBa6rLgW0+tMBLgtaJrANeak8yyCDsEIEA5cx+n/LGjyJfyZ7gy8V9rqKio4YT5VQjE9D7NJ6PW7hZPfdn+WhgpwZp/GbxYcO9GXqPer0ARCYLP8zZJlnKbcXqKyBZDrxDOgWgG1kHbByUTSadAsa3ewPlj7mD/Mu/D75WXrsFFZ7yNJW1PJJMBP17acEeWrNtn/r5sNZ1qU41c/zCbblHqxKA0bIqwIrrNVcFWwWtaS4Hgd9L0duG1YLWRJcDWyx/6WpXqrIO2HKdbGlHWggEyL/gU8r/bgT5CncEXy7ueREVHXs/UV4tMpkfFlfkngUmWWdtIl4BmK4CELFis6C1sV2mYbWgxXaxaYLWRh9at4RTugsW2HidTCQdE4lv7xbKG3cP5Sz6IvhaeY3GVHjSk1TW7gSygfGLKtwNwLGGCVpbBIYTY4yXXBVsuS6hSJYDb2C9oMXq1CTEhzYyXipYYMugLBba+MleMobyv72TsvbuF3wlXc6kwuMfJqpWj2wQULsKS+nXVdvV81YHVaO29c0KfrRlsemWAEyXq4KtgtY0lwNbsVrQ4mY0zUJrq8CIt11eL1hg03WypR2uU7ST8r9/gHLmfhR8qTy/HhUNeoxKO59GNoHqYKXlFf3i2I4NjBQhJn7ndLUjVa4KNgta09rlk7Rd5vnQwqpnUmfLRAttul0HEkWEIGXMtchePZnyvxpGWbvWBl8rbXciFZ70BAVq2Fc9S/efNc3dwCbSOXe54aqAuc1GS6at7TINqy20pvrQmpZqLF5B6yXXgUy30Hr1/HqG0iLKnfRXyp32Kvmo4noHcmtR4XEPUmn383ACyTZgmP1xcYU7RbWcLOrXui6ZhklGDJPakayrgtfa4xS2tss0rBa0krYr/bDrAATrihUr1IreS64DiWLL4GWDMHfrWmRtXkj5X95E2QXzgq+VtjicCgc/S4HaFduytoG+sGYP0ZY9FYvqw9sdRHmGVQczvT+b1JZ4XRUgatmg4eUCEPEiQWHewGpBa2phBa8PYpGI5jqA32GB0apVK8+5DiSKqddJiLF07U+Pka/s99K1WTlUdNSdVHLoNURZZgm8eJm7bf8CYWBHc90NbFp0mkQ0VwWI2z179tCUKVM8XQAiXiQozBtkhA+tSZiUtise14Fp06apbar69euTDZi88AjFpnYkOxn6dq2vKF276qfga2X1O1PhqS9QeaNulAnM25ZltP+sLf3Zpq1sdlUoKipS7enTp4+nC0Bk6nUyHe/3lAzzofWqUEo264BX25UotrRHBuH9+Bd+Tvlj76pcJAGla4++i8ifT5nAlj3FtGpPxcTcqXFNalrH3Hbb0LdtE0rcHq8XgIgXcTnwBhnhQ2vSoOCFLAduZB2wRQAypvSnWDD9uiR9LZCOa9wIypn/cfCl8ppNqPCUZ6is9dHJf0H+b4qKguOSV5m8vCL3rOnuBrZg+r0ZSrS52EsFIDLF5cBn0TyWES4HvPLz8iSSbuGXqqwDNg7OpmPbgBYv2Wt+pvwvb6mUjquk0+lUOOjRpIokYFEKX0GejHFgcgY1atRQEzGOevXqqZ+9ch10QXtUh/3CwsT70ivnNBlMMsa40Z50FYDI9OtkKlYLWhaxsIyIoE1vwQIbLbS2tMemdsQ8qURKx3XCw1Ta9Zy403HxopDFK57rliTsaPAki4kYx7p162j+/Plq54MFbjr9BnH+pqyoELTVc7PpkJbmpeuyqT8zNgmlZIWfV10VJA+tN8gIQWtSYBhuOKdcDrxUsMAmAWjTJGNLO1KZjovvK936CmssLEmYPBs1akQdO3YMa3nFmITf4wC413FPbtu2TU3Gq1atUn7/mLDZgovHVGypLtq0hzb/nq6rb+u6lOs3bwvVtr5tm+XPjfZ4wVXBtutkKlYLWhZpJgWGJSP8vFywwEZBa1N7MiYd14x/UN6PldNxFQ+4g4oP+7+I6bjKysqU6NQFLO4rnhzbtWunnrOLU7wLWN4qZSCWIW4hcpcvX67+bwhhFrc48P85bW2auGR/dbAB7e3IRmI6tgmlVLUn1a4KpgWFBSyduzLCQmujoE2X60AmpCOLFRvaY4Mwj2UiUem4vvkz+Vf+GJKO63kqb9S90nvhoqSLVwhKLI550mvdurWr9xXuYRxNmzZVP0M880S8ZcsWNRlDZPP3YaGbrFvVxGVbg8+P6mCuoLXNh9Ym0iXQ3XZVMDUozDasFrToxJiI0HFNIZzw85LrQCYLJ5vbYzP+haMpf+zwkHRcV1LRUXdReXYe7QmxvuqTGia/dEdRw2KE/M2cwxn9Di4ObMVduHCh+rl69epBcRtvsFlRSRlNW7FNPa+TG6B2DaqTqdh0X9pmofWSr6mTrgomXiefYd+XMl3QAlgtTLPQ4qaHJcaLrgPJYNNEY9J5zxRhfkA7kI7ru/soZ95/gy+V12hMmwY8SBtrdKMdcxYEg7d42xFWUdxbXk7mjmsGwY2DrU0Y40KDzSAcdAtutO3UX1fvoKLSCt/9rnXt6N82tMGmdgCvjzWJuipgx8QrQj1T+xbw7qjt0MXCAO5lQRvqOoCbBlueixYt8qTrQKLYJJwY29pjE9lrplD+V7dQ1s41wdc2NjiCpje9hPy761Od7MKowVumUVWwGbZTMa5wsBkLXbY0TdD8ZzvXlX7tFWzbyjbNkhmrqwLut19//bXS4tGLBSBsx2pB6zULbSyuA5hc165dS0cccQTZhG2C1qb22NIOFby1fSvlT36KGix8L5iOqzS7Oq05eBiVd/8D9XfA19QEogWb4VixYgXNnj1bnQtMwOPn7w6+r7OZ2bqCiA+tt68N5juTCeeq8PXXXyvDE1wWvFwAwnasF7TwKU1X2q5Esg5g1QdBaxs2CUCb2mPy4MrBW1u3VgQzzRr7bzp05WtUe++K4HtKm/enwlOfo/oxpOOynXDBZhiTlq/bTEu2VlhoW9YIUI3scuWX61SwWaqx4b401aKZae3R+xt2RyBYvVwAwnasP5Nc/tZtnMo64IXSt25giwC0EROuS6TKW9jWq1WzBrXbNIZ6bPiQfGXFWjqu26n4sGsjpuPKdNjSNGnt/h2sYzo3Ip9vgxozwwWb4cA5N0GUmPAdM6kdtgtavV1eLQChY9t1sF7Q4oK54XLgZtYBW4Wfbe2ypT1eHdT0ylsY/HF/hQveytlXQHlf3Uo5aycE/7asficqHPw8lTfukcYWmIOef/aoDg2obN0m6tGjxwHBZthK1YPN2A/Xa1YmG+5LWwWgbe0BbICqql1eKABhO94ZhVzCiaCwVBYssEUo2d4u29qTzokG//fevXsrlY2FZRD3EwZzRBx36tTpgOAt/6LRlP9t+HRclFMtLW0xDZz7ycsrXDbyc7Lo4GY16dd1sQebrVmzRk3KGP/08r1eyMCS7v/fCWwTgLa1B/A8kIhVNdUFIGzH+jOCjhBPHtp0FyywsQCBjQLQlkE5He1A8BZbX/kRr+Ge4spbeIzou6nScd1POfP+E3xpX049Kh3yPFH741LXEAtYvW0frd9RUTWtT8uqy91WFWy2cuXKSsFm7KrgRmWzSNg0ztjUFtsFrRPtSqWrgs+y65AxgjaShdaLBQvEh9YMbBPobk40euUtDMhYMCZaeSt7zVTK/+rmSum4ijucSt9XO42OaH20/QOaw0xZXlFMAfRvW089xtsPQoPNsDjBtYYV183KZpkwWdsmAG1rj+5y4NaCTVwVYiejBC3XYPdywQL+f2278W0TgMCG9jjdx/QdDj4gaNma0LJly8R8wsrLKHfKC5Q7+WnyBSomkEBuTSo8/mEq7HQmlfz0k6PtyBR+/t3dQBe0yQKDgO4rqFc2w6EHm+kC16lgMxvuSx2b5gHb5rV0pYkTV4UME7QQsb/99ptytH7xxRfpoYceUhPtv//9bzWherVggc2C1qbsDTZdm2REAPzLdfGKRSLODW9LN2vWLOnKW77dGyj/y5vJv3pS5XRcg5+lQJ2W2JNLqg2ZCs4XW2ir52ZTj2a1qXDvnpRVNmMrLgfD4H1OBZvZcn/aNg/Y1h6vtCkRV4WuXbsal5IvowTtZ599Rt999x1NmTKFZsyYoYJIIFaxFfbnP/+ZjjrqqKDztVdhcW3b5GybhdYWgR7PQBwavIUDP1cVvJUM2cu/o/yvhlHWvgpLYsCXRcVH3ErF/W+WdFxJsmzzXirYXbF7dVjrupSTnUX7UnSPYiJt2LChOvRgM0y8ELkcbIYJWk8ZFsvumU3jjE1t8Yr4cxr0XS8ZxWJxVcB9ZmNQWdxX4bHHHqO+ffuqgQaRr2eddZbaQtIZOHCg6rT6ce2111Z6z6pVq2jIkCFq2wmfc8cddxwQvDV+/Hjq06cP5eXlUYcOHeitt96K+t1++ukn1bluvPFGmjNnjvLfQsWt0047jc4991zPi1nAN7sNYslmQQtsa08o8HuEuEBVqZkzZ9KECRNo2rRpyqIGH9j27durRSLusW7dulHz5s2dy1FaVkx5PzxE1T++NChmy2s2oX3n/VsJWj23rG0TZFr8Z9vUS+v55GAz+FP37t1bzSE40MfwfRBshvEdcwKMFcuXL1eTcqRx0pY+YZsAxPWyqT2mXaP83zMqQE+Z8p3jIW6J/sMPP9ANN9ygRC0E6D333EMnnXQSzZs3T1lmmKuvvppGjRoV/BnCVZ8oIWYRcDVp0iQ1QV566aVqknz00UfVezBg4T0Qwu+99x6NGzeOrrrqKmVtPfnkk8N+t7/+9a+eLn0br8uBTdgmaG1pj97f9JyIOPTgLVjJUuWi49u+kqp9cQNlb/gt+Fppu0FUePLTFKi+39ogOOk/e5AnJ1/METj0YDMIWVSHW7ZsWaVgMz5suC91bBIeJok/20W6z8Dv7LigRc1iHVhNYWGdPn06HXPMMZUELA9EoYwZM0YJ4LFjx6rVAlbk8HEdPnw4jRw5UonQV199ldq2bUtPPfWU+hv4e8BC9Mwzz0QUtOmsFOYUImjNwPTBgIO3YIEFcNNxJHgrSfwLP6f8MXeSr3hXsOJX0bH3UskhV+KkR/1b24SMm5SXB2jqioprXyvfT92a1iKvU1Ww2eLFi1WfRp9F/8YuIBZiplQ2ywQBiPZ4cXs+GWxsk6kk7USBFTPQ/TQArKrvvvuuErWnn3463XfffUEr7eTJk6lnz56VXAAgUq+77jqaO3cuHXLIIeo9gwYNqvSZeM+wYcPi+n6wMJlooRWXA+9jkoAKF7yFQRgWV9CxY0d1D6fNr6pkH+V9/wDlzn4/+FJ53da077RXqLxxr/R8J4tZXLCHtu0tCfrPZmeZtzMULtgM/XzdunUqyGzDhg3KHU4PNuPDFP9BGwWtTe2xtU2mktRdDdEFgTlgwIBgqUTwxz/+UflCIcJ51qxZyvKKgeXjjz9Wv8dAE+rPyj/jd9Heoxc6cKOwQrphn2OTJpZYsK1NXm5PPMFbuIfhRgRLVrom+azNCyl/9PWUvWW/L35JlzOpcNDjRHnetxyayBTN3eBwzX/WdGDA4L7cr1+/SsFmemWzRILN0oFXx5hEsVH8eTUoLBNJagaDLy2Cr+AKoHPNNdcEn8MSC7/XE044QSXYhpN/KjHNh9brYilRbGuTl9qjV97iA4MsV97CPRe18la6Js5AgHJm/4vyvr+ffKWFFS/581Vu2dIeF1TpYsDYNkGmgp+1gLDD2x1k1fnU+7Je2QxGFgBBy9kUQiubccqwVFY2yyQBaFt7TG2T73fDmW0kLGiRSWD06NH0448/Brd7ItG/f3/1uGTJEjW5wg1h6tSpld6zceNG9ch+t3jk1/T3cOGDeFbs8A00CRvL33pJAJqOk8FbaRvUinZR/rfDKWfh/4IvlTXoQoVwMajfMT3fKUMoKw/QtN/9Z+tWz6FOjWqSbUTr17EGm2Gu0a24yLaTDmwSHiaKv0xsU8YIWly8m266iT755BOVQgWBW1WBAgeASyMizc8jjzxCmzZtUgFl4Ntvv1UDCNL/8Hu+/PLLSp+D9+D1eMDKG1tOJmGj+LPthk/VNYpWeQuTrFPBW6nsb1kbZlK10ddT1o6VwdeKD76Eio69nygn9sVqKLbdM26xYMMu2llY4YbVr3VdyvrdfzZTBUa4YDO46MCCqwebcWUztuKmItjMNrFkW3uAuBwYLGjhZvD++++rIgawArHPKyZVWE7hVoDfn3rqqVS/fn3lQ3vrrbeqDAi9elUEdyDNF4TrJZdcQk8++aT6jBEjRqjP5lUw0nWhwtedd95JV1xxhSqY8OGHH9IXX3wR1/eF5cqkLAc2Je23uU1uCdpIwVu4v7Dgc6Lylk5KJ5dAOeVMf53yfnqMfOUV92QgrzYVnvgklXY+LXXfI8OplH82JF2XLAoq7gn4l+PQg83YD1cPNuNdEbeCzWwTgLa1x9Q2BSxd/Md9973yyivqEUmvdd5880267LLLlEUU6bieffZZlVIFFiQUNYBg1VfEcFdAVgNYXDFwDB06tFLeWlh+IV4hhp977jk1sLz++utxpewCEMgmClrbOpxtbXKq5ny44C1YgiBa3ai8lS58e7dQ/te3kn/5d8HXypoeQvuGvESBOq3S+t0yjakrdUF7YECY6X3NjXEGhpHQymac9i402IwtuE4Em9k0Zpoq/mzNQ2sjCbkcRAMCFlHTVQEH/VCXglAgmlEVJhlMtdDaNpDZ1qZE2hMteAuTH6q3QMimsr52KvIeZ6+eRPlf3kRZu/f7xBf1vZ6KB9xBlJ2T9OfLZBJf/tlfV20P+s92aLi/GI5NuN0nsGuCexVHrMFmOGDRjSfYzDYBaFt7gOSh9Q5mJOPLsCwHGPBs2p63UdCCqtoTKXgLExv89bALAT88r0RTO055GeX+/Czl/vwc+QIV/bm8Wn0qPPU5KmtTeYdHSF3+2R37KvxnD21V9wBxYcM9mq42RAs2g8hFsBlSSHJlM7biVhVsZpMAtFXQmtYmn2HfN1YyQtCKhTb92CZoQweEcMFbWEjplbd48vLaYOLG9/HtWqessv41U4KvlbYaQIWDn6dAzcr5pZ3Cpv7lFr9o7gZ9W9clW/HCPRYp2IwFbizBZiaKpWjY1h6Tg8J8ll2HjBC04nLgDWwcxJBtAEGQocFbOJo3b65cCUyqSOQU2UvHKn/ZrMIK8RTwZVPxkX+m4n43EGVlO/b/CPHzy8oKdwNwaARBa/q96tWFjR5shvEhNNgMaSlDg81g0YWl1xZsFLQ2tslUzJhtM9DlwKuDciZaaCMFb3HpWGwxdu7cWVlaTBzYHPvOZcUqg0Hu9L8HXyqv1YwKT32Rylr0I7cw8Zynqx+zoK2em03dmthbhc2UPlFVsBkWzUh7yX72bMX1amWzTPQ3NS0oLBAIGDsXV0VGCFoTXQ7EhzZ9RAreQgAILCcI3kL55YKCAuratWsav6l38G1bTtW+uIGyN84KvlbS/mQqPPmvRNXsKa1qMmu27aNNuyqKzBzSsg75s+0SFozJk3VosBnGno4dO6o5ASJ31apVqjonl/hlkYv3w8XB65gm/jJVpJuKCFoPYrI107Q24TvBChIavIWFEMRrpOAt5KL0YnvScW388z+l/LF3ka94t/o5kJ1LRceMoJJDLo+5fK0T2HI93GKa5m5wWAR3A1vOoS2iCdcDfvcYh/RgMyy4IXBxLF++XBlt4g02Swc2bs/b2CZTsV7QYiVrmsuBV8WfDVbnqoK3WrVqpR69GLzlOUr2Ut5391PunA+CL5XXa0v7hrxC5Y17pPWrCdH9Zw9rba/V3CaBEa4tsMRCtOKIFmwGtwTdigu3hXSfF5uujc1WZ1OxXtCa6HIgPrTOAbGquw+EC95KZLvOtkVHvG3JKphP+aOvp+yti4OvlXQ7lwpPeIQot6YL31BIlum/55/NyfZRr+a1I75PJmez7stYg82A7oeL8Q8Gn1Rio6A10eXA5/NZdx0yQtBypTCTbiSvWDOdJBXnPlrlLQzeTgZv2SRo4zoXgQDlzHqP8sY/QL7SCn/MgL8aFQ56lEq7n0fpwpR7O10U7CqiFVv2quc9m9em/Bzv+1smii33JUh03ooUbMZW3HXr1qk4AOxMsRXX7WAzvi623asmaQvbsV7QisuBN3BDACKlDVtf+TE0eMutylu2DWAxXZvCHZT/7XDKWTQ6+FJZw26077RXKHBQe3e/oJAUv/xunQWHtapnvRi06f50oi16sBncqvTKZjj0YDPdD9fJYDNbBa24HHgH6wWtiS4HNln/nGoT/pYrb7F4jSV4y01suUaxTDBZ63+laqNvoKydq4OvFfceSkXH3kfkzycvYMv1cIPpuv9sG3sLKthmMXOzLeEqm2FsZStuaLAZC91Eg81sFbRolwkZJjIB6wWtiYUVpPTtgcFbGGRxHUODtzAopwPbFh0R2xIop5xfXqO8CU+Qr7yiZGogr45Kx1XacXBqv6SQMNN+rxAGLdGnZXRBa5vgMJlUinM92AzGAT3YDMeSJUuCwWa6FTfWYDNbBS3mKlMK6NiO9VcBq0nJcpB+qhKAuEa67+uuXbscCd5yC5sG5Uht8e3dTPlfDSP/ivHB18qaHkr7TnuJArVbpPAbCsmwc18JLdxYkVKtS+OaVCvf7mE/IxaaKSCRYDM+wgWb2SpoTQwKsxW7RzZxOfCkoMXjnj17KmUe0IO3mjZtSl26dPF05S3bLLShZK+aSPlf3kxZezaqnwPkU6VrUcKWslMbGR0LXu0nXmDG6h2I5bM+XZeN/cFr7hOhwWb4fjA+RAs2w4Gx3GZBa1ubTMV6QStBYemHg7ewNYMyjvxcD95KRwqZZLFF0FYS5+WllDv5Gcr9+XnyUcVr5dUbUuHg56iszTHp/aJCUu4G0Qoq2NSnbWiDjpfFEr5btGCz1atXB4PN8B6A1yFyvbLblqlBYT4DvzNluqBF0BAGODi8m+LnYnIeWj14K7TyFqhfvz61a9cupcFbbmCjhda3cx3lf3kj+ddODb5W2voYJWYDNSosMl7GtuvhdP7ZWAStLdgyWZto/YsUbIZS4Zs3b6aZM2cq1wUIXN2Km654iGQRlwPvYIbCSwKOyER5U1MELQYwDAKmrE6x5aQLWD14C/XI8Yg2TZw4kVq0aGHcAB0OG9qgtyVvxXdUY8II8hXuUK8FfNlUPOAOKu53PZHP3IVHutEjx3FgDOLAG9wXbi/qikvLac66Xep564OqUYOaedb3bRNFoM1t4WAzzMXInHDccccptwS4KEQLNjPF6GHDNbIFMxReErBlEEFHcG43AS9b/6oK3oJgDRe8xYF5ttz8Xr5GcVFaRF2Wv0kNN3wVfKm8dgvaN+QlKm92aFq/molgMcdZOXDg/uDUcvA7xL3CKZEgdnkC50nc6UX3vPW7lKgFh1SR3UDwFlaMLxq6Dy18anHowWa4b3BvxBtslm5MdTmwEesFLd8AJqXu8opYCg3ewoGVdSLBW/x7L7TLKUxvi2/bMqo2+nqqtWlO8LWSjoOp8KS/EOWbJX7SNaFg50cXsLqlCZM1b6Xy94O4bdOmTfDewgSOY+3ateqzeBsWeZXxt8kWBZmxer+7wSEt61jfp21pA7AtiCra1jzm6QYNGqgjNNgMhx5spltxvRA4bJrLQcCS+4My3UJrCunKQ6tX3uLsA3rwVseOHRMO3rJN0Hpl0ZEo/nn/pfyx95CvZI/6OZCdS0UDR1LJwZdUJCs1ELevB/uH8ySLQ59kIVRxf8SSeB79B3+Ho2XLluo13obdunWrslBB8HK0OIvceP0MkeGAOaRVbIuUdAsEJ7ChDba1JZ7duXDBZljwcTYFPdhML/qQjtSOYqH1DtYLWnRuHGKhjS14C5MxJmWng7dsFLRGUrKX8seNoJy5HwZf2lOtGe046Tmq1eGItH41r8FWVF3AcjBLsgu8cMCyi6NZs2bBRTgL3BUrVtCsWbPU71nc4jGahQrf/9ffA8Jq5mVTh4ZmuFwliy1uTTZaaJNpC+amxo0bqyOcfzruEb4/dSuu28FmpllobcZ6QWti+Vs3rH+xBm+5dfPbJmhNbEvWpnmUP/o6yt62NPhaSffz6edaZ1CHgzpTpqPfIzxJ4hrzBJnq4h4Yt/QJHDsouovCvHnzgkFmLHD1qk1rtxdSwe6KnaneLepQdpYdwihTMG18SfVCQ69sxp+vB5stXbpU3c/sAsQi1+lgMxMXUD6fz7jvHAvWC1pcNNNy0TqRtivR4C23sE3QGuVyEAhQzsx3KG/8KPKVFVW8lFODCgc9SqXdzqXyn382py0RSGRwDrXw4D7BPcKTH1wIvBRpDfGqJ7Xn7w8LLlIiLVq0SJ0HnuSnbtx/TWMNCDO9H9jSBiAW2vgIF2yGRSDf35s2bVL3iNPBZmKh9Q7WC1pgmqCNVyw5GbzlFjYKWiMo3E75Y+6gnMX7sxiUNepB+4a8TIGD2pnVFpcyEGBCg0iECwEyoZhyPkItVGxhZivud7O2BN/bKHu3ygHqRiYFL2LKNYyGCNrkQV8PDTaDax1bcdevX6+qVLIfPFtx45krxYfWO9g/shnqchAtKMzN4C03McqqGQNeb0vWuulU7YsbKGvnmuBrxYdcQUXH3EvkzzOqLbEQ2gYOImERi4mMF3nhMhCYjr4DA+tywc9TiGgXoXWtapSrIBo9kwIfoZkUTD8fJm4BR8OWtnjhuuD/h1sOjnDBZmvWrKG5c+cGXXlY5OKeirSb6YV2CRkkaE220MYSvNW+fXtlWfLK1mgmCFpPtyVQTrlTX6bciX8hX6CiQEcgvy7tO/lpKutw0gFvt2Ew5vsE90ayGQhsYE9RKS3YWFFQoVPjmnT4ob3Vc/YxxIHt19BMCunIriKERyy0qSE02Az3gL6TU1WwmYkWWp9h3zdWrBe0uHAmWWhxc2DSweQ8e/bsSsFbPDHjxjKxTKCnRaAlA4JvTwHlf3UL+Vf+GHyttHk/Kjz1RQrUroieD4dp1yU0AwH8SWGB5EnHq7sUqWLW2p1UHjgw/2xVmRSw24O+jYwKHGzmhVyf8WCLxcy0e9KW6wLDULhgM7bicrAZ5mC8B/Mzfo+52+tGJduxXtB63UIbLniLb3psi6Q6eMtNbBO0XmtL9sqfKP/Lmylrb4H6OUA+Kj78Zio+4lairMi3ugmTDPuH6i4EegYC3B+9e/dWIlYIKagQJf9saCYFJLBfvHixmqzxfP78+ZXK9ULg6pkUBPcQC6030IPNeCGoB5tt2LBB3Sc4MP7orgqZuqBOFxkjaL1goY0UvAV3AUzMCN7q2rWr2jaFtQTWWJvwogh0Isgt7ZN7WQnlTnqKcqe+RD6qOL/lNRpR4akvUFmrAWQisLjyPcIiFqIVE0a4DATwfRPrSPiCCn1iqBDG4BxjvOzUqVOl7VdYcBFUBrGL/o5rwKnCcE28dO49cU86gI2C1kv9xKlgs5UrV9Jhhx0WLGvtVLCZm/g88B3cICMEbbpcDkKDt3AADt6CVQTPQ1dxuBFsEX62ClqvTJ6+nWuo2hc3Uva6X4KvlbYZSIWDn6VA9QbGXJdoGQgaNWoUUwaCdLfBK5SXB+i33wVt/Rq51LJeNUe3XzGmsR8uFt4Y5zCescDNlEwKqcAm4WGrfzYL9UjBZjiw4EbeaCwYdT/caMFmQvxkxKiDidFtl4NIwVvYukOnxUou1uAtJ/LQehEvCCeb0pD5F39F+d/cTr6iCvESyPJT0VF3Uclh1xD5vG0J0Qd7vldszkCQSpZt3kM7C0uD/rNOnkN8lp5JgXed9IIPsWRSsHmR6RS2tMPW9jCRgsKSDTYTkhS0tnY4NwRtpMpbWKHxYJ9oVHVVabtMxUZBmxZKCynvh4co97e3gy+V125J+057icqb9knoI928LnpQBQ/ooRkI8JhK0ZMp7gZ6QJgb/QD3Aa4jjpYtWx6QSQEuClis6JkUcCAwTXD2Wngdm1wOEmlXtGAzvbIZB5uxqwI0hY3nzVVBC+viK6+8QrfeeivZBra/khW0ocFb2HbD57K1AoM5Op4T2wc2CT/b25Xq9vi2LqVqKF9bMC/4Wkmn06jwxCeI8ut4QpyHZiDAoVsi4J+Je8bJbWkbF+KJ8muMAWFuUVUmBWRv4UmbDycLWtgyxthmYLKtPdymRNtVVbAZqv9hQYjP59gBFroSbBae4IyClcKf//xnuv76663L1QjLDzqKE8Fb6FjofAjewqDtxg1qo/CzrV3pcDnwz/2I8sfdS76SvRX/tz+Pio57kEp6/glfKKnPTqYdegYCtsLqGQjgQmBLpg6TLLQ52T7q0bRW3H/v9JgWmkmBJ22IXLcyKdginGxph82CFjjVrqoqm+FeCQ02wxHPgjBgyRwcVdBi0OFsADYK2mgW2kSDt9xCfGi9T0oH5uI9lD/uHsqZ99/gS2UHdaTC016m8oZdU94WzkDA4lXPQABB0rZt20oZCFKFzQN1rGzdU0zLN1cseLo3rU15Od5bRIRO2tEyKbDAjSeTgi3CyZZ22Noefcxxa6yLVtksmWAzn2XX4QBBiwuCgcWr+VqdynLgZPCWW4gPrTm4LaKyNs6hal9cR1nblgdfK+5xIRUdP4oop3pK2lFVBgK4EHglHU2mg4IKTO84/WfTRTjfQvQxCFxYppAWKRMzKdgmAG1rT7pSq4ULNuPMI9u3b1f3C3Qc75Cx0M2EYDP/+PHjaeDAgeokYWCxTdBiNYML/e233xLaumDBAnrppZeCq34vlsS0aWve1na57nIQCFDOjDcp78eHyVdWcU8GcmtS4aDHqbTrWY7+V6GDsW4BwAH3G8lAYAYz1+wPCDu4RfyC1gv3J/ojJmMc4TIpwE0BRglYrVjg6pkUvNAGJ7ClHTYLWg7eTmfQFv5vFq7hgs2WLVsWDDZj6227du3IRvxnnHEGPf7443TxxRcrE7XpgnbTpk00ceJEmjRpkjp++eUXdcEbNmxIf/jDH+imm26iY4891lMC1mbhZ2u7XB2Y922j/DG3U86Sb4IvlTU+WGUxCNR1ttgGrgdcCFgssL84+2jBfcCEDAS2TZSJMquSoK1NNhBvJgXT5zBbBaBt7fFq8QtfhGAzjO24X7i8tY34R44cSXfeeSfNnTtXrXoxuVXFY489Rh9//LGydiIw6sgjj6QnnniCOnfuHHwPPgtBZh988IGy+Jx88sn08ssvB83kYNWqVXTdddfR999/rwajoUOHqs/Wt5JgVb3tttvU98NgNmLECLrssssifre7776bpk6dqr7TNddcQ2+//Tb95S9/UauTBx98kEyAfWhtGwBsFLROtyd77TTK/+IGytq1Lvha8aFXU9HRdxNl57qSgQACAG4F7D7gdAYCIXUFFdjloEHNXGpWJ7EtRhPGnEiZFHjbFXPODz/84FomhVRh2veNhm3zmW6h9Xq7/H4/1a9fXx2R8ubagB9iETXQYbmMdYLGQHHDDTdQ3759lfK/55576KSTTlLOyRg0ANJ/ffHFF/TRRx+pCfLGG2+kc845R1lPAYTzkCFDqEmTJsqSilJxl156qQq8evTRR9V7li9frt5z7bXX0nvvvUfjxo2jq666SpWIhUAOx9///vcDzP/pqhRmRVlVB7FJ0DKOtae8TJWuRQlbX6BiUVmeX48KT3mGytoPcjwDAaebQwYCpFKCmMVzk7Gtb8XLiq17gwUVejWvndDYYeo51DMpYKcRFlyIXTczKbiNbeO/be3R22Rbu0zFD2F5/PHH05gxY+jZZ59VZuqq+Prrryv9/NZbb6kJcfr06XTMMceoSfONN96g999/X302ePPNN1Wqq59//pkOP/xw9f9BAI8dO1YNQhDVDz30EA0fPpxgNcYA9eqrr6otz6eeekp9Bv5+woQJ9Mwzz0QUtOF8WfBZnLnABLxQhcotbGqTUwLdt3sj5X91C/lXTQi+VtricCo89QUK1GqacAYCHNheqioDAXZKBLv8Z3sn4D9rC7gn0efDZVKAwEUmhSVLlqjXE82kkApsE4C2tcfWNpmMHzc+LgqsM9iaTwQWixgUAIQtLKKDBu23LHXp0kWlnZg8ebIStHjs2bNnJRcEiFS4IMC94JBDDlHv0T+D3zNs2LC4vh+nIzMFWwWtbRZaJway7JUTKP/LGylr72b1c8CXRcWH30LFhw8jyqo65VK0DAS4t+AGFEsGApuuS6Yyc83+DAe9khC0pk/Q4fqynkkBATHRMimwwE13JgXb7kkbt7pNbFPAsn6lo+5WviC4OPGuUPE3EJgDBgygHj16qNc2bNgQnFR1MMHid/weXczy7/l30d4DqxO2lGItn4gAMJMELV8D2zqebfl1kxLogXLKnfIi5U76K/kCFX5Y5TUbK6tsWcsjI/5ZpAwEXMAAj/GWFTVtQM6ExVIyAWG4nD2b2REQlihV9elwmRSQsJ4FLseUwC2BBa6eSSEV2Gb9s609NpfzNZVKy89ELgx8aefMmaNcAbyKqRZadji3BRtFR0Lt2beNqn11M/mXfx98qbTNQCoc/BwFqtev9Nl6+hUcPMnCkmRKBgLBfQpLymjhxt3qeYeGNahmvgT1xTs2If4DR7RMCvi9nios3sVjvNgkAG0UfzaKdJNJatRDoNfo0aPpxx9/pBYtWgRfR6AXok4xAetW2o0bN6rf8XuQjUAHv+ff8SO/pr8Hq+p4BpKqKoV5DXE5sFegT5j7Fj00/zU6f+dOguNMgHxUPOB2Ku5/k3q+e9euSi4EWIhxgmy4D7iRgcDGhUamMXf9Liotr7iGvZon7m5gQz9wSmREy6QAF4XZs2er3T9d4DqZScE2sWRbexLd1Rbcw59ox0RWhE8++USl1YKlSOfQQw9VVlFkJTj33HPVawsXLlTBJ0cccYT6GY+PPPKIyhuLgDKA4geYvLt16xZ8z5dfflnps/Ee/gybLbQ2igzb2hTv4LxowYd058K/EWX56I26dejmQj9tGfgEbazRlbbPml0pAwG7EOB+iFbCUBBszT+bKG6NMXomBQCfWyw6I2VSwIH7N1ERZ5sAtK09prbJZ3FWBn8iFwVuBshg8Nlnn6ntT/Z5xUSMFS0er7zySpU/FqtX3NQQwBCiCAgDSPMF4XrJJZfQk08+qT4DOWbx2Vz0AOm6XnzxRZUn94orrqDvvvuOPvzwQ5UOLB5MS9tlc/lbmwRtPO0pLC2kESs+rPTauA4PUMmWGlS3bGfEDASpwrbrkmn8lmSFMB1bJzungXiNlElhy5YtKpMC7itd4GJujHWBats9aaPLgYlBYTaTkKB95ZVX1CNK5uogNRcXPUBqLXReWGj1wgoMbmq4KyCrAYQutmpQWGHUqFHB92CCh3hFTtvnnntOuTW8/vrrEVN22eJyYKM108Y2VdUeLKI49+vby9+mNXsriiVgSB/V5UHq2frImDIQuE26/38nsK1vxcus3zMcVM/NVj60mU46+rSeSQFUlUmBj2guRDbcmyZbMzNRpFshaOMhlvejMtdLL72kjki0bt36AJeCUCCaZ8yYQclgqoXWtgnatjaFDs6RMhDsrbaXxu8er96Tk5VD75zwDrWu1Zq8hE3XJdMo2FVE63YUquc9mtUif3ZWRvcDr4iMSJkUIG4hcpGHPVomBdsEoG3tMbFNgd8rkGZE2i5bMdFCa1uKK9sELdqB7aaCggJau3ZtxAwE8N++ZcItVPZ79a9LOl3iOTFr+/1vOzPX7nc3SCYgTEhdJgUOosaYwRZcPZMChK1N46WJ4i8WJCjMg4LWC6tZNzEtKAzYNpiZLtLxvTHZsAsBZyCArxx86CJlIBi/djz9UvCLet60elO6pPMl5EVMvS6MbRNlIu4GTvjP2nAuTRJO2MlEFoVwmRSQ0QepwxB47VYmhVRi0nXJ5DaZTEYkKzTV5cC2oDCTRDrOPfzf2H0gNAMBLCzYMuzYsWOwQl4oRWVF9MLsF4I/39TzJsrLrgh49BIyINtT8jbTMxyYjp5JAQGi2P1p3769Erjr168Pm0kBu0ImGKVsFH82tslkRNB6FJPEXzx4tU0I1kAFOhaweI7ARa71Hi4DQVWTyPuL36f1e9er54c1PIyObXYseRWvXpdMa0O8lJUHaNbaCgtt49p51Lh2PmU6NokMjDFuZlJIJTZdF5NdDny2p+2yHaQBw1aOSTeUydvzJoh0zkDA1ldYY7lcM6wjcCGIJQNBpPZs2ruJ/rnwn+p5ti+bhh08zLN9z6vfS6iapQV7aG9xhX/2wc2Tt8565f4UwgvASJkUONAskUwKqcKk+TeT22Qy6e/lKfKhNS0ozFaXg3S1CcEXuv8rZyBg9wHOoeyUQH957stUWFYReX52u7OpXe125GVEyNjgbiABYTaJjFjaoWdSQNagcJkU4IeL37O4hYtUOspl23JddCQPrUcFrY2djcHNi1WrSXjJmmlam/B/YBDXU2hxBgIIWM5AkOygHul+mb1lNo1ZPUY9r5Nbh67qehV5GRvuexvakAjsbuCkoDX9XNoybibSjqoyKcBFQc+kwMFm8S7mE8FGjeGVFHFCBX5sV2Cit62jmZ62SwRtYhkI2AoLlwJYJSBcI2UgcOMalQfK6ZmZzwR/hpitnSuBOoK7FtrsLB91byb9TL83TccpARgukwLGSXZRmD17tnLL0wWuG5kUTPQ3zUSRbjL+u+66K1iGFiu6VKzUUo2JWQ5w49vocuCE9YQzEMDiAPEamoGgZcuWapGWisCI0PZ8teorWrB9gXoON4Mz255JXseWxZMNbYgH+M4u3rRbPe/YqIaqEpYsmXYOvY4bYgnzYaNGjdQBsHsJget2JgUb+5aNIt1k/BADd9xxB7Vr145OPPFEOuGEE6h3795WrTxM9aG1bQBItE3RMhDAohAuA0E62rOnZA+9OufV4M/Deg0jf1ZGuKkLaWDe+l1U/nv36+mgddb0cd+WuStV7YB4TUUmBRu3503taz4Dv3Ms+N9991315LXXXqObbrqJBg0apMrRmnqhIq1Iy8rK1I3qxXQm4chkQatnIMABd4JEMhCkuj3IarClaIt6fkzTY+iwRoeRCdjY1zKBOev2+8/2dCDDgS3Y0pfT1Y5omRRwwE0BYzSnNIw1k4JNmsLmNpmMf/LkyTRp0iSaOHEi9evXjy644AL1C5suEvyDAKy0prhU2CgyIrUpWgYCuA/gEX5gXmbdnnX0wZIP1POcrBy6seeN6f5KGYVN41WszNYCwkTQ2tcfvCKWEsmkgIPnXa+1x0nE5cBb+M877zy1ZXvjjTfS+eefH0ytZNPWAEezmyRobc1Di76FwTBSBgK4vmA7Kx1pZZIR6C/NeYmKyyvcWs7vcD61qFkRYWwKtvU1rxAasIgdIrjJ4Eg28IYttLn+LOrYqKZj31fwDl4UgJEyKbDADc2kwMFmNgpaG9tkMn7Uie7QoUPwBdvELGBxZFJgmC15aPUJHQEHsL5OnTo1KGDdykCQSkE7o2AGfb/2e/Vavbx6dFnny8gkbBmQvSDG8B3QxzG5s4jFfcyJ7nGuN2zYQAsWLFC+/SxuccTjRrNzXwmt2LJXPe/apCblZNs1ZieDLSLDpHZgB61p06bqCM2ksGrVKpozZ456fcWKFVRUVOTIgs4L2KiXTMYPMQv/UlwUdC4bL45uoTUFU10OcINzABe7EABM6Bj00K4+ffoY48tcFWWBMnpu1nPBn/+v2/9RjZwaZBom9jUvoG+/sojFeMoCtlWrVpUixHkswnt4wl+3bp3atsXv6tevHxS42E2KNOHPXb8r+LyHw+m6TBcZtmCSoI0lk8KECRPUHMALOswBeqowJzIppBrTAt0Clo/zyixmi7iIBNqHgcE0C60Jna+qDARwIcBKHDc9Ju6NGzda099wjcZvGk+LdixSP3es05GGtBlCpmFKX/OSgOU0R3jkUqPs8w1fwqomOdwDEK84QgXumjVraO7cucoHkcUt3qe7S4n/rJ1CUMemexI7cOjzzZs3V9kUImVS4Hkj0UwKqcaWvmYL/i+++EJZLrENAMGHaMaGDRsSfGttAR3OtFy0Xs1Di77CllfOQICJF4NPVRkIbBNORYEi+teafwV/vqXXLZTt8/YAHAnTr4tbkwpXnWPxikcIWA6AwQSNvp+slUYXuB07dgzmBtUFLqxbLHBnrt4W/FsnU3aZ3g9saYONYklvT1WZFOCmgLlGz6SA53DT8RImuhz4LOpTofgvuugiNZjigOiDc3f//v2tErQmVgvzivhzMgOBV9rkFN9s/4a2l1S4VBzb7Fjq07APmYjNA1y8oH9ygAuLWIwbLGBRbQnP3bYcheYGhcDloBtM9tOXo6CCj/KzfZRXvIMKC/2ezwSSSmzp07a0oyqBHi2TAo5YMymkGlMXHT4Dv3Ms+OGkjcETB/uDmLbiiOXioX0iaKPDg4hugXUyA4FNgnb93vU0dvtY9dzv89MNPW5I91fKeBLtW5go2fqKgwUs+nzXrl09sfWJ8Qs7Zzi27C6mbV//qF5vf1AOrV61kubOma0WmuyegMdEJ3tbJzvTMFUsOdGeaJkUcITLpIAjmt+5G9h2jUzHj4EPq37koz355JOtvUBioa06pRAHtGAyxyTudAYCmwQtKoKVBkqNTdNl63WJBZ4YWcTC3QqLNkyIXbp0UUI23QI21oIK/To0oSOP7KTcqdgfcfny5TRz5kw12bPA9YI1K1XYMofZdk8mG0AVSyYF9HE9VZjbmRTgcmBDX7MFPyqE/fbbb6pS2F/+8hd1gVAK1zbge2OaDy22GVOVgSCegJZMF05zts6hb9d8q57X8teioZ2HkunYcF0iAcGquxDAIssC1sS0cbPDVAjD+KZHlWOyZxeFpUuXKv9ElIfW04SZkOs5k/uyaRH0VeF0TEi4TAqY19DnQzMpsMB1OpOCidfIZ7EA9z/wwAPB1c9LL72kzPsQtLaschnTgsKcyEPLNziL13AZCDDJpeo62yBo8f2fn/V88Oc/NP0D1cqtRSZjw32utwECVs9CADcadptBwBUeTRKwocyJoUIYxjsEaeJggYuJXk98j3PC4hYTPv7G9PvTpj5tUztSIf5wT+uZQ/RMCrywczqTglhovYUfK5nFixdTjx49aNmyZVbeSGgLLBi2+9BGykCAG7iqDASpwAZB+93a75SFFjTNbUrHNzg+3V8p40G/x2IVaeEwhkHAYqGGCat9+/aejI5OFNw/bKGtU81PLevFVvkQYrVJkybq0K3WcFFYtGiRCvaEwOVYCpxPW86ZqdhmVEp1e8JlUsCcCHGrZ1KAqGWBG+9YYaKF1mb8PLhB6LBVj/O22oSNgpYzELALASYl+AzhBo03A0EqML1PFZcV0ytzXgn+fF6j8yiL7BjMTFposL8oW2HR7zGpICDENgEbysadRbR5d3GwoEKi9xQWuqECFxM9goQx6Y8bN065H+kuCqZYtW0Rgra0wyvtwf+NRRuOcJkU5s+fr36OJ5NCutskVEaNUKgW9tBDD1W8YMigFS+wUDjtk5rKPLR6Qne2wmISgiXKiQwEqcD0cr7/WfYfWrd3nXret1Ff6lWzl1FCMBJeH5AhYHUXAo5uRr9v27atepw1a5YKFkEWAJvR/WedrBCGSRvnD4tkjC8IjmMXBfgicsok3UXBq3OFDfekTe3wqvhzIpOC5KH1Fn4k7UZkO+edxeSByeHQQw8lmzAtywFfi9WrVx+QgQATuInBLCa7HOwo2kFvL3hbPfeRj27scSOVrjdngVQVXrouXFSAJxZMKthBwmQCywr7e2YisfjPOgF2dpBzFweAoGWBi5ygmPgxFnGKMK/5JXtJONkiADOhPZEyKbCLQmgmBZOMZJmA/6yzzlKrDIglpHrB6vzqq6+mX3/9lWzC64JWz0DABwYAuH+wC4GbGQhSgcmC9h8L/kG7Snap56e2PpU61u1ICzYsMLY9OumeZFjA8sSBiHxYQVjAQixVlXLK5L7lBQstE+kc4nqgKhoOFrjwv4XAxSSP3SL2RYTITWfqM5v6QbrvTaeviWntqSqTAtydZs+erfz33cqkIMSO/8ILL1T+ZrgI6HQwr99yyy1kG14rrMA3BrsQ6BkIMCFAvOL1gw8+mGzBVNGxatcq+njZx+p5fnY+XdPtGqPbE45UtoP7PrsQQMDCMsKp4zIpZ2q814hz0DaomUtNaqfvHEHgYpsWB5cHZgsudvi4bCm7KKRa4JomnEy1aNouaKvKpPDjjz9Sq1atlEEqNJMCC1wvFGbJFPy33367erJ582bV2TBQDR1qfl5Nr/nQRstAgMCM0AwE69evD+aJtQVTBeArc1+hskCZev7Hjn+khtUaWjE4M263A7s/LGDZAsvbdrD44dFLwYteZfW2fbRjX8UY1jOJgLCqiPdz8X6MXThY4MLfnwUu3NrgPhUqcN2yYpk4xoRDBK0ZwBjIAjc0kwJcBjmTAgvcdAatBiy5NyLhR8oWTDbnn38+vfvuu2pw6tevn6f8oUx0OdAzEHAuTA5kiSUDganiLxomCsAZm2fQD+t+UM8b5DegP3X6U6Xf23KNnGwHC1h2IcDuA+4/FrDo+1g4C/Exe6277gZO9QM92AZjHT4TW7MscOGLCOMCT/BsxXJS4Jo41oRiy9hik4W2qjy0sWZS4BzQsWRSEGLHf/zxx6uTDovgRRddpHyhkMtRBG3s6BkI2IVAz0CAVELxZiCwVdCa1KbyQDm9MOuF4M9Xd7uaqvmrGdueSCQ7yUDAQrSyCwH6PywQGKgRXNGtWze1eLNtMktnyVs3A8KcBtcdYyEObM/qAhd+uEgVBmHAFlx2uUpU4NpwTzK23DO2Ctqq8tBWlUmBq/hhd0MXuHomBafxWXYNdPzPP19R9ehPf/qTKn2LwcTG1YKTpW95W0G3wOoZCDCJ43kyiwLcJDYNzCYKwLFrxtKC7QvU8w51OqhgMJPbE4142sEBjDwo4zn6OgZiFPBAyic3B+Rw2HQt0mmhBW5ft3ACl7dpORcu+phuwY1X4NowaYvLgZ3XKDSTAufV1jMpwPilC9xUVvQ0Gf8555yjnsCiCGutrXkck3E5gFjFKkq3wAKIV7cyEJies9V00VFUVlSpiALSdGX7so1tTzSqGijRD9H/edBF/+ca6V6oQJcJlJUHaO76iiwbzerkU/2a9qQtC7dNi/7GApcrWIYKXNv7m02ClucyW9rDOJGHFsa2cJkUMNYikwLyQPN4y4fpGY/cImhCPOaYYzyVBcANQRurhVbPQMBR2HoGArgQuL1iskUshWuTCQP1h0s+pI37NqrnRzQ+gvo17hf2fbZdo1ABy/cA938seDt16iQCNsUs37yH9haXGedukAgYGzBh42jTpo26x7ALwAIX27QAEzvnwYUY5jHFhPElFmwaW2x2OXC6TaGZFPQdsXCZFLhkb3YMmRTwXW27Bjp+TFw4YePHjyebieZyEJqBQE8jhG2Brl27yhaqA5hyI20v2k7vLHxHPUdp2xt63mD9ogP3xsqVK4P3ANrGggGVBOEDZsr1s5E5v1tn3XY38GJ/Rr/DThgOVIbTBS6y8yxevFhZq9iCC4OEF9sRL7YIc70ttrQnUlCYG6BvQ4twZUSvZ1JIJ/5hw4apEwTT9uuvvx6sDGMb8AtGJwBY4WCLlEVsvBkI0lH61gZMsaC8teAt2lO6Rz0f0mYItavdzipBywMiuxDgwGsYADEYooyy+Gx5i7laQFj3ZrUokwkVuGzBYoGLnLic7J5dFEztzyZ+53B4fcx3KygsVS46nAd6W0gmBRa4mZJJwb9x40Yl3rCqtU1AAVzshQsXKkdrXOjPPvtMDXrvv/8+dezYMaEMBKnAVLEUq6D1Kmv3rK1UROGqrleR6ehBjOxGoG9ZYbKHIOjVqxeZio33i848zULbtYm7gtY04aFbsLAY++mnn1REOeazTZs2qfEf27gsbnGYsONgkwi0qS1ec6MIzQMdLZNCgwYNqGfPnmQr/tGjR5NtILful19+qQY2HLiYCF7BIPb4448rf2EMfl7GxgnaBEH72tzXqDRQkbz+wg4XBosomHSNOC0Si1e2wPKkD79EiFi2LMCShXtE8Cbl5YGgoEVA2EE1vLX49iKwTmHyhsECwha7cUgRxkE2nFaOfXC9GNRokwi0qS1eE7TxZFLYt28f2UxStnKUfTv99NOVmwIu6qefflrp95dddlnQb4aPU045pdJ7YCZHyjBOeXXllVcGXQMYlFI8+uij1UWCS8CTTz4Z9Xt9++236kCBCHwnTOr4XFigzjjjDM+LWS+LJZsF7byt81SqLlA3r+4BRRS8eo1YwKIiE7ZaJ0yYQL/88ouawDGxo3wy7h88YosqXISsF9ohhGfF1r3BgDC33Q1s6AehbWD/WviCY0444YQT1L2ARR0Wc7hfEEMyc+ZM5Y+I7VovnAcvfAensFHQ8o62CdkGcn7PpMAWXFvxQ7Gjs8HlQLfaxAImUQwMV1xxBXH6r1AgYN98883gz6F+HBCzKOoAAYpVxOWXX07XXHONcgkA2Ao96aSTaNCgQfTqq6+qCRv/H0Qp3heOG264QR1u5aFNBbbmoQVebBe+00tzXgr+fGWXK6lGTo2of5OuAVov5MHbSkgtx0EByO0JIRvrvWzbRGOzu0G3ppntPxsr0fo0osHZ9QAiF/cO7iUYVyBw582bp+Yp3UUBFtxUY5MItKktJlhoMxX/3XffHQwKGzlypIroj7XzDR48WB3RwMDQpEmTsL+DT+vXX39N06ZNo8MOO0y99sILL9Cpp55Kf/3rX5Xl97333lMRfP/4xz+Un2v37t3pt99+o6effjqioA2HaYLW1jy0XhW0kzZMUmVuQYsaLejMtmfG9HepaAs7/esuBFiAwtIKAYtVd7J5Cb14TYQK5q7bL2i7N3U/ZZfpE3S8fRkCV0+TBIHLKZKw6zF37lw1j7F7Ao5UlW42/VrYLGhNstAyNmaa0PFPmTJF3Zy4eUO3+p0AWzkwdWPiReGGhx9+ODhwTJ48WVlaWcwCWGLRQfC9zj77bPUe+LzqQVsnn3wyPfHEE2rQwefGAgYkk/LsemU7OxMEbVmgjF6e+3Lw52t7XEv+LH/arhE+U3fqh4jltCy4X7DQg4CNJe9gLNgwwNl4vzBz10uGg1T2adxX8L/FAbB4ZIHLlZzg/qYLXDey4tgkAm1qCyMWWu/hh2AMxamOB3cDuCIgtQoi7e655x5l0cX/iUEDVmGujhH8Qr9Ho+J3AI/4ex0EePHvYhW0plloedVn20DgReHx1cqvaPnO5ep593rd6bhmx8X0d05eF1hgdRcCCFi2wMKxH2LWKQEbDq9dE2H/dWGXg0a18qhBTftT73itL2NOQkERrqKpC1yU6UWMB1wSdBcFJwSuTWO/TW2xvfqZyfixfQ9RCbcAp03nF154YfA5UkUgKAtRp7DawjHfq5XCvADfJLhp3BQymS5oC0sL6W/z/hb8GUUU4hmgEm1LqAUWpac5byDcftwWsDoyIHuX1dv20a7C0pTln/XSvenVPh0qcDmCXBe4yKijC9xEcoDaci1sFbTpyEGbDIHfq3TajH/AgAF0yCGH0P3336+Cr9wEOQKxjbNkyRIlaCGikSdQB6tfDAzsd4tH5MrV4Z8j+eba4nIAbOuAXhO0/17yb9pcuFk9P6rpUdS7QW9X2gLBqgtYCFoIWLgQdO7cWQlYTJTpwkvXJBFsmyyZuVpAWHcJCPNkX+YIct5thMDlMr3Lli1T2RMQcK0L3FjzntvSr1NRUSvV2CjSTcePPK2PPvqoygrwxhtvKH9VdD43Vh5wsEcqIc6NdsQRR6jJffr06XTooYeq17777jv1//fv3z/4nnvvvVcNElzKDRkRIAJidTcA+FsRtOnHS4IWJW7fXfRusMTt9d2vd6wtLGDZjQAuBWyBRUEPCNl0ClgdGZS9yzwtIKxbCgLCbOkP6WwD5hq4xbFrHOYdjAGY+2DMQawKC1z44WJMCCdwbRJMNrXFVAttJuDv06cPffjhh0rIIq8sHhHlGcuFwo2JG5RZvny5ykDAq9AHH3yQzj33XGVJhQ/tnXfeqdKkIKgLYGsVfrZXX321SskF0XrjjTcqVwUuwfvHP/5RfQ7yyA4fPlw55D/33HP0zDPPxD3IwPprCmKhdZ+3F74dLHF7WpvTqE3tNnH9vT5A86TFAhZptTBpsYCFBTYTa2unEq8slNwKCOuR4SVvTQViNVTgsgV38eLFah7FYlcXuBgrbBOBtok/G63OpuPnCwOrKUd1xjrxInn7ccftD6C57bbb1OPQoUPplVdeUb5Eb7/9tprkIVDh0vDQQw9V8idCWi6IWLggoMNDAD///PPB30MIjBkzRlmQYcXFd4R7RDwpu3hQMclCqweF2YRXLLTr964PlrjNy86jK7teGdffoy8hRzJyMSMjBx4hYGF5hZ84Hk0RsF65JkJlcE3Y5aB+jVwVFOY2NvQDrwtBzEUw8rDLHHZzWOCiTC/GEgSEYozB3InnpowlmST+vN7PMjJtF0oCvvbaa8qnDz6uKHLAPqycXisSAwcOjDoAfvPNN1V+AaxKuYhCJBBMBteIZICIhgXYlE6oB4XZhFfE0xvz3qCS8oogwfPbnx+1xC1A39GzEGDS4YUZsnCwVUUQnGL9jiLavrckWFAhVeOWCeNjNLwwvsQDxhG9TCnmYszByH+LNGGw4kLUcpowjDVecVey9Zpkqkg3HX/fvn2V20Dv3r3Vtj9quhcUFNCwYcNU6VpbMM2H1kviz7Y2Ld2xlL5a9ZV6XiunFl3c6eKIApZFLLYFkZoHkwkELCyweB2lMkNTz5mGF65Jstg4sUj+2czsD0j5hR3NRYsWqUqcnCcePrioYgbBC4HLLgpe8sfPJH9TG9tkOn7khdVdADBpw/qEmu82YVraLpvL36a7Ta/NfY0CVPEdLu18KdXKraX8q1m84hELO9wLmCxwL+AxNPWOyZOmjaS7X5leIcwWTNmFi7UdLHA5rgQBpuyigJgSuCzANY9jV7Do9lqqR1uuiY5YaL2HHwFWmYBphRVsLn+bTuExc/NMmrBhgnpeP7c+HUKHqNLLELCwhEC4tmzZUk0KseSOtEVE2dIOm5iXhgph0g+8LwIxTjVv3lwdAAGooQIX4xgLXDxPt8C1UdCKhdZ7+MeNG6duDPjPwoqJDAfsOGxTB2QfWpNIt/izqU1cn/2ZX/Znxzg+/3jylfmUgMWgH291H1uuj033uY0BYXWr5VCzOs6XVrUVG+7JeNqBnSQcLVq0UH8DCy7cEyBwkSoT8x4suOyDi7Eu1Vvltgpa29pkOv4TTzyRevTooXxmr7jiirSv5NxCfGi9QapEIAQsAh7ZhQAZCZYEltCivYvU71vVaEU3HX8T+bMS9z2zaTCzRQTYwqZdxbR5d3HKA8Js6dc2tCGRduD9LHCxUMd9zRZciFwEmcG9ii24ELkQu24LXBvFn7gceA//eeedR9ddd51Ki4WbADlg3Sqs4AUfWpNuLPGhjU/AQrRyFgI8xzWH6wCihzt37UxvTHoj+P5re1yblJi1SQiacj/Y3gYdCQjL7HsSODFX4e9RhhcHC1zEyLCLAgtcjJPsouCGwDVp3rXd5cBn2XXQ8aNzI/3Waaedpgos2Cpo2R8SN2+sZQfTjfjQRgZ9FBZYDuSCgEWkLwtYFO2ArxnfvMhqsGznMvW8W71udGyzYx25PjZNnqZjQxvSWSHMpnNow6TthgjE5yFfNo5WrVqp/wMZXFjgrlixQo2tusBFRoVk9YCtgta2NpmOn1NZYQUHgWArLGLhMG+SoLVlgkm2TRhk2QILEYu+CgGLrTNU4OnSpUslAatTXFZMf5/39+DP1/W4zpGByJbBzJZ2eB30+1gnwUoW2qZSISze82wDqWgH+iKqlOFANhcWuOyDi+qfGHtZ3LLAjXfMsFH8icuB9/Ajp93mzZtVpPeRRx6pXrTNOgtYxJoUGJbJghaDBTIPsAsBBCz8u2E5aNiwIXXq1Em5yMQySH66/FPasHeDet6/UX86tOGh5BS2XB9b2uEldP9F7scYW+G3yAf6cDg4IKxWvp9aHVQtxd/cfGwQT+kQgbrAbdOmjfoOGIfZBxcl7AFbcNGH8d6qvqeN4s9UlwOb8U+fPp0uuOACtRV/8803qxdtvEhcxcmk4gqZ5EOLAQ+WAV3A4r0sYDt27Kh2EeIdFPeV7qO3F75dyXfWKWxZcNg20aQTLmOKPoxHjKucIxQCAYsyCIN169apJPnIrAFRgJLeeA8W3pt3F9HGnUXq87o2SW1AmA39wYZ70ivXAv8/LLI4WOBip4xdFFCUCfOU7qIQSeCmuy1OY5pID1h0X0TCf9xxx9Epp5yiqoLBr8ZWTLXQ2pqHllf+ejED/A4uBJjgO3TokJCADeWTZZ/QtqJt6vlxzY+jznU7O9QSewQtML0d6ZpYuCAHi1jEJGBCx8TerVs3JWb1zDEcqIj+jb/F32CHDMIACzoIhxVF+622PVKUf9Y2TBIaJm3T4/ugT+NAxUR2BUP/Rz9GmV4IXN1FAbrCxrgcsdB6D//o0aMpE8CNaFpxBdsEEyZsuLigXCwmcAABiwkeeZAx8Dk5gO8t3UvvLnpXPfeRj67seqVjn60+02OTTaa3IxX3CgcjsgWWC3JwSWQ88m5QVcAHHLsPONi6C+vtmAkrg+/J21tAS5dmB9MruX2tvCiiEukDJrfBpHZApGIMx4ExnAUu+vGmTZtU+V4s6HBP4MAc4IShwguYeK/4DPu+8eJns7ntDWXriEkuByYLWk4Pwy4EsGLxihYTM7IQxOJ7lQz/Xfpf2l68XT0f1GIQtavdztHPN/n6CLGhpznivowJGpYnlCLFY7wFOaJlYsFnbikvIKI96rXD2jdWAgHBOSDU/zYTxu1MxNRxRRe47du3Dy4AFy5cqAo+TJo0SS3kdAuuqQLXNJcDxsTvHCt+27YBol1E3EgmCVpcG1NcDnji110I8N0hXmG1gv8VLLBz585Vgx22Vt1kT8keem/xe+p5FmXR5V0vd+X/MXXi0RFhXhlMvGyBxSP6Me8kYJJ2ewLmgLDqudl0ZI/2lJVVsXCCsMW27saNG2nBggVqga4L3FhKNds+4dlwP9pyLQD71+L+wVzQuXPnoIvOhg0bVD+G5VYXuKYs1MTlwHskn1neIMRC63wEN4tXPKK4AQtY5DiEBTZ0wZQq8fTR0o9oZ3FF6qMTW55IbWq1cfz/ECFoB3BDYusrJlq4xWDBhX6McqJO5OGMle17S2jt9kL1vEuTmkrMhvouQlRzKWds7SJ36KxZs9SCkcUthAEW8JmKCYLIBpeDeLfndf9agH7MApcDJbEw0wVupHSM6cZElwPbyagRT3xoE4drhOsuBBzBjdV3rBN/KkTg7pLd9K/F/1LPs33ZdEXXK1z5f2wZzGwQ5vG0QS+LzH6wsLpCwCKbBvpzusTg/A37Cyp0j1JQAW4PyIyAA2DniVMrweqFe5XLm+I9qShv6gVsEYK2tKMqayb6MS/CdIGLfrxmzRq1o8cClxdqELhewFSXA5vxc2UQrrIEq5qtcPlbU0in0GABy9ZXHDh3bIFt3rx5QpNkKtr04ZIPaVdJhTA4ueXJ1LJmS9f+L9OFYCag59LklHBY3KIfoxwoHp3ark+W+b+7G4CucRRUwNjWpEkTdQDOfwsXBQRhQiiwKMARKQBT+rM3sFHQxtKWUIGrZxFBmd45c+Yon3W23uJ9TvmwZ4LLgc+S/hQ1bRdWPBgIu3fvTgMGDKA+ffqoxPU2gQtpmstBqn1o2QLLIhbnirdeEayC53oKokRxc9KEm8EHSz4IWmfd8p21xbLJ2NKO0N0EFrEAFst4i3Kk00LbtUniaRTRPhzYOWFBD6sXp1bShQMO3erlxfOSaf3YlnYkuz0PQ5u+E8Gp7ljgzp49W/Vz3UUhVQLXREFrO/7HHntMCZf169eramEjRoxQEehnnXUWXXmls2mO0o1pQWFuCyb4CuouBEgbBNGKiR99IDSHpglt+veSfyuXA3Bqq1OpeY3mrv1fJk/8trUDFkikBJo/f76a7HCfc0GDSP7cXoQFbU62jzo0rOl4cnzOHRq6rQtBC9EAy64N+cht6NM2tcMpf9PQVHcQuFzkgX3JIXDZPQGHW7svuI9iTdEnpAb/hRdeeMCL7777rjpsE7Sw0OIGMAWnxR8ErO5CAAGLiR4WWESfQgC47TvoZvWzHUU7lKBl6+xlXS4jt7HFkmJaO3Afw3WALbAQs7zt3qVLF7Uoc3ox5jaFJWW0bPNe9bx9wxqU63dHgOuBOfAZ5qA4WG9xTiF0MU6w9dakc2nLVr0t7XA7gArzVaNGjdQBuC+jDy9btoxmzpyp/ON1gctFljIxKMxn2PeNl7Dq5eKLL1aHbZjocpCM0IBg1V0IsA2bagGbSgstAsFQTAGc3uZ0alqjKbmJLS4HJgxysIawHywOpLDiYJHWrVtTQUGBmrhggTSVRZt2U1l5RX/q1iR1sQywMrEo4IpOGBsgCmDxgkjAmMECF5ZeE/qMyYigTb4vA8z37KLA1fiwA6G7KCQqcCUozHv4MVjh5sGWHcDWE5cmNWGLzmZBG2/pW755WcRi+xACFhaWdEdvuy0CtxdtV6m6QE5WDl3a+VJyG57UTVyph+I1Yc5p4fSCBpzTElZYlJXV/T4hvkxnwfoKVxnQJYWCNpwoQNAnDs4vjfOLY+nSpeo6hBZ48Aq2CEFb2sGka4zEnN+4cWN16NlAdIHLpar5iNWNwEaNZDr+UaNGqYsMoYMJ5JlnnrG2chg6qmmCNprQQFt0FwL2f+ME8BCwXvTxcUM8oYjCvrJ96vkZbc6gJtUror3dxJZ7xCvtwI4CC1g8wq2ACxrA8up0aWSvMU8LCOsWR4YDN8H5xnnHAUs4V37S84YiCEcXuE5t6WYyXltgJotXFv2h2UB4zMGBMr1YvGEHgsVttFLWXmmTsB//v/71L3URUX0Gvmc2XyQT89DqFlr2D2IRi+vG+TO9LGCTsTrHwtbCrarMLcjNyk2JdVbH5nvGTThimQUsFmScVQMZV5zKqmEKesqudFpoo8FWch5z+BrqPouweOkFHlJ5DW2ybNrQBq9vz8NtqWnTpurQA6U5nzOPSZwiDP2edznZPUfwDn6Y3eFucPPNN9NRRx1FNmNaHlrcMLihsHKEiMX2CAQshCssVng0zRrihsvBu4vepcKyiupKZ7Y9kxpWq4iATaXLgcmkyheYrXt6QQO4DZi0IHML+M4u3FghaFvWq0a18tPjGhRvPwiNOmeLFwQBsifg51D/WxEBsV0HLwpA29uD3YZQgcsWXGRQQRwKC1z0bVNK09swT8WCGjWxgobrQYcOHei0006ztriC131oOYk0W60gYHFt4KOG7T5MDKYJWLfF0+Z9m+njZR8HrbOXdLqEUoUtgtYtcF7Qh1nAom9zARf4Z+LRqZyRpgfordiyl/aVlCedf9ZLFi/OB4zsCVyiF6/pBR6wQHdS6NhioTVFAMaKqf6mGJ+Qgx0HQH9mgYuxDVbcDRs2VHJR8PKuks+iPhUOPy4KLLTff/+9ejQprZXpFloWsCxidYsVcmfi2mAViIwEtuC08IB1tri8YpFyTrtzqEG1igTcqcCmwcGpaxJa0AAWDFheIV6wYHZawNjCAr2gQpr9Z526PvgcLMYxluFAH0N2CojbTZs20cKFC5VFHn0DOXBTmRTf65i8OLNZoGN+5oBJ7J4i2AyLdIx3qGKG+ZpLTuMwKeWdDfhvvfVW5dw/YcIEuvzyy9WEYyvpDgrj3JnsBwsBC4sGl+BE59cH9JUrV6otD5twUtAW7CugT5d/qp7nZ+fTxZ3Sk2rO9MknmYmG/bpZwKK/8pYcqlTJFnNszNNL3nrUf9aJfoZ0YDjatWunDChc4AFjHVKEIfhM97+NNyuL6feijg0C0DZBG9om3pHginy6BRf9GXqDBS76tBvFioT9+FHnG1XCDj74YLr77ruN39L2UmEFDNgsYNkCixsAHTyWLVc3ixDYIGjfWfhO0Dp7brtz6aD8gyiV2OJyEE/6Me7TLGDRp7EI5iT9XkgNZ7qFNp0ZDlLZl/Xyu3pKJQ7IgTiAANALPMS6bW26eLJNANrWnnCBbrwjoZec5tSDOFCVDwaAUAuuia4YXsX/9ddfqyePPPIIoQzuQw89pKwqNuK2Dy1P9uxCgO01/J8QrvDBwaOeOzMdGQFsEbQb9m6g/634n3peLbsa/bHTHynV2DZAhwPXigsaoE+jf2OnA4MxdhXQp90qLZkpPrT43pyyq171HGpUK/3n0wsplSBoOf8tDC8YX1kIwEUhXBo3W4STLe2wtT2x+AWjvVjs48BYyTmdWeCuWrVKGdgwhnK/xgLOLYHrszQdq07QlHLvvfeqVcX//d//qaTlNoKJGD4ubkRtQ8TyZI8Oim2Irl27KgGbaCcyeZJ2u02wzpaUV/hDn9f+PKqXV4/ShenXKNTSzNtmvLMA0KcRyQ5/7mT6tHAgm3YV09Y9JUHrbLrPbbr/fwb9DHMSW7sQU8ACF9l5dAsvjniMBV7HNgFoW3sSaZOe05l9yrloCcZbBE3qMQcQuOKyFR9+RKDiJH777bfW51WDBQDWpkTB+YHVlSd6PMf2KjognMORx9fJyV4EbXjW71lPo1eMVs+r+6vTRR0vonRgi8sB71pgmxf9mv2+IGKRXQNZT2ybjLzE/A3ezz+bbtD/0A9xtGnTRo3F7H+LrVykCMPYCwsX7kds7ZqcAs70MSUTBG2yeim0aAkv2tiCu3z5cvV/cNo7CFz0f5s1WrL4EQiGVcKvv/5KV1xxhVoN20q8hRW4fjwLWFhgYRVAB0OtaFir4C/j1o0qPrTheWvhW1QaqPCFPr/9+VQnrw6lA1MFrZ4eDgMn7n++P7CrIIEL6Suo4JUKYV4HYyNv08J3G30afRkplMC4ceOUdYszKJgYbW6TALRR0DrdJn3RxgKX3b1woOw00F0U0Mdj/Q4Bw+apRPDjxOHEDB8+nE4++WSrOyB8/aIJWl3AsgsBBk4MhthuxcCZyrRD4kN7IGt2r6EvV36pntfw16ALO15I6cSEe0TfWcDAiOcIRtRLyv78888q8ty0Sd+k6xCLhTbdGQ5MnfSwUwYjAwwMqHp59NFHq76OHUhEm2PcDy3w4OU+Y9v8a1t7UtEmfDb6KQ7sSnDau1CBy+L2oN8tuLad53jwv/jii8EfcMLY0dnGkxJqoeUVEAtYHGi3V/JmisvBgby14C0qC5Sp5xCztXPTH8DoNRHAvll6QQMuWQrfbvjI6/6GfE94rR3xYur3Z0Gbn5NFbepXT/fXsWLs1xPi676KXKIXbdQLPLi505YItglA29oDUu2iqae9gyGCDXC8cFu8eHFw54IXb+ECJ23GHykKbsqUKUrQcUoVG4D1CVtSyOQwadIk5WIB31f2F4SFyksdQARtZVbvXk1fr6rIylErpxZd0OECSjdeuUZcg5yDubAFy+lhqurXXunvmciuwlJatXWfet65cU3KzpJr4bRwCvVV5B0LiFvMByhpit07uCawr2K6M3fYJgBDU1zZQLqvEcRrqMDd+bsFt6CgoJLA5cPmtKxBQYsLg0AQWHJQZAHbj2+//TY9++yzRgtaXODZs2erKmjjx49XgW/IcoD2DhgwgA477DBlyvfqjSY+tJV5c8GbVE4VacwQCFYzJ/0lQtPVd2BVxf3KAhaZCbDdhEELVik3078IzrFwo3fcDTIFdiPD0b59e7X448UgAnFmzpyp7iW23sLYkercyl5YJNsk/jJBpGdp/RpGDM7ChH6Nynyw4vbv359sxv/CCy8okYfGIi8a/I2wXYMiC3379iVT+c9//qNSkEGow59q4MCBKohr6tSp9L//VeQv9TriQ7ufFbtW0JhVY9RzuBkgVZdXSMXkow9OnGED26QQsJiUk5l0TQ1uswGvVQgzvQ8kIpxw3yBGAgfAnMHuCfPmzVO7H+yGxtWeUrFY9JJYcjtnq4l4vU1Zv7uZ4cAcgTzOtuNHQQVsxSOZNSyWzzzzDJ144okxf8CPP/5If/nLX2j69Omq4tgnn3xCZ511VqWL/sADD9Df//53ZVGCZfSVV15RAVYMJumbbrqJPv/8c3URzj33XHruuefUFhEDoX3DDTfQtGnT1MCD9995550RvxdWIl988QUdeuihwfQtb7zxhnI1MAWvbGd7oU1vzt9vnf1jxz9SjZwaVl8jPYUL+3hj4oWARZW5nj17pn1b1EuYOvl7McOBqefSKbAtC19zHADVnljgokQv7k3d/9aNOAvbLJq2tce0NgUs0xGR8HOaEx09OKwq4GyPsrnwRz3nnHMO+P2TTz5Jzz//vHJhgJ/Hfffdp7IpYOXLZV//9Kc/KTEMlwBspSKV2DXXXEPvv/+++j2sUSeddBINGjSIXn31VeVGgP8Pq2a8LxyozIEjdKCKJ21XuhGXgwqW7VxGY9eMVc/r5talP7T/A3kFJwVtaEEDPQchFoBuBa7YYqE18ftzQBhcZzs2Sr8Ljem4ITK4nClXe0IgDsQttnEXLlyoDCZ6gYdo5cxtFEuZ2h6vW2gzEX9oaVUODIu18w0ePFgd4cAFhx/uiBEj6Mwzz1SvvfPOOyoQ69NPP6ULL7xQOeSj/C4sr7AQA7hBnHrqqfTXv/5V+QO+9957ahvoH//4hxKl3bt3p99++42efvrpiII2HKYJWnE5qOAf8/9BAaoQKxd3ulgVU/AKyQzS6NMsXiFk4fqD7UyIWEyekkTbbkrKymlJQUUOYGQ3qJZrZsq0TEJPpQQDDbZxucADXPZgbIHFlvPf4l5OpMCDjQLQtvYAm9pkA343VxhwsIcFGJZVBhM23AEmT56sBC0eYWllMQvwfnwvZFo4++yz1XuOOeaYShF6sPI+8cQTSgxg0HCjsEK6EZcDoqU7ltJ3a79T5+OgvIPonHYH7gKYYhnkyY8FLFwK4FaD/gv/btwbqQ4+Md3CaTIrtuylkrKApyqEmd4HUi2c9PK7APML7m0IXFhv4a4A8csZFDDXxTLnmn4dQhFBK6QCV2dPdmeARVYHP/Pv8IiE2JW+1O++gvp7sBoO/Qz+XayCFoKYy3yagAhaojfmvxE8H7DO5vuT385L1TXSC3VgkkNQF/og+jbSB6HfeiGNilgZ0sPCjbuDz5GySzAfGE0wN/H8BDci9r9dvXq1WtTqBR6iJcK36b60TdDyzrZJLge+OHbeTSV95qA0Vgoz5ebKdEG7cPtCGr9uvHpeP68+nd3ubPJye/AIi4xe0ABgAsME16VLF1XQwKt9z+S+5tVzGo2FG7wpaE08l4zXxnbc7ygnj4MDPVngLlmyRFl4scCFBReP8NX1YjuSxcb2AJvaZAOuClpkTgAoRcgRo/xz7969g++Bc70O1+Xmv8cj/kaHf+b3xLp6NslCm+lBYbp19tIul1Jetvei+tEW9FUENULIYsEE1wFMTshxbEIpQq9/v1gx7V7Rc9B28pCgFdy7zzAe4MDYwKn4IG7Xrl1Lc+fOVQIYllubjBk2ij+20NrUJhtwVdDCTQCCc9y4cUEBi4wF8I297rrr1M9HHHGEsmQh7RdSbIHvvvtOdRhOAoz33HvvvUossIM9MiLA7zBWdwNTg8I4mtKWGyfWgXrBtgU0Yf0E9bxhfkM6o80Z5AWw2NILGiCQCwsyuM107dpViVlYXUzDpgnUFBZtqrDQ1szLpmZ1vOVKYyomjZV6nlBU5WRDDlcww9iCNJN6gQcTxxYbBa2NbbKBpAUttlCwdaIHgiEDASxUrVq1omHDhtHDDz+s0g5x2i5kLuBctRABp5xyCl199dUqJRcE54033qgCxvA+8Mc//pEefPBBuvLKK2n48OE0Z84clacWOXPjwcSgMNMGaaeE0+vzXw8+v7Rz+qyzejlBLmiAtDzo3+jPS5cupU6dOhldUU9IPTv2ldD6HUXqeefG3rHiy6ImfSB2BAtjHAgWRdVOZDuBwMWch91FLvAAFwUEm3ml32Sa+OM52aY22UDSgvaXX36h4447Lvjzbbfdph6HDh1Kb731lip+gFy1SK8Fy9ZRRx2l0nTpufqQlgsi9oQTTggWVkDuWgZWrzFjxqjCCrDi4ma+//7740rZxT60prkc2DbJxCJo526dS5M2VBTAaFytMZ3e5vQUfbuKc43+qhc04HrYcJvp1q2b2hbUF3C2YFM/8zqLJCDMFWxa/MMaC6MODvbPZ/9bHnf0/Ldu5alOFhsFLQwdJgWEZQpJC1qUlI02EaITjxo1Sh2RgFjgIgqR6NWrF/30009JfVcTXQ745jFxqylRQfv6vP3W2aFdhlJutruZAFDakgUsHnG+YQnhkoFVVQKyQQiaPtmY5jLh5QwHpvcFG4U5nmMcwoGdT/weu0UoWQ/3BORzh8FGF7heqSRoo7+pTQsnm8ioLAemBYXZUsFJp6pBYPaW2TRl0xT1vGn1pjSk9RDHvwMWNXpBAwhabN9BwKKkLJ7Huvo2TUhFw/R2mPT9K1lom3hL0JqMLUKjqnbgd9i5xIFFN9KBYTyD9XbFihWqVDzcFvQCD+nMcQ1ssmhCpJvYz3wGfud4yChBCwstBgrc/Om+uTNZ0EZrj+47e3mXyyknK/4qO6HgeiOamAUscsPC0oFBHr7dsMYm2h9sEbS2D3RettB6qeSt6X3Z9O+fqDDHDh6EKw4Aww0HmMF6i3y47H+LA0I4VQLTVgutTQLdFryv6hyEt2AQPSqC1nuZG2ZsnkHTNk1Tz5vVaEantDolof+D663rBQ1gneeSsnh0ajvOpkFaSA3l5YFghoMW9apRzTxvDcOm92nTv79TxhtkGOK0lrr/7cqVK5XIhKsfC1xYc906b7YsMmzcCbANb42kLsNVmbB6hYXO63AUJa9wbSBS5obdJbvppdkvBX++ossV5M+KrXvis2CBYAGLR7wG4dqwYUOVhcDNgAkbBmxbLM1eRE/1hjGo0F+L9haXedJ/VrBTMGH8w4EFPS/4IW7hg7to0SK14Nf9b/Wg7WSxMSOAqS4HtpNRgpZz2JoWGGaT0AjnRjFn6xwaOXUkrdu7Tv3csmZLOqnlSVE/B4sSXcDC6s4FDRA0geTlqdgSsu36mIqXJhdMdmwR4x0CbAljgYV++92iVcH3NskvU+/1cgU5k7DFcuZmO/C5iBPAgdSD6K9YcKG/rlq1SqUIg/hlcYsxlefOTL4mJrscBDJkjspYC60p2CxoywJl9O7Cd5XfLJ6DWjm16L7D7jvAOgs/WL2gAfIfY5sMgy0KbMA/LB2ZIGy5Pja0I13fH4KAAw255DEWWBAM6J9Imo8FFvfPCduWEc1fqZ7XpT0qewsLCC6BivfaJgIEb4pATkuIAzEF6MvsfwvrLRZc6Mt6gYd4xJytgta2NtlARglaTBI4TLLQ2lb+lgeBgn0F9Mhvj9CvBb8Gf9fzoJ40st9Ild0AIgHbYixgYeWC3ysG3datW6tBlRcogpBqsMDSC26gr2KblgMNMfFjrAk38S/ZvDf4/JQjelHLOrlBATFv3rxgAn2IW7jMwD0qVdYg08caW4RGOq8DrLGNGzdWB0AWGPa/RfYEzJ+6/21V5b1tuSY6kofWm2SUoDU1F61tPrTzS+bTX8b/hXaU7FCvZVGWqgZ2YasLaceWHTRrySwlEvBeDJwYWFFRTi9o4BVssGwytrTDDXAPwuqKSZ0LbnC+YvTP7t27KytrLMJzaUGFoPVn+ah1/erkz8oKBvCwuwJ8G/F/oQoj+zdC4OIRY5ibAsE28WEqXrkOWKg1b95cHVx4Ru+f6POhBR5sF7QmtslnmR8zZbqgxcU0MRetLUKjqKyIXpz1Iv13z3+Dr9XPrU9XNrmSGmxroKy17AcL3y43I2+dwpbr4/XznI7roAdzQcRiImc3F0zuieT2LC0vpxVbKgRtm9/FrA7EAf4PHG3atFHfgfOLoswyLGS4R1jg4rktRVcyVWiY1A58J71/YgGG3TP0T5TqxQ4DBLAucL3almSQoDBvklGCFoigTQ8rdq2g+6fcT0t2Lgm+1jWnK13a8FJqUa+FEgnw0zJtcrZpoLZBmCfrRgDrKASsHszFgYZcfSmZ7f/VWwuptLziPLdvWNmSFQ4IZrgd4MD10bd/EcAD8P04B2mywWWZ3ge8gikiEPcCFnY44CuuL8CWLVtGM2fOVBZb3Fuw6uJ9po3xNgSFZQoZJ2hNczkw2YeWo2c/W/oZvb3mbSoOVFjG/eSny1pfRhf3vNgKP1hTr4+OCZOn28FceMTuDayePEHrwVxOsHTznuDz9g1qxH2NIFhbtGihDraO4buvXbtWJdCHeIDAhQDOxOAyU4Sgre3QF2AAbjpYeKF6GbIn4GfcW3qBBxPbaer1sZ2ME7QmWmhN8aHFTY7sAywQNmzdQJ/t+4xmFs0MvqdtrbZ0mu80OrPrmVaIWVtcDoAt7Ug0mAv5iqMFcznB0gJN0MZgoY3VOtauXbtK0enJBJeZPFHb0odtaQcH8sId4ZhjjqlU4AEiF+3UA8zQR03ofya6HPgM+76JkFGCFhfUNAut1wVTaEEDDpTZlreN/l70d1pftD743jPbnEm39LqFfp7wM9mCLYOELe1wM5jLCZZpGQ7itdDGMrZ5KbgsXdjSJlvaoQckQbDigAsP5jUsLtE/N23aRAsXLlR9Uve/daqio9OIy4E3yShBa6qF1kuCNnSLFj59nG8T26DYov1o2Uf08pyXqTRQqv6mZk5NuuuQu+j4Fsd7sk3JYktbTG6H3qfYj49zwjoRzOW0y0GWryIozC3CBZdx8nwOLsN9y763ElzmLWza0o6U4grtQ7/DgR0GzjWORRist+ij6L96gQevlKy36frYhDd6R4oFrUkWWgwE6XQ5wCDDfnq8RYsVNifhhqWLB5kdRTvoril30cQNE4N/3/2g7jSq7yhqWqNp8DWbBK0tbTF5cEYfhRUWfXPGjBmqv6JPQrg6FczlBOWBAC37PWVXy3rVKNefuu+D88HiNVpwGay6WPCbOmGb+r1ttgDGOj7C1YfFK0A/ZBeaBQsWqN3A0Awf6TpH4nLgTTJO0IrLQXS4zrde0ACLAIgD1AHHY7htoJmbZ9ID0x6gTfs2BV/7U6c/0f91+78Dqn7ZIgJta4sp7YgUzMVCjXMXw280mZKdTrN+RyEVllYsTts56G4QL3pwWaNGjaigoIA2bNigLGM4t6gOhSAzE4PLTOnDmdKOZMS57kIDIGh5EYaFKxayuv9tKtM82rTgsImMFLTiclD5xsRAwcIAB4BwxWSGsrLRUgGhZO0/F/6T3pj/RrB8bd28unT/offT4U0Ot14EmjDJ29COWIK50Aa8h7csERgFKw77jFZV0chtlvxunXUiICxRcN9hkcq+tThfXNa0ffv26hzhXEcKLsMB4eDlydzrfTmTLM1OtkXP8MEByOjHOBYvXlzJwovDzUI8JlpoM4GME7Sm+dC6kbaLt3LYwsWpirisbKwT1pbCLTTql1E0bdO04Gt9GvShB/o+QA2rVaRtsV3QApva4hUwYcDaqi+0cJ6rCubimvQQufh7Fm7Lly8PTngscFNtvV35e0EF0K5B6gQtB8XxucD9h/ZjxwXnIjTbCM5TaHAZW8Y4uIyttzYHl6UbW86pG+Icn4fFFw4U4eEUkeija9asoblz5ypBy/c6+quT97tYaL2JCNoMSNvFASEsYBEkg4EA1i2UlE0kIAQiduS0kbStaFuwfO3lXS+ny7pcRtm+6J9lk6C1pS1eaIfTwVyw4Oo5W3nCg7hFTkwOisKEh+duC4hV2/YFn7c+yD1By/lpWcDCms2+h1iwxtNWPbgMf+v14DJbLJu2tCNVbUE/5cUsYjvQT3mXAdZbWHP5HsCBhXEy/dSm62MTGSdoYU1AZ7dZaGBCYz9YHNhW5HyAWM3iZk40B2xpealyL3hn4TsUoIrv1SC/AY3sO5L6NOzjWpu8ik1tSVdlLk6p5WYwV+iEx0FREH0rV65U15HFLVsdnWbV1v2CFkFhTqK3B/c8twci1Mn2hAaXwfrLwnn16tXBvKJOVS6LF1vuRZsEUzragn4K/3AcQA+CxCIMPvh6gYd4F7SmuRwELLkvqiIjBa1JLgexCCb8HtYs3brFSdexbditWzdH/Ik27t2orLIzt+wvlHB448PpvsPuo3p59TJSBJo0qKX7mqSjMlc06y2svjj0evQQt7r1FodT1lu20FbPzab6NZLb/tQtzhCUuP/5OyNNVyoszvj8UCs4+zBz5TKMO7qbRyqCy2y4J20ZH70izvX7nedLFrgo0YvvxwFmsSzE8BmmlfD1WXBfVEVGClqT0nZFEhpYcerCAFZnWF5xUyKnn9MRnxPWT6CHpz9MO4t3qp/hVnBt92vpoo4XUZYvPguaTYIW2NQW2ypzJVKPXvc5RUor9jnlyS4Ra2dpeTmt216onrc6KDGrpe4TjIN9grHrkg6f4HDnEWMQDtwTGJP4u0LcmhZcluki0Na24LvobjS8EEM/Xb9+veqrGKP0ALPQe95rbRIyVNCaGBTGli1YZFgYIDMB+8EiQMatnHwl5SWqSMK/l/w7+FqT6k1oVL9R1OOgHgl9pk2C1qa2ONGORIO5vATcHJo1a6YO3eqILXUEm+C+0623sbRl654SKi2vOL9Na+fH9D3wf+P8sbiGewZnbYCITXfWhmjge2GsTXVwmU1Cw5Z2eP2a6AsxZPpgX3623s6cOVPdayxuMeea5nKQKWScoDXF5YC3QWHR4q1QbINgAsBNh5vPbYvMmt1r6P6p99OC7QuCrx3b7Fi6u8/dVDu3dsKfa5MItGVQS6YdejAXHiFceLGFrWi9+IZp6JMdrLcYO1hg/vbbb6of61vqkUp1btu7f1comrsBFqpshcX5xHnjlFpOR2qnktDgMljuddHAwWV8LhMN2rFlXPG6CLS5LbjnsMjCAfiex4EFLXZwcB9ijIORKdZFreA+Zs4yFroccF493Q8WNxYGdUwCXbp0UdsgqeK7Nd/RY78+RntKK0p15mTl0E09b6Jz252b9OBkk6AFtrQl1nZEC+aCWOFteRsHebSradOm6uBa9BCgnCqILTmhlYy27immonULqWTrWtrVAMGTXdTrerlPnE+cV4g5FrGpTBafSjCuhQaX8UIB5zKZ4DIbzpctY4qJgraqex6LTixm0WenT58e7KtswUUlTa+11+fzee47uUHGCVovlb4NLWgAqyxHXsIahBsDW3MgVWK2uKyYnp/9PH287OPgay1qtKCH+j9Enet2duT/sEnQOpFWzQtEG+y8FMzlJfRa9BCfuiUH25S6KHvu8b/Qhvf+rv7ub18QlS+8ii677DJ1PjEm4T04j16qV58qOLhMD9JLNLjMlnHFdBFoa1vQDrhMoT/yAp79bzdt2kQLFy5U97Puf5tKQ1Smk1kj5++rLViU0gEmPPaDxYEVHrYrMIkhyTlEQahVCzcQrDipYO2etXTflPsquRic1PIkuqP3HVQjx7lSnbYJ2kwK5uKiBV4I5jLBeouJ7ttvv6XPfxezzOuvv04nnHACHX/88Z606JgcXGbLubSlHTb6m7JI1xe1CMbmHRf0VQSUzp49W/VPFreZuGBNJRl3ZlNpoeXOzRZYiAJ0bo7wjsW3MFXi74e1P9Ajvz5Cu0t2q59zs3LptoNvo9PbnO5KlRdbBC2wpS0I5sJ2L7u8cDAXgnpMCObyEnAdwMIZ55F3WUJBWdk+ffqoMUCsOMkHl0HcmpRjPFOsmja1pSqRrpffBdAaXOBhwYIFalc2tMBDKsbUgCVzVFVknKBF0IZbghadRi9ogAkNVhu2wOIx3pQ/bou/cFkMWtZsSQ/3e5g61u3oyv9pk6A1uS16MBf8t7kAB4RBz549jQ7mSjVYvPLEha1yLA6wcMW5PPHEE+mZZ5454G9QpQ9pgjDRYbHA2+n4O1k4xBdctmHDBrXdC+stBC6eJxNclm5sEoE2lomNtU3oi8juggNA0PJiDFlT0H/ZzZB3G2y57ukg42YrJy206NSwFrAFFgdAB0WFEgRyJVspx03BtGHvBuViMHfb3OBrJzQ/ge7qc5ejLgY2iUCT2xItmAtpoHhHYd26dep3ejlT2yYkp+59iFccOG9YDGBi6ty5s1q8spCC8Lrtttvo6aefDv79n//8ZzrzzDMrWXHwOQgsw0IjNMm7ED6lGc5ZQUFBcAHBAhaLM85Ckc7KZYliypiSaeI82Tah/3EhEg4E13cbdAsvDrn34yPjBG2yabvg96oHx2Ay4uAYTFxO54bkPLROM3H9RHpo+kPBQgnIYnBzz5vpnHbnpKSSjy0DtpfbEi2YC5N8pGAu/A1bGjm4CYOrni80E9Hrw+Pc4Fzivsd5weIVVtZI985DDz1EZ5xxhqorj9K7ffv2DWvF4UkOn79x40ZlacTnJpvOyga4sASX9+UyvHDfCueb6JXKZZkuAm1qi5N+wfh7jL84UN2PU3XqWVO4v7L/baaOvbGScYI2XgstJjH2g8UgipJ5nGMTW4YQB25OME4LptLyUvr7vL/TPxf9M/has+rN6OH+D1OXehWphDJZBJqOE8Fcus8irhMPssiFjEFWLwvr5eT+ycIlMjmlFs4nziXajXsfY0A89z5ErC5kq5rkYDVnEc3WW4xdbHHEtYTYtRW978EKy+V9sYCIJaWZ6ZXLbLmvbBS0brhR6BULsejVF9Cw3mKhm2iuZp+k7crMPLS8qmdBgOcsCDDBoLOlMrm5k+KvYF+BKpQwc8vM4GvHND2G7j30XqqVW4tShU2CNt1t0StzhQvm6tGjh1rlJzr4on16YQH8X2yhXLFiRTCfKESGDRG8uoBEOyF6uJoVRGyqBSTOJ9yXcOgCm1ME6RbHeAW2F8HYzFZYnH+A9sGClczuQLzBZZxLOV1CzCYRaFNbUtkm/d4HPM6jvyJ7Au8QsQW3du3a1p3neDF79nHA5YAnCb2gAa+UkH6nW7duafVjwXdxQjBN3TiVRv4ykrYXbVc/Z/uy6YYeN9AFHS5I+U2QbhFoelsiVebi4EM3g7lC84WyHyO20hHwwIFQOJCOyuvofmzsC4v7Hd8f976Xtvj1GvQQeLoFhy2OfP45wbvX0V0scMAii++NBUTv3r3VDpgb41O0ymXLly9XgsGJymWJYpMItDEoDGNfqtuEsZdLcrNu4fsf1fZ8Pl+lAg/RXKBsJSMFLayuL7zwAv3www909tlnq8mZTf3YyvJSXshkE/eXBcrozflv0psL3qQAVQivxtUaq0IJPQ7qQenANkHrNl6tzIX/jwdPBELpQVIQuLw9D3Hipch9PQgL55S38eHDivRkpgRihLPeoj3Ynl+0aFHw/LP/nVeEuW4Fx8FBcDAgILtGOlKYRapchiMdwWW2jI+2iXOvtElf3LZq1Sq4s7xlyxaV8QMLXA5S5fRgmeB/mxGCFiJg/PjxNHbsWPrkk09UqhxYM4466ig6+OCDlSXGK5Otk+Jva+FWemDaAzS9YHrwtSMaH0H3H3Y/1cmrQ+nCJkELnG5LosFc6QYWAQyuOCDC2eqpR+6zIEilaOF0evx9sCDgNFkQsF4S205McGxxZOsNXBMg0HR/0VRbb/TFDr4Xrj8WOl48/5Eql+FcIgNIaHAZu9q4ka/bBtIt/jKhWITuL96+fftKOw5wDUN/rcp/3wasFrRTp06lG2+8UdVbRhTyoEGD6NJLL6XPP/+cfv75ZzKBRMXfrwW/0gNTH6AtRVuCLgZXd7uaLu50MWX50j952CJonRLnGIAgtNjtBRMoBiFTK3Phu+rWQ95W5ryrvK3MacGcnhw4UwOLWJxftgKyX7HN4Pzj/OLgFGN8LuAvCmuNbr112kVFd0fBobujwJpvgjtEOLGAalChrh5YLLAvI863E8FlNolAm9piihtFdsiOQ6qqjVopaEeOHEkPPvhgpdcwiGEiY+dm5GH84IMP1GBw8skn08svvxxMPgxQNu66666j77//Xg0QQ4cOpcceeyyugRer65tuukmVmITfCfjf//6nDlOI14e2PFBO/1z4T5XJoJwqXBUa5DegB/s9SIc0OIS8gFN+wV4g0YHa7WAuLxEaua8H/mA7F+iplBLZGmMrLH8uFgQQTXqRCBvOZaLnH+cCB1vPw/k+8zVI1OVKDxjEI6fVQsS2DQGDenAZp1nDfYzzx21eunSpaiefy0SDy2wSgTa1xdQ2+Qz6rsng2giDrSRs8Qf/I20wu/XWW+mLL76gjz76SFlnYEU955xzaOLEier3GHCHDBmiJvZJkyYpqw4sqxhIHn300bgE7SWXXBJXlgOvEY8PLXLKPvTLQzRxQ8V5BH0b9aUHDnuADso/iLxCprocoN9BuKYjmMtL4D6GpRRHpLRgbL2NlpoJbhhshcWB+wTnEovXXr16STnZGKw3ujsACzKMkaHb6eHQrx0OLCgwnuPvsHCxOaUbg0USLxacDC6zaXz02vZ8JrbJJ2m7kgODIARpKBgA33jjDXr//ffp+OOPV6+9+eabKiUO3AAOP/xwGjNmjKpzDkGMVTCiXZGYfPjw4cr6m4xzMxylMRGassKKVfwt3L6Q7v35Xlq3d13F35GPruh6BV3W5TLlbuAlbBK00doSLZhLT0OUqZbDSGnBWCBBELCVj9NS6eIL5xOiCecRAlYqmjnj+4xFl269Zd9bnGeMn3pAFxfdgJjL5KIbsQaXQQhxoA4WbZGCy0yZn2LBpraY4nKQqbhmDsJgCEsJnOuPOOII5S6AARP+rLBUwZ+VgX8rfjd58mQlaPGIbULdBQFuCXBBgAXnkEMOSco6lEylMC+Kv9ErRtNff/srFZdXtKtObh0a2Xck9W/cn7xIspkbvHp9OJhLdyPg5O2cLBsWR68Fc3kJjBdcGhLnE/lWEYgzZ84cJbY4+AljAwI6IbAE59BLb3LmClTZwoExHfCiDLtweJ9M7PEHl7EvebTgMltEoK2C1rY22YArgrZ///701ltvqQERNy78aY8++mg1KSGlBFbxmOR1MEHhdwCPupjl3/PvkgH/N5z6TSFa6duisiJ6+ren6fOVnwdf61avm6r61aT6gdZxr2CThZZFLAJtbAjmSjf6NjYmfpxPWGGx4MW2Llu8sDUOoatbb+UcOwNnSGArLBeXgFsMDALsq4yyyOxigH6eCS4GqQouwzWwZYy00ZqZjjy0QpoE7eDBg4PPsRUIgYstqQ8//DDt0cWhhRW8TiTxt3bPWuVisGjHouBr57Q7h27ueTPlZnt/28/UwZqDudiNAAcmH06LhN0DTP4y2MUOi1QWsZwgHAKKt7l19KICXkgLZgN6Wi30aZzzSCV+4fuMxRrcEfi6wT2Et9vZwpvKioqmB5dxmrs1a9aoc4n+jAUD9+d0Vy7LxLE+WnvEQutNUhKBgokeAyCsWCeeeKISlLBm6VbajRs3Bn1u8YiUWzr4Pf8uGUwMCgsdECasn6CCv3aV7FI/52Xn0fBDhtMprU4hEzDNQltVMBfEE15HQnv4yWHRxkFNmRxdH21RACssiyFY/BA4g/MFS2wsabxCiwogLRjOP6y22MqFWwILAbeqTZl+DdhXFgf6tJ5WK5Y8tejnunsIPo+DobAbx9cUh1hvw18DXpSh73KBD8yV7C/ulcplyWCbhZbnLhlTMlTQYrLBFiEyDhx66KFqZTpu3Dg699xz1e+R+BtpuuBrC/D4yCOPKP85rmP87bffqhsaRRCSwTQfWj3FFap+IR3XOwvfCf6+Zc2W9Gj/R6l9nfZkCl4XtIkEc0FAQdyy5RATFCYhTFpcLSuTrVZs1eYDfcCpYCI9LRi2cjn7AYTCjBkz1O/ZcojHTL0GsISzgMX5YYsqErHj3CSTYQP3AsQYDviK6ym8kL1CryqXycFj6JsYG/gaoC9GsoRz8Y9kgsu8gG3WTJ67TBPpPouuQUoF7e23306nn366mqxgMXnggQfUjXrRRRcpa8mVV15Jt912mxr8IFKRKxYiFgFh4KSTTlLCFQL4ySefVH6zI0aMoBtuuCHpIBAMpBAsGBBMWOFyABWqfo2cNpJ+Kfgl+LuBzQbSvYfeSzVyzElS7kVBGy6YCz+jb/IEHWswV6jlkP1B2WoFywpbb01KLh8vusUO7UdZVrYwYVzAc7cGWNzjnBaMA3EgIlAxB+4J7PdZVVow00H/Q9vZAshptXAN3E6rFRoMpadmw33A3wPXwM2+kG70oiK4BuwTjjEAi69Y+l+ywWXpxlZBa1ObbMEVQQs/IIhX3HC4cVFiFim58Bw888wzanUDC61eWIGBcBg9erTKagChi4kfhRVGjRqV9HdjQYyVcrr9eWMBN83K4pX09HdPU0FhgXoNabiu63EdXdThIiNvKi8UVtArc8EKi8keFhFYSbDlikkhWUteaEoqTsCOiQ0R4+yagMOGlFOccgv3Pc4rW+UgnnA+02GV0wNx2HLIVsply5YFc67iGuA7mrDIjaVCWmhaLbhysB9mOq4B7ivO9MGWYnxP7MyxtT6ZwhpediXAXIN2QYw6kZkjluAyTrXmVOWyZLBN0HKQtunjtY34AulWFikGNz4Gl9WrVysR4WVwad5f8D69Mv+VYNWv+nn1aVT/UZ6p+pUIOPew3iE1W7qCubgyFyZZDiZKZWUuTEIsPDDp4buY5pqg+2GiLbDC6pY3r/tN6hWzcHCUOVtvscDxOug3OO/cj7BI4wpp7D/s5YmXrY16cQbdV9QU/2eIVr4GXCWNF6uhrgRuElq5DPcnp2JLV3AZ0nBiUZts/ItXwDiBCqbYSfbyvRUaxAYrvwn3UjJknKDF9g8mWlhnuFKOF9lbupce//VxGrtmf7W1g+sfTA/1e4gaVPPu945V0EJIIAOGm0QL5sLhlcpcumsCJkQIFHZNwOElYaVHtuO86onknbBqp/MahEb6Y4HDiwwvBfdFSqvF18GEnadYMl6gjWxh1gs7eN2VgK2iXhAPeuUynE9851QHl6HaJ/y0Q1NxmgoMI+PHj1c7y164xlURyCBBm/7ZPMXwdpaXA8NW7FpB9/x8j3pk4F4ANwN/lvmXzC0fWgzeEIMsYDmYC5O9lytzhXNN4MARuCZwcEg6XBNg8WErJiZFiD72QcUk5ZWJO1nQBr2EqW5BR3Af+pZu5Uq1sNLdVXA90I/RH8IFE5kMzisK8uDghR6uAxbB8H+GaGRxm657AdfADVeCdFUu0xdDsWS3iBcbXQ5MKiUbyCCbpfnqKIEbHB3Rq6m7flj3g0rJBQstqO6vTmfmnknXd7+esrPsmLScErSRgrkw0ZlcmQsWNi5Fqgsr5KUE7PPplmsCiye2knEkNgSsyVbYeMBCiHOEoq9yMQFUzEJZbhZWuA5uBDWFS6vFfpFI6wThbcqE6sRCD31Pz16Be4Gtt3y4kXs4kisBqlua5nOdruAy2wStbe2xiYwTtOiIXsxFWx4op3/M/wf9Y8E/gq+1q92ORh06ilbMWGHVKisZQZuKYC4vCyu0HZMrZ01gn89kXBP0bUlM3hC0LJ5gMbbFCpsoaDtEKw5OC8ZC89dffw2mBWPLYaL9L7TABKyPmbaQiDV7hZ7BAYsMBEOx7zCuQaIuIuyTzFbYRLISmEK04DKIW2yt8zjAPvGJnFO2aNqCie3xGfZ9EyXjBK0Xq4XtKdlDD/7yoCqYwAxqMYju7nM35fpyaQVlrqANF8wFMNBiYkNgWSqDubxisYL1ORnXhFB/UY72x+eyZUYID84Vb4vrKal4kcEuGbgO0SypuijDgedcjMDt1Gamg/OC84yDrbfsV8z5n9GP2doYzXobyZUA19errgRuVy7TfcpxXhFzkkxwmU392NRCET6LrkEkMm7WwkXFZO0VQbtq1yoa/vNwWrlrpfo5i7Lo+h7X00UdK1JycYoQfswEQQvrOSYYdiMIrcxlk8+g064JmJDZNUG3GmIAZusLJiksErhufKZsYaciJZXursFpwfSgOT2lEw5OkB+pzK8QGzjPiKLHobuI8FY6+rceCIX7xRZXglT4lGMXR68Eh0UDu91UFVyGPm6iAIyEuBx4l4wTtF6y0E7aMIkemPoA7Sndo36ulVOLRvUbRf0b9w++h0WGzRZaU4O5vOyagMkHkzmq8HFfx3u4tCkEmFhh3VlkQJzi4GwEKAwDyy1EFOCALuwueCl7gq0uIpybF9cBgVC4LgBWWxRAOeywwzyfYi7dsHUWR7zBZTbNXTYKdJvISEGLrZV0+tDiBv/non/Sa3NfowAFgv6yjx/+OLWo2aLSezma0sZBAYIrNJgr3spcQvh0TphoOK8qJnaA87xx40ZlveJqZV7PU2oien5b3sLm68B+0KigiOuhW7jkOjiL7krAeYa5OiUH3SF7Au4Vtt7iOsl1cCa4DOca44yJPqe2WWh9hn3fRMlIQZvOoDBkL3hk+iP0/drvK5WwHXHYCJXRIBw2CFo9mAsTDLZm4fcJQQUBi8kk04Nekkmqzz7GmGjYChtu67Qq1wS5Bs5khmBXg0hb2JydI9Tnk7NXiOuBc1kJot0PfB3wXqQFw89sacR18FIOaJOCy3D+IW5nzZqlxn5kBsHuUTLBZV7BREGbKWScoGXn93S4HKzds5bumnwXLd25tOK7kI+u6nYVDe08lLJ8kW9w3ZfWFKIFc7Vo0UJdAw6IQYQyp6LCIaIqMhyJzCKWrX/s/weft0SyJsDfk7MmsPVWJvPo/ZuDwXBwlTScOyzQqvJJDg3A4XuB861ycBgOCQ6LPysBzhuqU1XlShB6HVB4APcVdjLgrsPFNTA+ie9+dGAB5+uAc4jxA2M9+i9+50RwmRcQlwPvknGVwgB81+655x467bTTUvZ/Tt04le6beh/tKtmlfq7hr0EP9H2Ajmp6VJV/++OPP9IhhxyiBmcvEy2YC0e4CYEnkU2bNqmBEM+5Shb820yueuQE+iQLwYPz6tYky1kTOHk/JiQWt7gmJk06bqCn68L1cCpdV1X/D6xZvOCT9F2RXQm4rzqVjzbS4tGk0sipXkzwoo4zfISiB5fh4MqdJi0aMFdhd3HAgAFkkgDPy4CA04wUtIceeigNGzaMzjrrLNf/L5zefy35F708+2Uqpwora6uarejxIx6nNrXaxPQZP/30k0ofw76QXkEP5sKBAQ2TOgYlTpkTbzAXrLoYHDFoYNLCoMgDZKZYqvRtUEym6dgG1V0T8B0Ap6LCd8iEgDKOlmeLEyyynJM0VZbTaAUWcGRKdopIrgQs8t0WQdHce3A/ZEpmBA445TEaiwl9d40rccb6WXpwGca8VFQuSxYEF8LSfOSRR5IJlJeXq74Zz7UxlYwUtEcccQRdddVVdP7557v6/xSWFtLjMx6nMavHBF8b0GSAsszWzKkZ8+dMnDiRevTooVa/6b4xMLHoVlg9mAsDm5PBXPhsfRLD57Ll1qbgDb0uPA4IJwzkqay3XtX3Y9cEHBBVtrom6JXZcKS75G2svrqcFswWUaVb//iewNjCwindWQnQTzjwDwdbb7mveFGIJQruAV7c4gB8HdBep/qcHlyGvo1rrgeXOVW5LFngG7xy5Uo6/PDDyQTKRdDazbHHHksXXnghXXzxxa79H+v3rqe7J99Ni3YsCr52eZfL6cquV0b1lw3HpEmTgjXbU020ylxsmUiFzytvM7JrAgsNFlWm+d3qQUEsnPRE8F52teCE66GuCVzQId0TTjzoCeS5yAQXqPB69gE9qwWLKt265eU+lE5XAjf7EFtvsfDRS8iattBAX9L9YdEeGBJwLVLhfoRzqlcuw6NeuQznFjsk6bg3kaEEvu79++9Pr+llykXQ2s0JJ5xAZ5xxBl1xxRWufP6Mghl075R7aXtxRSBUtexqdN9h99HA5gMT+rzJkycHo3TTGczFk2W6K3PxVjCLW1hy2GKIQdeLE5+e7J23r0OTvXtVONnmmqCn1WLhZLpvpL4lzr7Wug+0V9OzhfoLQ/il0pXAzYVGuCImXnYT0f1hMT5BMOr+sOn8zrxoYPcE3L/oG/qCJ1XBZWvWrFFW2r59+5IJlIugtZvBgwfToEGD6JprrnH8pvvvsv/Sc7Oeo7JARfLuZjWa0ROHP0Ht67RP+HOnTJlCHTp0UAOiWyvh0GAuDGY8CKd7yzuWbVgWt/j+vDUJcZvO2uuc0J0na7Yqx1KO00SiuSbgSKfFEKKCrcro57Domy6cYtkBYAsbVyTTI8vTgdddCdwgUplptt6ma9EX7n7V/WHT7V4TjUjBZXo+YbfuaVhnkQUDBTlMoKysTPUx8aG1lDPPPFP5v9x4442OfWZRWRE99dtTNHrl6OBr/Rr1U5W/aucmF8w1depUld8PN6pXg7m8NJHrW2VoF4tbt62gevol/N94DssGb5GZaoU10TUhUlotFnTpXOikGr1f4sBzTguWChHJrgTcF0xxJXBzd4AXuliM69vobvdLth7zGIlro/vDenFHxWvBZatWrVLnDsHlJlAmgtZuzjvvPBVkddtttznyeQX7Cuien++hudvmBl/7U6c/0bXdr6VsX/KrxGnTpql62hBlTgRz4RFWWbeCubxCuMEbgxvOo1ODN86rboXF4KpnJMikyTpWi6Fbrgl8LXgx41ZaLdPhyZ+t1Vhk6efJqWsRmnqMhZONFvFE4W10DvLjnQMn4xNCM0TgM3V/WNsW2Rjn2b2Lg8swDrP1lvt4ogJ3xYoVag5FKk1T5sGcnJyMGP8yMsvBH//4R2XxHD58eNKfNXvLbCVmtxRtUT/nZefRPX3uoRNbnkhO8csvv6ja8Ej+HW8wFwYwbM1wMBfnhM20fJbhttdwDjCwx2Ml4s/hSUhP5I6B0qu+il5M/cOTbKKuCbpfMl8LLMxYnJkWoJYOoqUFw7WI1bKl+/CyD6btrgRubaPzIgDXItFdhXBuWCxiM213wungsuXLl6v+3bt3bzKBMhG0dnPZZZepm/u+++5L6nM+W/6ZcjMoDZSqn5tUb0KPH/44darbiZxk+vTpqmZ2kyZNEgrm0n02RWyFD4DAhMsDfmgABFu0eMWPQVJf7XvZ18w21wQ92T0O/KxfC7GIO+vvib7NgirUL1FcCVKToo2FGAfMsR++bpBgtxIe0zhQlsc0uS9iCy7j8Tya4F+6dKny2UVueBMoE0FrNwgGg2gZNWpUQn9fUl5Cz858lj5Z/knwtT4N+9DD/R6munl1yWlmzJihrLPNmjWrNJmwP5apwVxeTVED/2G2tGLgghUQ55WFU7rSxWQCeiAduyagH2OLEAs2vVIaiyy5FqlLC4ZrgQkf1wnXQlwJUm9Jx/0BwYpxCIs/3o3joFN2qcqkHTgngsvQ1zHWVxVctmTJErXYQMVRU/pOTk6Okf7R8WJ/C8OAi4sBORG2Fm5VKblmbpkZfO389ufTjT1vJH+WO6cTq0WsLDmYi1eWejAX6pZjlWlqMFc64eT0fK7ZvxDnHOcSE0TTpk0dTSIuhIf9+3gA5m1TnHf0fwgqLO7SnTUhE2CLIM4zLHyI7GZLIa4FXmcLoLjauAvGIYzvEFs4UK2KBRjGqdB0ZyJmY+/jbPHGeWQ/fByzZs2qlB+cXXDwvkxx2TCNjPShRTAYLG9PPfVUXH83b+s8uvvnu6mgsKJaSm5WLt15yJ10autTXfmeHMyFGwvPsb2aCcFcqUCPgsfghYkB51W3wgIu84gDFkL2u423zKMQW1otduvg0qZ6aiPsQHBQGZdF5klc/GWdtwZyvw+XlSDUkq674aQzLZiNwBrI10IvB45xCOKWfdL5erD1Vh/LRIAlHlzGApeDy3AuIWzhcuCFymVVUZ5BFtqMFLR33nmnGhyef/75mP/mq5Vf0RMznqDi8mL1c8P8hvTY4Y9Rt4O6OfrdwgVzYXKAkMWNg0EMFioMVmKJjQ/2MWbRxFHwvEKPNgnr+TNxcOoj3e9WiB0OrmPf2XjTaoVzTfB6QQcvE1pmml0J+P6ItmjW/TfxGTAWcFowHBIQllgpbN6dwHPsTPBYU9XORLhxTvczl8VG/HMyrgN2KHiswfiCe8YLlcuqolwErd2MGDFCpd545ZVXqnxvaXkpvTj7Rfpw6YfB13rV70WP9H+E6ufXd6SzYQXOOWEhYjEAhQZz4TX8DoMcDtxM+B2LW5nAw5/bcJYLfQBKdHUNqxWLW1w3XCN9+9Xrq/Z0iia2igMnJlq2trOg4gwWLHDFNSFycIweGMlZCZK16uHeiFb9S8aq8H2Yx3dcD9wr7A+bTGlvvjd4DOR4AD0ri4xVkfOZ43rg3PH4joN375INLkulIM/Nzc2I+y4jLbQPPfQQzZ49m954442o79tetJ1GTB1Bvxb8Gnzt7LZn07CDh1FOVuIDTKRgLk6nVVUwl176FQcEsWyFV4BzoVsnokUGu1X+FYOY5Nzcb2liccOiye20WuKaEJ8rAYtNNyLh9UwIXEhALzOcyTsbPG5gDMe5SUWAnZ6xhXM163mzMzljCyzbLGLRZznVGVecdDq4LFWUiaC1m8cff1yVk33nnXcivmfR9kV018930Ya9G9TPfp+fbut9G53V9qy4/79owVycQDuZYC58Notb3Ei8PYXD9nQtPGHyIK3nbsS5TfV2ZzjRwIIhE3wLw6XV0iv2pLo/smtC6GLDySICtroSuJ0WjHc2+P7IhIwVLJp4Z4dT1LHlL9VjVWhlQ86pnSnZXNiNDHMn2o+5I9mg09DgMlzncMFlqbrWZSJo7QbBYN9//z3961//Cvv7b1d/S4/++qgqZwvq59WnRw9/lHrW7+n5ylwYMFncQlhxflUctlhDOD8jBwdBlOi10b0S4csWSp7AOPCMXRMwqNlAOJHCoslLIiWSawJ/V1tcE0JzLLNVHO30UoAQJlrdDzrdix830H3v2eCAMYBFrJfGgHBVD3lnC9fDBuut7p+MA9eGRSauhxsGh0jBZU5VLovl/8/Nzc2I4PGMdDl48cUX6X//+x/997//rfR6WaCMXp37Kr236L3ga93qdVPBXw2rNYz6mRiMscLzUmUuzq/KfkD4PixuTQrU0Cvo6PXPeUAwpfJNqHWGo5W9JjQyZRtZ9yFF/+LrYVqVMX1XANeDs3G46UqQCvcUU7NY8MKJ/WGxS8OiyZTsKBzox+KWi8/wmGtSmjYOQEVQF64H5kUugY7HVO7SuFG5rCrKRdDazWuvvUYffPCBErXMzuKd9MDUB2jKpinB14a0HkK3975dlbNNNJjLKzc9biIMTuyvxfk+ceA7e22yYKsf3/Rc41xP42QyoVvhWD3r/nNe6TfhIqfxGC6tlk3XA+ffy4FM4VwJ9CwPpltj3AogdNvazOMr4PvZhuuhb6FzmjbebfSiNR3zM+YNXlTg+7IV1i3/ZKcrl/H4mmxwWbkIWrt58803VUDYV199pX5etnMZDZ88nNbuWat+zvZl0y29bqFz250b7EihwVx4hKCNJ5jLaxWA+GYHLG7TJab43PIkBgHFPsZs9fOa6Hba4snWQs5gwdbCdLhQ6Gm1Mi0Vk+6agIODLtPtmsBb17geWEB71ZUgVX2Rr0e6dmg4mwPGUd3VBuOoSRblRK23LG7xnK3pXKEyXXOIbrTBXKwbbbxmJIi2G8kW3GSDywKBgBrPIIpN0CbJkpEuB++//z49++yzNG7cOBq/djw99MtDtK9sn/pd3dy69HD/h1Up29BgLgyqsAw4Fczlte1KTgfGg7KbQTP6ypR9YbliF59br1nFUgFnsODrwTXZ2drjppgKlyyfBawXrWKpAteAraG6a4LbQjKcK4HeF7xmFfNSEY5UBRGxsLYpRiHRcYPHDg6A4nHD7TErWnotkxcVkYLL2L0rluCygAha+/noo4/o4UcepqGvDaU3F7wZfL1TnU40ss9IyivMCxvMxZZCW1c6bqcD02vD4wblCZono1RGfppaJYhzheJI1koazW/RND85W1wT9K320M+3Yes6Vf7cbL1NNuiKrcMsmtg/me9BGwKl3JhDWNzi3OEasLh1Ikg0Wnot23fy4g0uC4igtZ8PPvmAHv71YfJ12H/hj6hzBJ1T8xwq2lOU9mAur5BsOjCO8NWtsJymh62wMkEn5jfJftA8scY6UfCign0vYQEILWsqxGdB5WuiW1DjcU0IdSUwNVjQC/B4wwtAXANeoMV7j/DWNe9USBW6xMYs3YgBA5FuxIj1HsFuHs9FnF6L5yJbspMkElzGKSvZQFRHq7bIY4e4HFjM8h3L6dKPL6XtWdvVz4HyAHXb3I1uOfIWTwZzec1SWFU6MD2CE4MYBBMPYDi/YoV1J+gBP+t+t7qlUE+rhQEQViV9kpdFRWpdE0KFsFd8dDM5JzLGKX2xiHuEr50p/pdeR98R4mxA+oJDj0EJl16LdwvdSq9lMjhf+35PZ8mB6jiXmG8XLVpEQ4cOzYhzlhE+tJjQf/jhB/rmm29ozKIxlHVeFvmyfFS2p4zqTahH1w+5nk455RSZSBJIB4YbCIMSJgdYNmDJxc9eqJCSKXCQhu53CwEFUYtBTlw7Uk+o6wDELC/8MFF7PYuCbeg5oTmQCZM9hCuuFX6HRTqLWFNSAZpM6IID8wqnrMKcjd+nOzjWRHbu3ElffvmlSkv63XffqUIRyLvftm1bsh1PLDtfeuklatOmjRJF/fv3p6lTpzr22b/99puaMK6//no1sT9y9SN0bY9rqUPdDvRot0epX+N+NGLECPX/X3rppaoTYHATIoMBBxMwJgMcOK8QURCznF4LN5H4/KUGTLy4d7CQYB9vXA/0YwhaTM5YWOA+EAt5asB9gMkZB84/RBNfEyz88Br/XsRsau4RnHN2dcK9AtGE64HrgnsG9w4vzkXMug8H82Gu4F0lzCG4HsgggXsIVkU8ioU8+mINO2/vvvsunXfeeUrL/OUvf6HevXvTxIkTadmyZRkhZj1hof33v/+thOSrr76qxCyyDyBoa+HChWprIVmw6luxYgV17NgxOEihyYVlhVTNXy24BfjLL78oMfvJJ5/QmjVr6MQTT6QzzzyTBg8enPF+bOG2iiCM2ArLW0Wh6cC4zGg604HZip74XK9CFppWi7dSOQpYtlLdI1ZXAvGZTb1rDgdWYowKdc3hErBsvcX10e8lsda6k16Lr0m49Friulb1+I/xfPTo0fTpp5/S+PHjqWvXrnT22WfTH/7wB+revXtGLsrSLmghYvv27auqdwEMLi1btqSbbrqJ7rrrrpR/H/z/s2bNov/85z9K3C5dupSOP/54JW6HDBmiLF2Z0FHCOfPribSr8vPjyZ3FLW8fuZ0OzGbCpdXi64GjKh+paMEueBTXkNRnPZCsBt4LntTTguHa4r7iSl8SyJr69Fqc4lGPA9Cj+zPlmuA8oNoZCkJ99tlnNGHCBDr44IPpnHPOUSK2o2a0y1TSKmhhPYKlD+LxrLPOCr4OB2aIIVy0dIJTM3/+fPX9Pv74Y5o3bx4de+yxStyefvrp6maypQO5mW4l1McTlit8JpcezARn9UTgLBE8OTuZVkvSEXkvL63knfVeejtOdM/iGFvhoakGhfDgXHFQl5PptXhhznOVHiOAecWm1F0Yp9euXau0EI6ff/6Z+vXrpyyx5557rnIlsKWtxgvadevWUfPmzWnSpEl0xBFHBF+/8847VRDXlCn7y9CmG5ymJUuWBC23M2bMoCOPPFIJ8TPOOIOaNGliXMfSLX54TFVCbA7O4HRgGIw4122mp43Sc/VyoIQeme3WNeEUbbgukjDeG5XD2DWBFzOZUhks3gIkODCmuF2AJJylkKP0JRtCetJr2VigB22CmySL2OnTp9OAAQOUJRZHixYtMvrej4YI2gQ73MqVK5XPLSy3EN5wnYCwhfUWLhNe7HBeLFkYazowm+F0K2xdwoDMYikdWSJgWWGhkEklPWNxJdATmKf6+3AfwffKxIILXisRzX6e3EewGNVdgDKh6EK09FrpKDyhl1DHfcKFNrxeQh3ncfHixUrAwid2zpw5ajcYAhbWWBMNZulAXA4c2hKAsMWBqEJEF8JyC3Gb7i0BvXQe+03qVlgvWUT1dGD4vtjOY3GbbFUsrwYP4YCFAYsJngi9NOhigsC1YL9bfC9bhRSuA4sl3ZWA3Tu8ck101wQcWIDwPW3bLsf/t3cewFKW1/9/80s1k8QxiZEoTRQVQRSQqAgWRLBOxoldkdgSlaKoIMYSwd7QgCgklsQukMlMIk2qiqKAFAuCCgKCbVJMTNREk/3P5yRn/w/r7u27+z77fj8z6/Xu7r3sfcvzfJ/znPM9iMYwH5brzaOwaSo09Yhx2HWPMasWI+qeruQilnE7TCFLk72WR9S9daznQ/ujmtFbT2lkx5e8WPxiqdchHxbtwOerlWsmU0Vh5ISMHz8+P1i3bds2GTJkSFWKwloiaZsVFtFb0iZ23313uzgRuLvsskvZL9Cw+t2jsAysvkKNpZ2pT2QupBgkw0rY2G50REeY3hFG/GLpRldMSMWcCx2mEvjCIhSGsTQ4qLXUBG9t6jsEvrCN6e9xdxG/330h6IGEGO73YtFxF7HMM6GDTQwLW8+H9nPigQSfGyvhZuFF52gEorEbN25M+vfvb5FYdnhjnNvSRCpsuygCmzRpkglbbLsmT56crF692vzpYoXDymDMhUvkds6cOcnOO++cF7cI3ZYSlj54eiQ2rH7na+xbXzHagRVbWDAZF7YljL1gzc+J24b5eUlrsUyYSsBX8ChstSM25UpNCP++NAqPsOlBeC25iE3rtdTYhZOPBaEtGH9fmnZkGmuvVQupXh695d4PnRNaatHB+ScP1iOxjJk0ckLEHnXUUTYXiBoRtIBlF0bA7777rm3Xjxs3ziK3tYJv0fzhD3+wyO0TTzxhid0ubrHeaMzg4L/Pb0aPwoZiKfbBpiF2YDzcVzINdmCh1RkPPmstLSwaG1VzcVtt8e6pBF40ktZUgiylJhT7bGE+bGzR/qYIKW9T6kVMabCgao69Vsx4BNp30RgzfNHB2N3YlDfmJWprCGghYpmvsf1ExOJtn5X6kEwK2qxBBGLatGkWuZ0xY4bdNGw3IG7x5C0mRgu3rLm5wlygWhZL9UVBXdwiqnwLnImx3Nt6oa0WD284EfYmr9WFRWPyHj1KWKmIeq2kEpSjGrwwNcEFZCXECtdFWGjn2/AxbVuXs4gptAUrt4NGJey1Yl90+HzLooNrMwxOFJtbuL6poSESS8MD5iPsPbHXolFTFsedSiNBW2WYZGbOnGmRW0QuK0FuArYiuKkQvFiE0Z7XC4e4oWo5stRUfNuSPGa38WlpO7BwAuLfqpStVi1VppejgKSw6QSExWuxpxKUq1i03KkJxRwz/J7UGFZ60VHuBXKl7bVixncTfHzh/Fx//fVJnz59LHWAe4ndV0QssPNKYRcFXrW805BGJGhTBD2XySFmi4JkcSA1AXHrglY0zQ6MCJRHCRu73ZPmLcJYLX7cO5Rr2gVOYyfSYqkELmIllpqfmuACt7GLwWKexn6OtdXavBQmPy+N3ZVLm71WzOCfP2bMmGT27NmWJskig/oYIrHDhg0ziy1RHSRoU8CDDz6Y3HLLLeY9R2EcOTb9+vWzSZocHB4MZghb0hIOOuggrfzKaAdWrIgjrbZatdTdiePqE2x954WfQdCW20w/axTrTldfakJo48TPsNXqYikrfqxpLDIttNfyPOU02mulHcYaBCxzMTuqLJiZi3kgaKmL4XkcDPbee+/kyiuvTI444ohqf+zMIUGbAubNm2cDDvYdTATFcnOefvrpZMqUKWb1wc3FzcLWBsK3lnwnq2UHhjDybetasNmJuTrfz4t7jboXrlIJ0pOawD2BYPLXICzO1K5FZW3BOC+e48nY5a4wnCOdl6bXuiBSmXMRrLguectZitaLpX+88847yaxZs5IuXbqYsBWVRYI2MpjcaRVMzi3J50S2yONB3CKItaXXMBjo2WbdtGmTTQgIKiYColHf//73LdVDk3J1YGuUlBsvkgGi6mzl4VGtxUV1IPLKeUEoEV0HFtNM9HRHVIS8OjB2cV6oHSC6DkTFEbKMY7XUlKaceER7+vTpJmKx2mS8wZmAnNhu3bplssg3JiRoI4Yt2CVLliRTp041ccvqkGpKtkEQufK3K15l7ZGNsFUlkUAm6WJ2YHyVuC3/4qJYKgHH3q2EeHjun6cZaHeivHjxkKcfII782INHaMPUhFIpI6LldzR8pym012LBF1boK+e/fr94CroQsfPnz7cGSC5iibTqOo4HCdoaErcrVqywyC12YG+++aalI2AHhv9dFjuQeNWwC1ii2Q2pGq62HVgWKLRwaqgrQWF7WrcY4ucq0emn1gmvfV9cNGQBUbgFzgLQz6cKJ6tnr+VNafzcyJXlv9c4xxBnAnJiSefr2rWriVjSCXbddVeNI5EiQVujN+wrr7ySj9zSL5pCMiK3FJbVco/oUr6OLmKb0nUorA4ulx1YFvDFhReBefOFproSuIjy/E4iUf77suoB3NTFcNiJj++b06zErdr83DTXNSHLtKS9Vinf7LDJSK3eM/ztuBMQhcVFiLQ9clxdxHbo0KFm58QsIUFb43Ajv/766yZuidyuXLky6d27t+XcEr0l/y32G5kIqk+e5d5i88p88tXYZpUlUf05aR5RLUwlaMmWph6J8n/Li2HkQ1v/ljWLAS/Ca+m2pi6iPBKv1IT02Gu5LZgvCMPOhrXQrY1juWHDBhOxPJYuXZr06tUrX9hF3nfsc5/YEgnaDMENTiqCF5QtXrw42XfffU3c8thhhx2iuMHr6o3Oo1Jbz0QIParFhOAtVVkkZHX7u6mpBOUU0ixCwm3zrNpIFXoze84rYqmS94zftzy4HrKempAGe626bMFiyonm73jjjTfyIhYbLRogIGARshT8xvB3iKYhQZtRuPGp8Cdqy4OWfT169MiL2/bt26fqxi+0D0qbrVYpOzCPeKXpWJbbT7a5qQQtjUcIvXjJm2y40X+1P18573Gsh1zEchzS5NsbdpHz9KCspCb43+4ilu99cZEG2zNvte67XkTsw/E2TZ33uM5Jq3MRu3r1auvSRToBItatzETtI0ErbECg4wlRW8Ttk08+adWd5Nwibjt27FjxAcEn4zBiQBTJB9U0CKX6tr89esvn9MmKCTv2PLVKphKUs8mGt2IN827Tek01p9WwR9nSbKafhdQE91P2cQHR6qkE3D9pHRe8k5yPxdzv3miGz879Xulzw2eiERFzFjmx7Dzi8EMkllQ6jmfs14toPBK0YgvcLJ2VLnm3NH3AxoRBgtVup06dyjZQEOUMK3L53nO6+BpjxMYng9jtwOpKJUCkp1UoNURguPgDF1BpiJI1FG8I4vmwfG7/O2JdQBUW/MWamlBor0W6CylJ3P/FunvFtCPjzjHuecsDIVmuc8NYumzZMnMmYH4iCIM9JZFYip0JcohsI0Er6vUHZQVM5JZuKRhNE7VF3O6xxx7NnizDqlsGR7ZBw8Exxsm4FLHZgZVKJXDLsxgn4/qudY/esuXKuXFhmLYCGa4d/6zeNtg/a6xCqSmpCWnMiXZ7Ld+ub4i9VqzUZQvWEi4w/H5qPRCxzENcB3TJJBJLi3iOrRCOBK1oMIgxDKgRt7QEZIBG3JKaQP5tQ8RnoaWP931vjq1WjPgWa5rswGJOJSjX9jfnhtQXoj9h3m01PhPXiH8m/p9FhX+mLJ4bHoxJpCNUu3ipJe21YqVY2gj3Spgm1pA5gt0gbLUQsXjFcmyJwCJi6YaZlWtdNB4JWtEkGLhmzJhh4nbatGk2WJGWgLilz3W47URbRgYy71zj24eeShDL9mE5CbuUhXZgPMo5gJdKJUhLsV2abOE877ZSRW+eruIilu1rjxrXgq1SzKkJlbTXihVPhfHoLceM65dFMucJx4HwvU899VRexHLtEywhneCQQw6JMt1MVB4JWtEiYmz27NlmB8ZgxGD+gx/8wMQSFafr16+3fFxycStpqxUrbgfmAoooh4vbljh2WUolKOcCgAcLtTBftbkCqty/v5YpLIjjPvIc/JYQmWmw14o93YrzQvR18ODB1pFr5513trSCRYsW2QKNgAgtZw888EAdT9FoJGhFi8AkPGvWLBO0RGyZTBjEGKTIeTrllFOSAw44QFGlJhaVeATVu2FRWNLQ6GCYSuC+vd49LUvb1ZWMoDYmJxph5CKMBQxb1GmyPctyakLa7bVig/SBOXPmJA899JB95TxxTNmRYoeP1ALcCjg/QjQWCVrRbO68885k2LBhViSGeD3yyCMt7YDBCgsworNsJTFx8xpbSfgEahupfHZgSiWoLOEWNOcmzHEtzKMsJraqnaObxdQE35UojHwX2mtxX/kOSa0VqlYC7gWCHcwBFBZz3D0SS2MfxqqFCxdaIITHunXrksmTJ9t7hGgMErSi2bzzzju2yqbTWCmYJGje4OKWiCGWKwxarMgVJWy+HRiTLVFBcj49lcAjUrXcpz2NFLoQcC6IQgHnLe0uCllLTeDeISWB/2fR6PZaipI3Pb1g+vTpZq9FJLZ169aWD0thV30FxGvXrrXzwUJDiMYgQSuqMqE8//zz+Ra87733nlWvErlF5Gq7qWGEqQQcQ3JjmSh4ngmBoos02oFlBY/0cW44R1z34Ib6rVq1UsQvBfZaLMi5j7h3OEdEyN0rVvn+DYMxh0UCLjiIWPzLaciDvSOR2JaweBSiPiRoy8iECROSm2++2Qyg99xzz2T8+PFWLCX+P0wgy5cvt8gt4nbDhg1Jv379TNySvqAcwi3xVALfPmUi8Sist6RMmx1YlvBOZL61TaTPjz0pCD7x+3Y2ojd0LtDiozL2Wt4G2e21PJe8MDWB8+GuHyrK2xKuZY4TdROIWFwKOnfubJFYROxuu+2m/O8SSBuUBwnaMvHYY48lp512WjJx4kTLJ7399tuTKVOmJGvWrLEBVBQfIGlniLjFDuy1115LDj74YBO3FAswoWSxQCbszOO2UQ1NJaiWHViWCPNh/RiH+bClrllv7+wCy4v1PO9Wi4/q2muVck3wxUcWrbk4nkS0aXJA6hjuBN27d89HYnfaaadMjtGNQdqgfEjQlglEbM+ePZM77rgjPzi2adMmGTp0aDJq1Khy/bM1NXAi/klLQNy++OKLSZ8+fSzn9uijj7YJqVYHTk8l8EgRk3BLmOh7NX257MCyQrEGFGE+bFOFji9cOD/k2XJOXNzq/FTXXsuFsd+T7prg57yWzw9/+1tvvZXv1kXnLoq5iMTyaNeuXc3+7eVA2qB8SNCWAVbyiA4ijWGl5qBBg2yiYntGNG5ApfLVxe3SpUuTXr16WeQWq5ftt98++gE1TCXgwQIoTCVo6a3osMc8/y5FSS5uleZR2mHCRazbN4WpHi19fsLUBc6/i1sWN8pHLG2v5akc5bTXKpWawL9JlD12Oy8fc5mrELIrV65MevfunRextTDmVgNpg/IiQVsG3n77bav4x0B6v/32yz8/cuRIs7GiIEo0L1rgBWUcYyLh3qWsbdu20Qy0ROR8UizmP1op0eLFSy6g3A6Mwpgs2xQVE5VhPmwlz0+hmPaIYznEdCykxV6L88H58XuZ64a0hthSE3xXzEXsq6++mhx00EHmTMDYWsu7YpVC2qC8ZHMkFNHCgIpoHT58eHLBBRdYPhfCFoF75ZVXJl27drXBl+ht2vK56koloGtOtfxHveqeR2gH9sorr+QjXT45xx55qg/POeb8+LY/x6VDhw5V21bmmPs5cEskPiP2Ri+99FKmWq6GOwt89aK7bt26VW1nAeHshWPcx56asHnzZhOF5FR79DZtqQnc76tWrbIxlHQCrimKcs8//3wbQ7NatyDiRBHaMqBthcrDRM8kwsBMWgK2MVTZMijz6NSpU1UGZlIJPLpWiVSCliQUTzzwVuUzu3hK82dvSkvOsDArFleIwoI08jr9s9dVkBYTMed+89k9jSgtqQmMQStWrLAoLNFYhPeAAQMsEkt9AgsDUR6kDcqLBG0ZE7+x6MKqywcRIotDhgxRUViZcWskIg5EbmfPnp3suOOOJmyJ3nbp0qWs25FhKgGfA1HkEc5Y8x+9u1UxOzAeMUUGi+VbulBP+yKjsZZhYd5tmoVffe4c3kktZncOrjOuu2qkJvBvL1myxEQsNlt8BmwRyYflq7y/K4e0QfmQoC2jNQdFYJMmTTJhi20X7fxWr15tuYmicjAhYvhN5HbmzJnWcICcW6xm2KpsrsAsFuXzVAImqlpsZRqbHZhvVbvY8zQLj5TFuMhoTF40hEVsaUsd8QWTN6FgwVTLqRTumuA7N4wfLZ2awO4QtlpEYVncc3xpPU4klohsGu/TLCBtUD4kaMsIll3eWGGvvfZKxo0bZ6szUT2YRGbMmGGRW1ozMmmyzUbkloVHQyf6UqkE/og1yldLW8KkSHgUlsgYn8ujfESkYopYNlc8kQ/s4pbjEorFarXdLex01xL2WrFSV2oC56qhCy4WbgsXLrTUKxbxfM/OFJFYcmPTnkKTFaQNyoMErcgs+Ic+8cQTJm4Z/IlYuLjFFqywehwx7EbrtZJKUKminUrYgYUm+h7l47y4cMNFIut4JNTFrUcGw7zbWrLXijk1wRfL3FMcG8YZdhMKI6uI4QULFlgklnGMY8gYRiSWxjRZWhiIbCNBK8T/onlz5861tAQmBiYFtud23nln82OcP3++/f9ll12Wj5zUYipBuWyVmJgRsy1tqxS6MnheoufDZi3K15zoeinruJZYgKTFXqsWUhMuvvhiW4hjp9W6dWtzuWDHiZ0QIrHHHXec+cVm1cpNZBsJWiECmDzIs73nnnvMMxihy+RAru1Pf/pTy7uttXy+ShC2EUXYNCcy500oXCgjiFyEsT2rKF/T8OPq4sn9iJtyXKsZqa9ViKw//PDDNjZhqYfYRciSSnDmmWfaV41NIstI0Arxv8mcbbo5c+aYGwWpBzxo8UgjDLq+USH84YcfJocffrhFQ5hAVFjRMnZgHvUuZQfGe1wgEUnkuLsgZstcAqll8ci3R289v9VTbIrl3aY1lzpmuE+IwDL2MDbRoYtF9bHHHmtuLSy6cS3gwYKRCO19991X7Y8tRFWQoBXifzzwwANWGIY5eqlJ/rnnnsuLWybv/v37mxCmaphJW7SMHZhXelM0xPMsJGrBuin2bW8enAt38aC4ju9jcbuIpYCPXFicCUiDoqkHRV3kxO65555FUzT4uRdffDF544037H1CZBEJWiGaAOL2hRdesIIy8m43bdpkEVvELRFcRQ0bD5My1e4cS8QRx5h0D7a727dvL8P3lJwjoq8bN260iCCpI6QiIHDbtGlj0VtFYht/TNl9QMSyUCbquvvuu+cjsfy/jqkQ9aNs/Ixx1VVX2eAYPuioFW7tDh482PIaiZCx2kdkhDCZUTBFBIZIzIgRI2zLPksQJenZs2dyww03mLcwfo9ET8aOHWvii62/+++/3yZ/JixRHAQR0b2XX37ZJnKOJYVJe+yxR7L//vsnHTt2zJvCc4xpzUlEUMe08vZar7/+evLss89alymuf7rvcY4QXKSJcA6ffvppa/fqVnai9DGlbTc+5V58+utf/zrp27ev5ccuX748+fnPf5507tw5U2JW85NoDiqFzCAMkuRjOWFF7PDhw5Np06YlU6ZMsYgYnc3Y7nrmmWfsdcQFA3CrVq1scmNQPu2002xCu+6665IswuSOmOUxZswYE2WkJTBZDRs2LDnggAMs55acXLZpszRB1dXRynMt3f4Mr+bCgiEWTVRzh0VG69evV5FRlYr4WGAUFvFxjhgPwp9B1HLOwrzbrDtOIGLZffBGB6Qv4UtOJJa8VxbCWR8bQPOTaCpKOcgYrIDZ1iLKUghRGCYfKmnZ6gLEGZEYomMUSFGgcNRRRyVvv/12vuPZxIkTk0suucQmsmqZtKd1AiOiiLjF6HzZsmXmb4u4pVMZHcuyMoG59yniyL1PPR+2sfZnsoFKv80a1z6RdD/nnH/Pjc6SJzDH4c033zQRy4PIK2MAO18ECnbYYYfMjAENQfOTaA6K0GYQtg6pliUytt9++yXXX3+9VfaTE0pUhVxQh3QEXnNBy1e2g8P2vRREnXvuubZVhr2V+C9MVGwljho1ygT/hg0bLN+Wx8iRI60ADXHLg/zDWprYwi5Q7mRALizXHZHs5tgLedtaHqFRP9efjPobRxj5RsyyIOW4FouWNwZ+jkULj5122slaJXtR2WuvvWbpTC5ua61rG9c+fyMCluAB1yW+saeffrpFZhk7a+nvbWk0P4mmIkGbMdjiIleLSn7SBUaPHp306dPH8t9o0cuERoFHCAMwrwFfQzHrr/trojhMYGwpXnjhhZbWQYTbxe3ll19uAsLFLVXNMU54iElvB8yDiZ2tZkQ929TlMHsnasjv5sHiy+3AEBRZbqVaF6XstbjuymWvRUSWhTEPF9F8BhZ5nBcXt7E2WmBhRZoFAhYhiyg75JBDkqFDh9o9zfUZ4z1daTQ/ieYgQZsxqMB3unbtagNIu3btksmTJ2dmG7DaMLGx1chkR44ywoKJEMcEcnBJ8cAtgYmQhUeaJ0LyYV2cuIE+woQofqXbAXOciCryQESzzU1BI6KJKBkRYk9zyJoBPRFSt0YL7bVYBFTaXgsBS7oND4QgotoLA/k+zLtNc8crPitWWaQTEXl96623zMaP3RdSitRAovFofhLNIb2jhagIiI5ddtnF/AsPPfRQEyj4IIZRWkQBRR/A18WLF2/xO9wFwd8jGifCiHDThewnP/mJTe5EeIjc3njjjbZdy+RI4QjV5GmIXtF60yN8iCO2jBEg5YzwNRY+A5+Fh29583nZRVizZk3N+6WW8vflHmUhmxZBz/WMcOXhDTe4tmg3jcD1RQgPUqTSIGKXLl2aj8TyWRFh7HRRLMu9IFoOzU+iMUjQZhwmOgqXBg4cmPTo0cOiJ5h5uzk3kz82XeTaAl+vvfZamyQRAzB79mwTCAgu0TwRxtbkGWecYQ/EIh2AiNySg0dUl6gt0VtSFColbkOhwQOhhNBAHBGJTYPQqA92H9iJ4BFuubOQq5WOVqU6sLHNX6oDW5oojLD7wskXIb5w8kLCSp0nUmlwJEDEcj9yjBGvt9xyS3LYYYc1uqhRNBzNT6IxyOUgY1x88cVmH8XETh4nXoc4HqxatcomC4q7pk+fbnm2iFS2xQGLLh/cEVMU99x000022SCGzzrrrMzadlUCKsY5L0Ru+YpQ4TwSucUPt6XFrW8Fu4jlvMeyFdzUoihPmXBxG8OWsbeodRFbq0VxntrixWue2sKjHKktXBdYFbqIZRHETgkLfXayYljExYjmJ9EcJGgzxoknnpg89dRTNikwGfTu3dsirmzLAlGdiy66KHnkkUdsEMfB4M4779winYCcRITvggULLDoxaNAgazBQKyIn7RC5mjVrlkVu8QzmHDDZErklgt5UERMW6/C1Fop1atEOzD+nLzaaY68VI1586IsQLz70xVZTr39EM809yInlvuK4ck8hYg8++GBZElYAzU+iOUjQChExLEBokkHklpw+RCiRWyZiFiv1bTOHdkrYX9WynVJjCO3AePB9NSOfpey1YokkV9oezq/h+nKF/f6hqAsRS2oK9w6d/nB/0SJdiHiQoBWiRkD0zJ8/3yK3bJUSyaIJBnm35OAyuSPM8BJm8qfK3IuFsmZ43xTR5OK2UnZgRAz93wzttWLP9S0nXgiHwPUGHjQ2wC2EVCl/D3n/3CPsdJCygIilmQxND2olTUOIrCFBK0QN8tlnnyULFy60FsZM3Agy3BSI7iHISBMZMWKE/FmbIG5ZBLho4v+JCCIyGxIRbKq9Vq26MZQTL/772c9+ZhFYdhwQr5s3b7YCSzp1kU5Ag5NaT9MQIgso6VGIGs2zxU6NbXMiUhSxEH0lmug5mFRu45uZ9ur3NEFUFGHEg7xzr8SnSQltotn+99SEhgjQuuy1YnGQSCMcVxYH5MSSVoBgZbHBPcAxpcjS83ARvtqZECJ+FKEVogajs0RjSSnw7mN77723Teruozl16lQrfsHpgqpt3oOfJhFB0bIduApTBIrZa5GX61FeLTCaBseVhdrjjz9u+eSk39A4gkgs6QSdO3e2c8D9gYMB7+GBmMXzVggRNxK0QtQgoU9wKRC3K1eutJxbisows6ddJ+IWn022Z5Wn2TJ2YBRx+WKBVAK312puZX7WQcSyE4G1Fqk1pNnsueee+XQCmsbUdQ3z8yxC6rtXhBDpR4lDIgqwGqN6H/9bJigmr8KJ6corr7SoJBGXfv36WT/1EKJmp5xyigkLxNqZZ55pW7whtLKkupltyTZt2pjXbow0ZIImYtutW7fkmmuusdawL7zwguUTYtO24447msftfffdZxM+x1c0HKKsnAOuV76GlmiIWY+gI2glZhsH1+KmTZvsOqWxAaL10UcfTY444ghrwLBkyRLLm21I22i3PIsNjYdCfB4J2oxDlC4GyDMk8jJhwoSiryM8x40bl0ycODF5/vnnbbsXD122cx3ELMKNCme2JZkUaDfrsA1MTilNJxB3N998c3LVVVclv/zlL5Nah4mdLVkabSxfvtyOE96bNNigaxMRW44DjTQkbkvDdjb5tCyMyN9EYJG72b1796Rv3772oPUsx5ttbt7DV2+KIIrDNbd+/frkF7/4he0i0JWQqCxRWHYWaPxCkSPtl7Owq6DxUIjPo5SDjELEKNZcPSYs8j+x2vHJjkgYDSHoNANe1Y8gw6z71VdftUmQ6A35pDBz5kyL6hDt4efvuuuu5LLLLjPRxhYxjBo1yqLBFPxkWUiQlsAxZ7Gw7777WiMHUhNat26dCQFRDnutatmBxQLHh9bE5LlyD7700kvJAQccYCKW3QMK57J+7YHGQyH+iyK0GeWxxx5LDjzwwJJCLZbILeAziQglzcCh2nyfffYxz1XgK2kGLmaB97Ptjkjz9zBhupgForxE2XALyOpkSfoBCwXyExG3xx9/vJnQd+nSxaK4t99+u52DLEVuqaCnYx4LJCL9FNdh34WPKd3acECorzEFr3FNsmW+//77W7oHAphjTOR22bJltthC6GYFriHacNNGm+PIMeG+PO+88+wY0wSB/yddQ2K2OFkbD3EaOeOMM6zYVWQb2XZlECaNU089Nbnwwgst6kElMNud5PLxGhNFTL6MDN5ARDaE7/01vhbmytEFCBESvgfxVvg7/DXslLIM1wUR2WHDhiVDhw61YhyithSUkaqAwHVXhfqKcWKj3PZaxezA+HdCOzCP+taaxRSLZ8YhorBEY1ko4LzBjgs7ASpObBxZGw+xx+OzXH311Tav0eWNayqmOUy0DBK0GYTJ07tIYTjOAOA3P9vu5Kk9/PDDSY8ePar9UUWKryHE3Lnnnpucc845ZpeEGCE14frrr086duxowpat4U6dOkUpbkvZa7Vt27bs9lpM0u3bt7dHaAdGoaO3J0ZckN4Q47FFcBCBZkHEGMTiiAIvCjsZl2QfJ+rCAy/+dcyYMckdd9yRDB8+3BZDLIJE9pCgzSBMJkRj2YIaO3asPcdkedttt5mgJdpGgVAsIKyASZGtSIfvvd0l7+FvLCzgIefRf56v/EyIf+/vEZ+HCYWcT1wj2PojLxSRQuSWawq3CCJtiFsKotIcOeHe+OCDD/IiloUffxuFcdWy16KojMg4j9AOjNQEXvPILVHcNItbjiXb2YORYk8AABu3SURBVCx8uD44zuSw33jjjeaBjDgXzaeWx0OPvPp17l+5di655JLk7rvvNnFLLQSLT5Et0juziLLhgoKVLBMi1fyIkQULFiQPPvigpSLE1KGIbTEG2Llz5+afI7LG5EkeHvCVCRT3AmfevHk2QCLs/T3kQyIaHBwRsP9Jy/Za2vG80NNOO822kJkAR48ebeILBwm257FUWrx4cWrytBFaREBxduD8s/3NZ6OIkDxzPjPR0DTYaxEVRqTg+HHQQQdZagdFaThTPP3001b8SLQ8LccWkcQxJXLGfUT+NfcmjiRsWz/yyCPW9EBituWopfGQzxO6f/jcxfWOOwjXF3APAE4XdEDkbxHZQy4HGQcfUsz1zzrrLIvM0uMcfCunKZQjf4mcRSqe/TMTWaYgiZwvtoCJ8txwww3Jb37zGxvQr7jiCrNOosDExTlRIAQW1l4M0qeffroVRZBeAUQWGawRXqz2GTCJOBJlDO29RNMgBxVnCSK3FJWxrexuCTgnVFIwMhEWNj6IJdJZ6p6jUMcjy3zvLXiJVFXy2CIuEEIsaLDHYyzhHNPsgMKjsMhINI1aGg/5WyiCxBoQfDFWbA5hwXbyySdbYRq55owf1157bb4GhMJBgjMcg0mTJrXYZxRxIEGbQVysbt682Va0CA22Aet7f32v83vKFWkhesyAXcigQYPMmovPgCDHK5XIQ+/evc14nQiWw3bakCFDzL+SwRL7HyJF5CQ6DPqDBw+26nW2mCl+YjAXLQv5qER7yLll+5mdAhpnkJZAxT8FKuWy1yIaSxSzofZasVENOzDOJ61mEbEsVjif2OoRfSXKXY7zmWVqaTwk537t2rWW7lZ4bRKJxfGCBShClsUngpe6jwceeMDmLz5bWO/BDiOFlAha0p1EdpCgzSAuQGfMmGE+q1QTs0XMoFHXxONbP4XRHo/IMiBdfvnlZvHEoMKkVux9QhQKTcQQ4hZBxPVJYRCCCDHUnIge9lou7BB5RIVdxFJ4VetwLImA+TFg0UkUj7+fCG7hPdoYcGJgUcJimLGEyLaLWBYlaUjREOnF5wPmHtKUENPMMVyjiG/mKNJ//DpCwJIGQSGhOzRQfMpYQTTar2WELKIeMU+6kMgOWjZnEI9EMVgwgOAtCOEE5KKXFTITIe8pNUG5SGU1/+GHH9qAglekDzCICn4WYdKcVAZRm3BdcH3xYCIjF3TKlCnmoMC1w1YkW9Z0iGpIbnco4FraXis2StmBsTXbFDswjidpIyw8nnjiCcstRsTyHLmXWrCKhgYveJ3rkZ0SjwozT7DoJC2J63PkyJEWJAF2Vogqe74snHTSSeapThDF0+V69uxp36uQN3soXJZRNm7caFExVrhMSoVC0/+fyY9BBVHAQIFHZIib6SNm8czECJ3nwkT+6dOnm5Agx09iVtQFOwRspSJs33rrLUtHIAeUCYpcQPL8EFNMhOHkyRYs+XXPPPOMFb9QBEMeHRFetiPZesyamK3LDoz7tE+fPjbpk37BcaOYhvuYLVzPY/T0BYq3TjjhBPtZchbJreSYYyN26623WuRMYlY4fi34XFCqSJHrkdxcIrS8x+cTUgpY6IaNHxCvpE+QnhCmWHANcu87dH1EIKep+YOoDBK0GYXoKflUhYn4hRA1owPLrFmzrKq60KzbByCq1hG/RGvatWtnW5BAhI2Ef4QzojgUummpxBbphGgNogtfZLofcQ0iUvEq5RqjWIUiI0QW1fNMbNhrcZ1iT8TElvX2sfWNAQj97t27m/DnmHKvc1y5X7n3Ob48Tzc4io9YLBA5I+8RsSERK4qBqwkWfa+99prdx8WuE58LSIHBk5j3+JxAygr3rxe+AQVgLGTZNfR5h10HFrrkbZPHDVzDXKtyzsgeSjnIKAhThIJTV74booCIDo9CfKDC/oUBhG1ivAARskAVLYMNkbVSP+sVqkKUgmsFkcq2Iw+itCtWrLDrhmsN4UW6CyJNIqtpkXEXFDhOELHluFJQxkKUhQVtUOkqqF0WUQgCM/SIZZFJ0fHUqVNNsJJCQF0Fzgq+G+hjft++fZObbrppizmBa5A0GNLiELFEckmPYfFF8ATLN/fY5Xcicrle2YXh9/L7PQVBZAdFaDM+ADX3dwARG6JjrMgRtURunn322fxrRNfYGgIGG1btWMVg6ePPCVEX5NRSZX3++efbVxZJbIWzBUl0h8UWnYKI1mAPhV0RFdF+jYri9y+ig2JORAE7Nlg20ewAWyS2bEndeOihh+z9FO+wECbHWYhwHnGB6osdBCbXzzXXXGOBE9KIPCBSuCBisYRAJfrPay6MeT9zRZhOwLVJvnb4HCkvpL+4Ny6dCXlOZA+5HIgm4wMPFaUIVNwSyH3COQEfSkQttjCkH5D3xJYQFjH4BGIMz7YUP0++pKc+hLm8PlhK8AqEF5FZrptSEUKuFyZA3BKICOGvzGRJQRnblUR8sh5d5BiRP48zAQ/yZcl/ZRHAg5SOUseIexHRgWBQa1ERwr1JUx6EJh61dLWj9oLca8Z+rqtScF15ygu7e57rTqoaUV28b7EOA0Qy7gVnn322RX4Lr+2s399ZRxFa0fSLJ+jaQmUqgxLQYYk0ArYtWTkjcoEJFE/BgQMHWj4kkVvEBs95igIDEh1uCrel4N5777WoW+wg9vFcJUeMv5Ht85Af//jH9nz4oM99CNW+p5xyih13xAWLBCrQQyjwQdAxQZAr6dt6McL2ISkHdU1YvEaxEp3IEGpEGYk8PvrooxZ95BiyeEIcZylyy99KLiJRK+5RdlJwKOD6ocjTO3khKOo6vtzviN9Yxazuu6bt2vE8lo5+z4T3DuM8vrSkohDdp4Bz06ZN9vX++++3rywu6/o3ua5oVct8QQGx06tXLxv/GfM935YoLP9eoZgFiVkhQSuaBSkFDEQk57s3IEU55NQiqBAVnj9LPhVFOwxI5ESRt0fhCat78qKAloUUpYwfP96KfxgUyaFC8NLNjN/JIOjWLazWb7nlFsufjAV8Fok0TpgwoeR7EF+4RviDSEcIYoRWrfiAkrrBZB1272GrmOOISOFc0N74qquusuOVBZjcOnToYMbr7BQQJcI4nh0CIoxYgLEVyi5BLYpb/iaiZfhzIkJJA1q4cKFdIwh6Fo3snrBQyIoQ0H1Xt4jlOgjzz8PnGav5yrgSXi+MQSyOGFdYRFIsyGLdizFJEWDHxFvUFuK/i/EOFw1+HncT4Hewu0eDiEJLSSGKkhOiGfzpT3/K3XbbbbnHH388/9z69etz3bp1y+200065XXbZxZ7785//nOvRo0duzJgxuX/961/59y5ZsiT37W9/Ozdv3jz7fuTIkbkvfOELucMPPzx3wQUX5I466qjcEUcckfvud7+b+973vpfbsGFD/mf/+c9/5k488cRc7969c//4xz+iPI/cgr/73e+2eG7QoEG5H/7whyV/ZtWqVfZzHDtnxowZdtw2b95s39955525bbbZxo6Rc8kll+R23XXXXJb5z3/+k3v77bdzEyZMyPXt2zf3pS99ya7V0aNH51asWJH7+9//btdSjI8PP/ww99xzz9l57tSpU+6rX/2q3T/33nuv3af87eK/6L77PL/97W9zAwYMyB144IFbPL9p06bcFVdckevatWuuZ8+eueuuuy73xhtv2Gt33323Pc/4v3HjxtwLL7xgY/0nn3xir8+cOdOuwzfffLPk9efP8zPHH3987rjjjsutWbOm6HuEqAtFaEWzYOvnggsu2ML+i6gg0dp169Ylp556qj1PhJXtcV731TvzChFXXvM2jkQbWdWTk0vP8MmTJ9s2MdXr/CzVs0SbSFegApZiAiKRRHzpRx7agsUMqRocQ7bQcY7AK9RZtGiRbfuGHo1U+RNdIcfR30NVethli2i4F/tkFSJCVEfT+GPOnDnWYID/53hRhEI0kygRPe9jiARxTxCBv+KKKywlg4px8ojZwsVhhIg0OyTcp1mJxDaHrN13+DyzW8GYPGzYMPu7GXcdPjfpKOxyMM5TGMg1RXQf+FkcCYjKYtlIEwTSe0ibYteMv51CYbp8eYSXHYLCCDD3GmM8uyakoZHnHaJrVzQE2XaJZhMm4/uWFSkDdAzzdAPyRZkMqJD2FrtsUZFSgIAFGj0w4CGCKSLg99K9iMmByRkBQsoCeVbkUpGeAF26dLGvteI5yvYbBTpU7LNVTk4ouaBMlmy9IeI9vcPheCJaeA34ys+HuIcwr3lFcJbhmqX9K6ks5CDjmMAEzxYpaSwsnigmO+aYY+waS4sdGIs27h1agCIuyKfmHiIXna/edUk0jqzdd6Qx4T7D34hLDdd7IYhMghXuUgNYuVHwRboZubM33nijiV1y+BGxpJAhgnEaQfiS5sL/M9YjZrlOeX94P/n8QaMP0qmEaAoStKLZFFs9Y8yOKAhhgmCFT1SDKMavfvUrM8DmeUBIkPfIc6FQZuKmuABRwVcqXJnUyQmkqAUbFyJSTC78m0Rs63NmICqMUE6jg8KJJ56Y/3/atVLEQ44y0SMiIqLl4TpDbDBx82BhxW4BBS1cr4gSIkdEofDCrLS4ZRFIlIzCSkQ3eeX0sKfhge9QiOaRlfvOx1WKdykS5G8tFLOMjwQTeJ4HOxYIV+4J7hOuf8Zrxl0CFWGhIJ2/EPneSpndA7fmYoGI7Z4Q5SAdIQdRczBoFm7ZslWHfyjbujgjYPOF5RcCARCoVLZSkR8KZarU2bpCzPrvZIDldzBwMkhSmMDW1+jRoy2CUOozuRDBDgbhQkVu2kHkMwl41xyiGHRlKxQ8ROq8fzlfiWqH+PfqcV4/pMecfPLJVsjIcWMypziPrdXOnTtbYSORu3KmuJBCwy7E0KFDbYHIzgXFkBTg8FnYxkVgS8yWh1q973xc5ZpC1FJQCkRWx44dawEFxD3fA38fopRFHiKWlBZ2NHBnYawlKss1yWKL99GilsitR1q5Ptk5wH6L46kOkaJcKEIrykKpnCe248hRLExVQBxQMctWlk/QvMZgigPCpZdeusXvQfzyOn3lEbtAtAFhS15uz549S34mqmbZssUwHr/EtIPoJpfPO+OwIGB7nNzJHj162HMIHyaKffbZJ/8eoieIIk/FwBGBHLk0bXvGADmAxx57rD2IXHGNEbnFjo4olOcPshhjl6A5IBBIvUEcEA3j3PG7uVa5vmslrSYG0nzf+cK+ObmljLMEE7jWiMLyt+EAgtsAgpZ0HCBCTSCCznwIVVILqGEgvxZxS4SXnFd23FgIEixAzIZ5xP6ZC50UhGhR6iwZE6IM/Pvf//7cc1SXU2VLNSx8+umn9nXOnDlWvb927dotfv7MM88094O//OUv+ecnTpyY23fffXOLFy8uWRk7efLk3I477miuAIXv4f8rUU1LNfry5cvtwS04duxY+38cHHjt4osvzi1atMiOBX9/9+7dcx07dsxXDsNhhx1m1fnPP/98buHChfb6SSedlH/9gw8+yG233Xa5gQMH5l5++eXco48+mvv617+emzRpUtn/vqyAg8S0adPsWsSFY9ttt82dfvrpud///vd2XTbUneCPf/yjnR8cO7beeuvcDjvskBs8eHBu/vz5+ftANJ9avO8+/vjjJv2cj3P8/X369DFng7/97W9bjM3+nmeffdbG4Llz59o1jyvLqaeeas/dcMMN9h6cDT777LOi/4YQlUKCVqSaZcuW2QSChde7775rz7344otmuYTFl8Okc9555+UOPvjgLayqwoEVUbzHHnvkhg8fbt/74M1gXEjh4NySIFSYUAsf2HV99NFHuf79+5s4+vKXv5xr165d7uyzz87/7Q42TEyk3/jGN3Lf+ta3TEgxKYesXLnSLM2wzUEk+eQjWh6EJyLonHPOybVq1cos0xA1U6dOteurUMS+9957ufvvvz/3ox/9yM5h+/bt7bpEJJXz2ssysd53Pn4VCsSjjz7aLNqKBQj8/VyXpV4HRCyLJz6vU+z92L9hw/id73zHBDvi9rXXXvvc+/hZCVlRLdT6VqQaFl0UhV199dWW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", + "text/plain": [ + "
" + ] + }, + "metadata": {}, + "output_type": "display_data" + } + ], + "source": [ + "comparison = CompareFlights(\n", + " [\n", + " flights_by_name[\"first_stage\"],\n", + " flights_by_name[\"second_stage\"],\n", + " flights_by_name[\"payload\"],\n", + " ]\n", + ")\n", + "\n", + "comparison.trajectories_3d(legend=True)" + ] + }, + { + "cell_type": "markdown", + "id": "1dd1f037", + "metadata": {}, + "source": [ + "## 10) `FlightBody` is not yet supported for Flight execution\n", + "\n", + "`FlightBody` is a lightweight value object satisfying the internal `BodyLike`\n", + "interface, useful for mission-level bookkeeping (e.g. `Mission.describe()`).\n", + "It does not yet implement everything `Flight` needs to integrate a real\n", + "trajectory (`total_mass()`, `add_motor()`, ...), so `MissionExecutor` raises a\n", + "clear error instead of silently producing an incorrect or empty flight if a\n", + "bare `FlightBody` is used as a mission item's body. This is a deliberate,\n", + "temporary limitation – see the mission-architecture roadmap for planned\n", + "`Flight`/`FlightBranch` integration for `FlightBody`.\n" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "bd79bb8f", + "metadata": { + "execution": { + "iopub.execute_input": "2026-07-12T18:58:28.952246Z", + "iopub.status.busy": "2026-07-12T18:58:28.952078Z", + "iopub.status.idle": "2026-07-12T18:58:28.956732Z", + "shell.execute_reply": "2026-07-12T18:58:28.956019Z" + } + }, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Got expected error: Cannot build a Flight from FlightBody 'Unsupported Demo Body': FlightBody does not implement total_mass()/add_motor() and cannot drive Flight's physics engine directly yet. Use a Rocket (optionally wrapped in RocketAdapter) for real flight execution; FlightBody-driven Flight integration is planned future work.\n" + ] + } + ], + "source": [ + "demo_body = FlightBody(\n", + " name=\"Unsupported Demo Body\",\n", + " geometry=0.05,\n", + " mass_model=lambda t: 1.0,\n", + " inertia_model=lambda t: (0.01, 0.01, 0.001, 0.0, 0.0, 0.0),\n", + " center_of_mass_model=lambda t: 0.0,\n", + ")\n", + "demo_mission = Mission(name=\"FlightBody Guard Demo\")\n", + "demo_mission.add_deployable(\n", + " Deployable(\n", + " name=\"unsupported_payload\",\n", + " body=demo_body,\n", + " attachment=Attachment(\n", + " parent_frame_position=[0.0, 0.0, 0.0],\n", + " child_frame_position=[0.0, 0.0, 0.0],\n", + " ),\n", + " deployment_event=DeploymentEvent(\n", + " name=\"Demo Deployment\", trigger=lambda **kwargs: False\n", + " ),\n", + " )\n", + ")\n", + "demo_executor = MissionExecutor(mission=demo_mission, environment=env, rail_length=5.2)\n", + "\n", + "try:\n", + " demo_executor.execute()\n", + "except TypeError as error:\n", + " print(f\"Got expected error: {error}\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3", + "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.14.0" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/rocketpy/__init__.py b/rocketpy/__init__.py index ae602d784..fdb7d1d29 100644 --- a/rocketpy/__init__.py +++ b/rocketpy/__init__.py @@ -1,4 +1,11 @@ from ._logging import enable_logging, logger, set_log_level +from .body import ( + BodyLike, + BodyResolver, + FlightBody, + FlightCompatibleRocket, + RocketAdapter, +) from .control import _Controller from .environment import Environment, EnvironmentAnalysis from .mathutils import ( @@ -7,6 +14,26 @@ funcify_method, reset_funcified_methods, ) +from .mission import ( + Attachment, + BranchResult, + Deployable, + DeploymentEvent, + FlightConfig, + IgnitionEvent, + InstantaneousSeparation, + Mission, + MissionExecutionResult, + MissionExecutor, + MissionResult, + NoOpParentUpdate, + ParentUpdate, + RecoveryEvent, + SeparationModel, + Stage, + StageSeparationEvent, + StageState, +) from .motors import ( CylindricalTank, EmptyMotor, @@ -52,6 +79,7 @@ from .simulation import ( Event, Flight, + FlightBranch, MonteCarlo, MultivariateRejectionSampler, ) diff --git a/rocketpy/body/__init__.py b/rocketpy/body/__init__.py new file mode 100644 index 000000000..80b7cd83d --- /dev/null +++ b/rocketpy/body/__init__.py @@ -0,0 +1,14 @@ +"""rocketpy.body – body abstraction layer for multistage simulation.""" + +from rocketpy.body.body_like import BodyLike +from rocketpy.body.body_resolver import BodyResolver, FlightCompatibleRocket +from rocketpy.body.flight_body import FlightBody +from rocketpy.body.rocket_adapter import RocketAdapter + +__all__ = [ + "BodyLike", + "BodyResolver", + "FlightBody", + "FlightCompatibleRocket", + "RocketAdapter", +] diff --git a/rocketpy/body/body_like.py b/rocketpy/body/body_like.py new file mode 100644 index 000000000..795cd5e84 --- /dev/null +++ b/rocketpy/body/body_like.py @@ -0,0 +1,157 @@ +"""BodyLike protocol for the multistage mission architecture. + +This module defines a structural typing contract so bodies can satisfy the +interface without inheriting from a shared base class. +""" + +from typing import Protocol, runtime_checkable + + +@runtime_checkable +class BodyLike(Protocol): + """Structural interface that every flight-ready body must satisfy. + + Any object that can be integrated by the Flight simulation engine + must implement this interface. Both the native :class:`FlightBody` + and the :class:`RocketAdapter` (which wraps a legacy :class:`Rocket`) + satisfy it, so that the simulation layer can treat them uniformly. + This uses structural typing, so concrete bodies do not need to inherit + from :class:`BodyLike` to be accepted by the simulation layer. + + Attributes + ---------- + name : str + Human-readable identifier for the body. + """ + + @property + def name(self) -> str: + """Human-readable name of the body. + + Returns + ------- + str + Body name. + """ + ... + + def mass(self, t: float) -> float: + """Total mass of the body at time *t*, in kg. + + Parameters + ---------- + t : float + Simulation time in seconds. + + Returns + ------- + float + Total mass in kg. + """ + ... + + def inertia_tensor(self, t: float): + """Inertia tensor of the body at time *t*, in kg·m². + + Parameters + ---------- + t : float + Simulation time in seconds. + + Returns + ------- + array-like + 3×3 inertia tensor in kg·m². + """ + ... + + def center_of_mass(self, t: float) -> float: + """Position of the center of mass along the body axis at time *t*. + + The position is measured in meters relative to the body's own + coordinate system origin. + + Parameters + ---------- + t : float + Simulation time in seconds. + + Returns + ------- + float + Center-of-mass position in meters. + """ + ... + + def aerodynamic_model(self): + """Return the aerodynamic model attached to this body. + + Returns + ------- + object + Aerodynamic model instance. + """ + ... + + def propulsion_model(self): + """Return the propulsion model attached to this body. + + Returns + ------- + object + Propulsion model instance. + """ + ... + + def recovery_systems(self): + """Return the list of recovery systems attached to this body. + + Returns + ------- + list + Recovery systems. + """ + ... + + def sensors(self): + """Return the list of sensors attached to this body. + + Returns + ------- + list + Sensor instances. + """ + ... + + def controllers(self): + """Return the list of active controllers attached to this body. + + Returns + ------- + list + Controller instances. + """ + ... + + def coordinate_system_orientation(self) -> str: + """Orientation convention for the body's coordinate system. + + Returns + ------- + str + One of ``"tail_to_nose"`` or ``"nose_to_tail"``. + """ + ... + + def to_branch_ready_copy(self): + """Return a simulation-ready deep copy of this body. + + The copy is detached from user-facing state so that the + simulation engine can mutate it freely without side-effects. + + Returns + ------- + BodyLike + A deep copy suitable for a :class:`FlightBranch`. + """ + ... diff --git a/rocketpy/body/body_resolver.py b/rocketpy/body/body_resolver.py new file mode 100644 index 000000000..852bb93b4 --- /dev/null +++ b/rocketpy/body/body_resolver.py @@ -0,0 +1,114 @@ +"""BodyResolver – resolves mission bodies to BodyLike / Flight-compatible rockets.""" + +from typing import Any, Protocol, runtime_checkable + +from rocketpy.body.body_like import BodyLike +from rocketpy.body.flight_body import FlightBody +from rocketpy.body.rocket_adapter import RocketAdapter + + +@runtime_checkable +class FlightCompatibleRocket(Protocol): + """Structural contract for rocket objects accepted by Flight.""" + + def add_motor(self, motor, position): + """Add a motor to the rocket body.""" + + def total_mass(self, t: float) -> float: + """Return total mass at time *t*.""" + + def center_of_mass(self, t: float) -> float: + """Return center-of-mass position at time *t*.""" + + +class BodyResolver: + """Resolves mission bodies (``Rocket``, ``RocketAdapter``, ``FlightBody``, ...) + into the shapes the simulation engine needs. + + This is a two-step resolver: + + 1. :meth:`to_body` resolves any supported source into a :class:`BodyLike`. + This step is intentionally permissive – a bare :class:`FlightBody` is a + legitimate ``BodyLike`` for mission-level bookkeeping (e.g. + :meth:`~rocketpy.mission.Mission.describe`), even though it cannot yet + drive a real :class:`~rocketpy.simulation.Flight`. + 2. :meth:`to_flight_rocket` takes a resolved :class:`BodyLike` and returns + an object that can be passed as ``Flight(rocket=...)``. This is where + bodies that cannot actually fly (today, a bare :class:`FlightBody`) are + rejected with a clear, actionable error instead of failing silently or + deep inside ``Flight``'s constructor. + """ + + @staticmethod + def to_body(source: Any) -> BodyLike: + """Resolve *source* to a :class:`BodyLike`. + + Parameters + ---------- + source : Rocket, RocketAdapter, FlightBody, or FlightCompatibleRocket + The mission body to resolve. + + Returns + ------- + BodyLike + A body satisfying the :class:`BodyLike` interface. + + Raises + ------ + TypeError + If *source* cannot be resolved to a :class:`BodyLike`. + """ + if isinstance(source, (RocketAdapter, FlightBody)): + return source + if isinstance(source, BodyLike): + return source + if hasattr(source, "as_body"): + return source.as_body() + if isinstance(source, FlightCompatibleRocket): + return RocketAdapter(source) + raise TypeError( + f"BodyResolver cannot resolve {type(source).__name__!r} to a BodyLike. " + "Supported sources: Rocket, RocketAdapter, FlightBody, or objects " + "satisfying FlightCompatibleRocket." + ) + + @staticmethod + def to_flight_rocket(body: BodyLike): + """Return a Flight-compatible rocket object for *body*. + + Parameters + ---------- + body : BodyLike + A body previously resolved via :meth:`to_body`. + + Returns + ------- + FlightCompatibleRocket + An object that can be passed as ``Flight(rocket=...)``. + + Raises + ------ + TypeError + If *body* cannot drive a :class:`~rocketpy.simulation.Flight` yet. + This is always the case for a bare :class:`FlightBody`, since it + does not implement ``total_mass()``/``add_motor()``. + """ + if isinstance(body, RocketAdapter): + return body.rocket + if isinstance(body, FlightCompatibleRocket): + return body + if isinstance(body, FlightBody): + raise TypeError( + f"Cannot build a Flight from FlightBody {body.name!r}: FlightBody " + "does not implement total_mass()/add_motor() and cannot drive " + "Flight's physics engine directly yet. Use a Rocket (optionally " + "wrapped in RocketAdapter) for real flight execution; " + "FlightBody-driven Flight integration is planned future work." + ) + raise TypeError( + f"BodyResolver cannot use {type(body).__name__!r} to construct a " + "Flight. Supported bodies: a Rocket (optionally wrapped in " + "RocketAdapter), or any object satisfying FlightCompatibleRocket " + "(add_motor, total_mass, center_of_mass). A bare FlightBody is also " + "not yet supported for Flight execution." + ) diff --git a/rocketpy/body/flight_body.py b/rocketpy/body/flight_body.py new file mode 100644 index 000000000..5e63d7afa --- /dev/null +++ b/rocketpy/body/flight_body.py @@ -0,0 +1,162 @@ +"""FlightBody – a first-class, fully configurable flight body.""" + +from copy import deepcopy + + +class FlightBody: + """A fully configurable flight body composed of interchangeable models. + + Unlike :class:`~rocketpy.rocket.Rocket`, which is a rich user-facing + builder object, :class:`FlightBody` is designed as a clean value object + that holds just enough information for the simulation engine to integrate + the equations of motion. Users who need the full Rocket builder + experience can convert a :class:`~rocketpy.rocket.Rocket` to a + :class:`FlightBody` via :class:`RocketAdapter`. It satisfies the + :class:`~rocketpy.body.BodyLike` protocol. + + Parameters + ---------- + name : str + Human-readable name for this body. + geometry : object + External geometry description (e.g. a reference radius value or a + geometry model object). + mass_model : callable + Callable ``mass_model(t) -> float`` returning total mass in kg at + time *t* (seconds). + inertia_model : callable + Callable ``inertia_model(t)`` returning a 3×3 inertia tensor (kg·m²) + at time *t*. + center_of_mass_model : callable + Callable ``center_of_mass_model(t) -> float`` returning the axial + position of the center of mass in meters at time *t*. + aero_model : object, optional + Aerodynamic model for the body. + propulsion : object, optional + Propulsion model for the body. + recovery_systems : list, optional + Recovery systems attached to this body. + sensors : list, optional + Sensors attached to this body. + controllers : list, optional + Active controllers attached to this body. + coordinate_system_orientation : str, optional + Orientation of the body's coordinate system. Must be + ``"tail_to_nose"`` (default) or ``"nose_to_tail"``. + + Attributes + ---------- + name : str + geometry : object + mass_model : callable + inertia_model : callable + aero_model : object + propulsion_model : object + recovery_systems : list + sensors : list + controllers : list + coordinate_system_orientation : str + """ + + def __init__( + self, + name, + geometry, + mass_model, + inertia_model, + center_of_mass_model, + aero_model=None, + propulsion=None, + recovery_systems=None, + sensors=None, + controllers=None, + coordinate_system_orientation="tail_to_nose", + ): + self._name = name + self.geometry = geometry + self.mass_model = mass_model + self.inertia_model = inertia_model + self._center_of_mass_model = center_of_mass_model + self.aero_model = aero_model + self._propulsion_model = propulsion + self._recovery_systems = list(recovery_systems or []) + self._sensors = list(sensors or []) + self._controllers = list(controllers or []) + self._coordinate_system_orientation = coordinate_system_orientation + + # ------------------------------------------------------------------ + # BodyLike interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + return self._name + + def mass(self, t: float) -> float: + """Total body mass at time *t*, in kg.""" + return self.mass_model(t) + + def inertia_tensor(self, t: float): + """3×3 inertia tensor at time *t*, in kg·m².""" + return self.inertia_model(t) + + def center_of_mass(self, t: float) -> float: + """Axial position of the center of mass at time *t*, in m.""" + return self._center_of_mass_model(t) + + def aerodynamic_model(self): + return self.aero_model + + def propulsion_model(self): + return self._propulsion_model + + def recovery_systems(self): + """Return the recovery systems attached to this body.""" + return self._recovery_systems + + def sensors(self): + return self._sensors + + def controllers(self): + return self._controllers + + def coordinate_system_orientation(self) -> str: + return self._coordinate_system_orientation + + def to_branch_ready_copy(self): + """Return a deep copy suitable for use in a :class:`FlightBranch`.""" + return deepcopy(self) + + # ------------------------------------------------------------------ + # Mutating helpers + # ------------------------------------------------------------------ + + def add_recovery_system(self, system): + """Attach a recovery system to this body. + + Parameters + ---------- + system : object + Recovery system to add. + """ + self._recovery_systems.append(system) + + def add_sensor(self, sensor): + """Attach a sensor to this body. + + Parameters + ---------- + sensor : object + Sensor to add. + """ + self._sensors.append(sensor) + + def add_controller(self, controller): + """Attach a controller to this body. + + Parameters + ---------- + controller : object + Controller to add. + """ + self._controllers.append(controller) diff --git a/rocketpy/body/rocket_adapter.py b/rocketpy/body/rocket_adapter.py new file mode 100644 index 000000000..46aec78de --- /dev/null +++ b/rocketpy/body/rocket_adapter.py @@ -0,0 +1,178 @@ +"""RocketAdapter – wraps a legacy :class:`Rocket` as a :class:`BodyLike`.""" + +from copy import deepcopy + + +class RocketAdapter: + """Adapts a legacy :class:`~rocketpy.rocket.Rocket` to the + :class:`BodyLike` interface. + + This allows the existing :class:`~rocketpy.rocket.Rocket` builder to be + consumed by the new multistage simulation infrastructure without any + changes to the ``Rocket`` class itself. + + Parameters + ---------- + rocket : :class:`~rocketpy.rocket.Rocket` + The rocket instance to adapt. + + Attributes + ---------- + rocket : :class:`~rocketpy.rocket.Rocket` + The wrapped rocket instance. + """ + + def __init__(self, rocket): + self.rocket = rocket + + # ------------------------------------------------------------------ + # BodyLike interface + # ------------------------------------------------------------------ + + @property + def name(self) -> str: + """Name of the underlying rocket. + + Returns + ------- + str + ``rocket.name`` if present, otherwise ``""``. + """ + return getattr(self.rocket, "name", "") + + def mass(self, t: float) -> float: + """Total rocket mass at time *t*, in kg. + + Delegates to ``rocket.total_mass(t)``. + + Parameters + ---------- + t : float + Simulation time in seconds. + + Returns + ------- + float + Total mass in kg. + """ + return self.rocket.total_mass(t) + + def inertia_tensor(self, t: float): + """Inertia tensor at time *t*, in kg·m². + + Returns a 3×3 matrix built from the scalar inertia components + available on the rocket. + + Parameters + ---------- + t : float + Simulation time in seconds. + + Returns + ------- + tuple[float, float, float, float, float, float] + Components ``(I_11, I_22, I_33, I_12, I_13, I_23)`` in kg·m². + """ + r = self.rocket + return ( + r.I_11(t), + r.I_22(t), + r.I_33(t), + r.I_12(t), + r.I_13(t), + r.I_23(t), + ) + + def center_of_mass(self, t: float) -> float: + """Axial position of the center of mass at time *t*, in m. + + Parameters + ---------- + t : float + Simulation time in seconds. + + Returns + ------- + float + Center-of-mass position in meters. + """ + return self.rocket.center_of_mass(t) + + def aerodynamic_model(self): + """Return the rocket's aerodynamic surfaces collection. + + Returns + ------- + list + ``rocket.aerodynamic_surfaces``. + """ + return self.rocket.aerodynamic_surfaces + + def propulsion_model(self): + """Return the rocket's motor. + + Returns + ------- + Motor + ``rocket.motor``. + """ + return self.rocket.motor + + def recovery_systems(self): + """Return the rocket's parachute list. + + Returns + ------- + list + ``rocket.parachutes``. + """ + return self.rocket.parachutes + + def sensors(self): + """Return the sensors attached to the rocket. + + Accesses ``rocket.sensors`` when available, which is a public + :class:`~rocketpy.rocket.Components` collection on the + :class:`~rocketpy.rocket.Rocket` class in newer Rocket + configurations. + + Returns + ------- + list or Components + ``rocket.sensors``, or an empty list if the attribute is absent. + """ + return getattr(self.rocket, "sensors", []) + + def controllers(self): + """Return the active controllers attached to the rocket. + + Accesses ``rocket._controllers``, which is a private list on the + :class:`~rocketpy.rocket.Rocket` class. + + Returns + ------- + list + ``rocket._controllers``, or an empty list if the attribute is + absent. + """ + return getattr(self.rocket, "_controllers", []) + + def coordinate_system_orientation(self) -> str: + """Coordinate system orientation of the underlying rocket. + + Returns + ------- + str + ``rocket.coordinate_system_orientation``. + """ + return self.rocket.coordinate_system_orientation + + def to_branch_ready_copy(self): + """Return a deep copy of this adapter suitable for a FlightBranch. + + Returns + ------- + RocketAdapter + Deep copy with a cloned rocket. + """ + return deepcopy(self) diff --git a/rocketpy/mission/__init__.py b/rocketpy/mission/__init__.py new file mode 100644 index 000000000..c6793ab11 --- /dev/null +++ b/rocketpy/mission/__init__.py @@ -0,0 +1,42 @@ +"""rocketpy.mission – mission architecture for multistage rockets.""" + +from rocketpy.mission.attached_item import AttachedItem +from rocketpy.mission.attachment import Attachment +from rocketpy.mission.branch_result import BranchResult +from rocketpy.mission.deployable import Deployable +from rocketpy.mission.events import ( + DeploymentEvent, + IgnitionEvent, + RecoveryEvent, + StageSeparationEvent, +) +from rocketpy.mission.flight_config import FlightConfig +from rocketpy.mission.mission import Mission +from rocketpy.mission.mission_executor import MissionExecutionResult, MissionExecutor +from rocketpy.mission.mission_result import MissionResult +from rocketpy.mission.parent_update import NoOpParentUpdate, ParentUpdate +from rocketpy.mission.separation_model import InstantaneousSeparation, SeparationModel +from rocketpy.mission.stage import Stage +from rocketpy.mission.stage_state import StageState + +__all__ = [ + "AttachedItem", + "Attachment", + "BranchResult", + "Deployable", + "DeploymentEvent", + "FlightConfig", + "IgnitionEvent", + "InstantaneousSeparation", + "Mission", + "MissionExecutionResult", + "MissionExecutor", + "MissionResult", + "NoOpParentUpdate", + "ParentUpdate", + "RecoveryEvent", + "SeparationModel", + "Stage", + "StageSeparationEvent", + "StageState", +] diff --git a/rocketpy/mission/attached_item.py b/rocketpy/mission/attached_item.py new file mode 100644 index 000000000..f7bf1c1c4 --- /dev/null +++ b/rocketpy/mission/attached_item.py @@ -0,0 +1,52 @@ +"""AttachedItem – abstract base for items that can be attached to a body.""" + +from abc import ABC, abstractmethod + + +class AttachedItem(ABC): + """Abstract base class for objects that can be attached to a parent body. + + Every mission item (a :class:`Deployable` or a :class:`Stage`) is an + :class:`AttachedItem`. It owns a :class:`~rocketpy.body.BodyLike` + instance (the physical body), an :class:`~rocketpy.mission.Attachment` + (the mechanical link), and a list of :class:`~rocketpy.mission.Event` + objects that govern its lifecycle. + + Parameters + ---------- + name : str + Human-readable identifier for this item. + body : :class:`~rocketpy.body.BodyLike` + The physical body associated with this item. + attachment : :class:`~rocketpy.mission.Attachment` + The mechanical link that positions and orients this item relative + to its parent. + events : list[:class:`~rocketpy.mission.Event`], optional + Events that can be fired while this item is attached. + + Attributes + ---------- + name : str + body : BodyLike + attachment : Attachment + events : list[Event] + """ + + def __init__(self, name, body, attachment, events=None): + self.name = name + self.body = body + self.attachment = attachment + self.events = list(events or []) + + @abstractmethod + def validate(self): + """Validate that this item is correctly configured. + + Raises + ------ + ValueError + If any required field is missing or inconsistent. + """ + + def __repr__(self) -> str: + return f"{type(self).__name__}(name={self.name!r})" diff --git a/rocketpy/mission/attachment.py b/rocketpy/mission/attachment.py new file mode 100644 index 000000000..804d9fec6 --- /dev/null +++ b/rocketpy/mission/attachment.py @@ -0,0 +1,85 @@ +"""Attachment – describes the mechanical link between two bodies.""" + + +class Attachment: + """Encodes how a child body is attached to a parent body. + + The attachment resolves the child's initial pose (position + orientation) + given the parent's current state, and records any mechanical constraints + that apply while the link is active. + + Parameters + ---------- + parent_frame_position : array-like + Position of the attachment point in the **parent** body frame, + in meters. Typically a 3-vector ``[x, y, z]``. + child_frame_position : array-like + Position of the attachment point in the **child** body frame, + in meters. + orientation : array-like or None, optional + Relative orientation of the child frame with respect to the parent + frame. Can be a rotation matrix, quaternion, or any representation + that the caller chooses to interpret. ``None`` means co-aligned + frames. + constraints : str, optional + Textual description of the mechanical constraint (e.g. + ``"rigid"``, ``"hinged"``). Purely informational at this stage. + tags : dict, optional + Arbitrary key-value metadata for downstream consumers. + + Attributes + ---------- + parent_frame_position : array-like + child_frame_position : array-like + orientation : array-like or None + constraints : str + tags : dict + """ + + def __init__( + self, + parent_frame_position, + child_frame_position, + orientation=None, + constraints="rigid", + tags=None, + ): + self.parent_frame_position = parent_frame_position + self.child_frame_position = child_frame_position + self.orientation = orientation + self.constraints = constraints + self.tags = dict(tags or {}) + + def resolve_child_pose(self, parent_state): + """Compute the initial child pose from the parent state. + + .. note:: + **Stub implementation** – this method currently returns + *parent_state* unchanged and does *not* apply the attachment + offsets (:attr:`parent_frame_position`, + :attr:`child_frame_position`) or :attr:`orientation`. A full + 6-DOF pose resolver will be implemented in a future iteration of + the multistage simulation engine. + + Parameters + ---------- + parent_state : object + Current state of the parent body (position, orientation, …). + + Returns + ------- + object + Initial state for the child body (currently equal to + *parent_state*). + """ + # TODO: compose parent_state with attachment offsets and orientation + # to produce the true initial child pose. + return parent_state + + def __repr__(self) -> str: + return ( + f"Attachment(" + f"parent_frame_position={self.parent_frame_position!r}, " + f"child_frame_position={self.child_frame_position!r}, " + f"constraints={self.constraints!r})" + ) diff --git a/rocketpy/mission/branch_result.py b/rocketpy/mission/branch_result.py new file mode 100644 index 000000000..a14433c64 --- /dev/null +++ b/rocketpy/mission/branch_result.py @@ -0,0 +1,39 @@ +"""BranchResult – execution record for a single mission item's flight.""" + +from dataclasses import dataclass +from typing import Any + + +@dataclass +class BranchResult: + """Execution record for one mission item's :class:`~rocketpy.simulation.Flight`. + + Parameters + ---------- + name : str + Name of the attached item this branch was built for. + flight : :class:`~rocketpy.simulation.Flight` + The simulated flight for this branch. + item : :class:`~rocketpy.mission.AttachedItem` + The mission item (``Stage`` or ``Deployable``) this branch was built + from. + event_time : float + Simulation time, in seconds, at which this branch's flight begins. + initial_solution : list + Initial solution state vector used to start this branch's flight. + Empty when the flight started from a fresh launch-rail state. + + Attributes + ---------- + name : str + flight : Flight + item : AttachedItem + event_time : float + initial_solution : list + """ + + name: str + flight: Any + item: Any + event_time: float + initial_solution: list diff --git a/rocketpy/mission/deployable.py b/rocketpy/mission/deployable.py new file mode 100644 index 000000000..a35922909 --- /dev/null +++ b/rocketpy/mission/deployable.py @@ -0,0 +1,82 @@ +"""Deployable – a mission item that can be released during flight.""" + +from rocketpy.mission.attached_item import AttachedItem +from rocketpy.mission.parent_update import NoOpParentUpdate +from rocketpy.mission.separation_model import InstantaneousSeparation + + +class Deployable(AttachedItem): + """A body that can be deployed (released) from its parent during flight. + + A :class:`Deployable` is the mission-layer wrapper around a physical + body that should be released at some point in flight (e.g. a nose + cone, a payload bay, or a secondary vehicle). It is *not* the physical + body itself – that is held in :attr:`body`. + + Parameters + ---------- + name : str + Human-readable identifier (e.g. ``"nose_cone_payload"``). + body : :class:`~rocketpy.body.BodyLike` + The physical body of the deployable. + attachment : :class:`~rocketpy.mission.Attachment` + Mechanical link to the parent body. + deployment_event : :class:`~rocketpy.mission.DeploymentEvent` + The event that triggers this deployable's release. + separation : :class:`~rocketpy.mission.SeparationModel`, optional + Model for the separation dynamics. Defaults to + :class:`~rocketpy.mission.InstantaneousSeparation`. + parent_update : :class:`~rocketpy.mission.ParentUpdate`, optional + Callback that modifies the parent body after separation. Defaults + to :class:`~rocketpy.mission.NoOpParentUpdate`. + events : list[:class:`~rocketpy.mission.Event`], optional + Additional events besides *deployment_event*. + + Attributes + ---------- + deployment_event : DeploymentEvent + separation : SeparationModel + parent_update : ParentUpdate + """ + + def __init__( + self, + name, + body, + attachment, + deployment_event, + separation=None, + parent_update=None, + events=None, + ): + # Merge deployment_event into the events list so the base class + # holds *all* events for this item. + all_events = list(events or []) + if deployment_event not in all_events: + all_events.insert(0, deployment_event) + super().__init__(name, body, attachment, events=all_events) + + self.deployment_event = deployment_event + self.separation = ( + separation if separation is not None else InstantaneousSeparation() + ) + self.parent_update = ( + parent_update if parent_update is not None else NoOpParentUpdate() + ) + + def validate(self): + """Validate that this deployable is correctly configured. + + Raises + ------ + ValueError + If *body*, *attachment*, or *deployment_event* are ``None``. + """ + if self.body is None: + raise ValueError(f"Deployable '{self.name}': body must not be None.") + if self.attachment is None: + raise ValueError(f"Deployable '{self.name}': attachment must not be None.") + if self.deployment_event is None: + raise ValueError( + f"Deployable '{self.name}': deployment_event must not be None." + ) diff --git a/rocketpy/mission/events.py b/rocketpy/mission/events.py new file mode 100644 index 000000000..5b5c6a613 --- /dev/null +++ b/rocketpy/mission/events.py @@ -0,0 +1,156 @@ +"""Mission-lifecycle events, built directly on rocketpy.simulation.events.Event. + +The mission architecture does not define its own event base class: each +event below is a thin :class:`~rocketpy.simulation.events.Event` subclass +that fixes the *callback* to a specific mission-lifecycle action (deployment, +stage separation, ignition, recovery), while the *trigger* is supplied by the +caller using the same ``trigger(**kwargs) -> bool`` convention as any other +:class:`~rocketpy.simulation.events.Event` (``kwargs`` includes ``time``, +``state``, ``flight``, ``rocket``, ...; see :class:`~rocketpy.simulation.events.Event` +for the full list). + +Each event fires at most once (``trigger_only_once=True``): deployment, +separation, and ignition are one-shot lifecycle transitions, not continuous +conditions, so a trigger that stays true after firing (e.g. ``vz <= 0`` +during descent) must not repeatedly re-invoke the callback. +""" + +from rocketpy.simulation.events.event import Event + + +class DeploymentEvent(Event): + """Event that triggers the deployment of a :class:`Deployable`. + + Parameters + ---------- + name : str + Human-readable name. + trigger : callable + ``trigger(**kwargs) -> bool`` – returns ``True`` when the deployable + should be released. + priority : int, optional + Event evaluation priority (see + :class:`~rocketpy.simulation.events.Event`). Defaults to ``4`` + (custom/user-defined), since deployment is a mission-level, not a + core-flight, event. + """ + + def __init__(self, name, trigger, priority=4): + super().__init__( + callback=self._deploy, + trigger=trigger, + name=name, + priority=priority, + trigger_only_once=True, + ) + + def _deploy(self, **kwargs): + """Deploy the associated :class:`Deployable`. + + Notes + ----- + The actual separation logic is delegated to the Deployable's + :class:`~rocketpy.mission.SeparationModel` and + :class:`~rocketpy.mission.ParentUpdate`. + + .. todo:: + Wire up ``Deployable.separation.apply()`` and + ``Deployable.parent_update.apply()`` via this callback's + ``event.commands`` once the mission executor drives branch + spawning (see :class:`~rocketpy.simulation.FlightBranch`). + """ + + +class StageSeparationEvent(Event): + """Event that triggers the mechanical separation of a :class:`Stage`. + + Parameters + ---------- + name : str + Human-readable name. + trigger : callable + ``trigger(**kwargs) -> bool``. + priority : int, optional + Event evaluation priority. Defaults to ``4``. + """ + + def __init__(self, name, trigger, priority=4): + super().__init__( + callback=self._separate, + trigger=trigger, + name=name, + priority=priority, + trigger_only_once=True, + ) + + def _separate(self, **kwargs): + """Separate the stage from its parent branch. + + .. todo:: + Invoke ``Stage.separation.apply()`` and + ``Stage.parent_update.apply()``, and spawn a new child + :class:`~rocketpy.simulation.FlightBranch` for the separated + stage, via this callback's ``event.commands``. + """ + + +class IgnitionEvent(Event): + """Event that triggers motor ignition on a :class:`Stage`. + + Parameters + ---------- + name : str + Human-readable name. + trigger : callable + ``trigger(**kwargs) -> bool``. + priority : int, optional + Event evaluation priority. Defaults to ``4``. + """ + + def __init__(self, name, trigger, priority=4): + super().__init__( + callback=self._ignite, + trigger=trigger, + name=name, + priority=priority, + trigger_only_once=True, + ) + + def _ignite(self, **kwargs): + """Ignite the stage motor. + + .. todo:: + Activate the propulsion model on the stage body and transition + :attr:`Stage.state` to :attr:`~rocketpy.mission.StageState.IGNITED`. + """ + + +class RecoveryEvent(Event): + """Event that triggers a recovery system (e.g. parachute deployment). + + Parameters + ---------- + name : str + Human-readable name. + trigger : callable + ``trigger(**kwargs) -> bool``. + priority : int, optional + Event evaluation priority. Defaults to ``4``. + """ + + def __init__(self, name, trigger, priority=4): + super().__init__( + callback=self._recover, + trigger=trigger, + name=name, + priority=priority, + trigger_only_once=True, + ) + + def _recover(self, **kwargs): + """Activate the recovery system. + + .. todo:: + Deploy the parachute or other recovery device attached to the + branch body. + """ diff --git a/rocketpy/mission/flight_config.py b/rocketpy/mission/flight_config.py new file mode 100644 index 000000000..c6ee8645e --- /dev/null +++ b/rocketpy/mission/flight_config.py @@ -0,0 +1,130 @@ +"""FlightConfig – typed per-branch Flight construction parameters.""" + +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class FlightConfig: + """Typed configuration for constructing a single mission branch's Flight. + + A :class:`FlightConfig` mirrors the subset of + :class:`~rocketpy.simulation.Flight` constructor arguments that mission + items are expected to override on a per-branch basis. It replaces ad hoc + keyword-argument dicts as the value passed around by + :class:`~rocketpy.mission.MissionExecutor`. + + Parameters + ---------- + rail_length : float + Length in meters of the launch rail for this branch. + inclination : float, optional + Rail inclination relative to the ground, in degrees. Defaults to + ``80.0``. + heading : float, optional + Launch heading relative to north, in degrees. Defaults to ``90.0``. + initial_solution : object, optional + Initial solution passed to ``Flight``. May be a state vector or a + previously built ``Flight`` (chained by + :class:`~rocketpy.mission.MissionExecutor`). Defaults to ``None``. + max_time : float, optional + Maximum simulation time, in seconds. Defaults to ``600``. + terminate_on_apogee : bool, optional + Whether to stop the simulation at apogee. Defaults to ``False``. + time_overshoot : bool, optional + Whether ``Flight`` is allowed to overshoot requested time steps. + Defaults to ``True``. + extra_kwargs : dict, optional + Any additional keyword arguments forwarded to ``Flight`` verbatim. + + Attributes + ---------- + rail_length : float + inclination : float + heading : float + initial_solution : object or None + max_time : float + terminate_on_apogee : bool + time_overshoot : bool + extra_kwargs : dict + """ + + rail_length: float + inclination: float = 80.0 + heading: float = 90.0 + initial_solution: Any = None + max_time: float = 600 + terminate_on_apogee: bool = False + time_overshoot: bool = True + extra_kwargs: dict = field(default_factory=dict) + + _KNOWN_FIELDS = ( + "rail_length", + "inclination", + "heading", + "initial_solution", + "max_time", + "terminate_on_apogee", + "time_overshoot", + ) + + def to_flight_kwargs(self) -> dict: + """Return keyword arguments to pass to ``Flight`` (excluding ``rail_length``, + which :class:`~rocketpy.mission.MissionExecutor` passes separately + alongside ``rocket``/``environment``). + + Returns + ------- + dict + Merged mapping of ``inclination``, ``heading``, + ``initial_solution``, ``max_time``, ``terminate_on_apogee``, + ``time_overshoot``, and any entries from :attr:`extra_kwargs`. + """ + kwargs = { + "inclination": self.inclination, + "heading": self.heading, + "initial_solution": self.initial_solution, + "max_time": self.max_time, + "terminate_on_apogee": self.terminate_on_apogee, + "time_overshoot": self.time_overshoot, + } + kwargs.update(self.extra_kwargs) + return kwargs + + def for_branch(self, name: str, overrides: dict = None) -> "FlightConfig": + """Return a new :class:`FlightConfig` with *overrides* applied on top. + + Parameters + ---------- + name : str + Name of the branch these overrides apply to. Used only for + error messages; it is not stored on the returned config. + overrides : dict, optional + Values to override. Keys matching a known :class:`FlightConfig` + field are applied directly; any other keys are merged into the + returned config's :attr:`extra_kwargs`. + + Returns + ------- + FlightConfig + A new, independent :class:`FlightConfig` instance. + """ + overrides = dict(overrides or {}) + known = {k: v for k, v in overrides.items() if k in self._KNOWN_FIELDS} + extra = {k: v for k, v in overrides.items() if k not in self._KNOWN_FIELDS} + + merged_extra = dict(self.extra_kwargs) + merged_extra.update(extra) + + return FlightConfig( + rail_length=known.get("rail_length", self.rail_length), + inclination=known.get("inclination", self.inclination), + heading=known.get("heading", self.heading), + initial_solution=known.get("initial_solution", self.initial_solution), + max_time=known.get("max_time", self.max_time), + terminate_on_apogee=known.get( + "terminate_on_apogee", self.terminate_on_apogee + ), + time_overshoot=known.get("time_overshoot", self.time_overshoot), + extra_kwargs=merged_extra, + ) diff --git a/rocketpy/mission/mission.py b/rocketpy/mission/mission.py new file mode 100644 index 000000000..4c13e4d58 --- /dev/null +++ b/rocketpy/mission/mission.py @@ -0,0 +1,331 @@ +"""Mission – top-level container for a multistage rocket mission.""" + +import warnings +from typing import Any + + +class Mission: # pylint: disable=too-many-instance-attributes + """Container that stores all mission items for a multistage flight. + + A :class:`Mission` groups together all the :class:`~rocketpy.mission.Stage` + and :class:`~rocketpy.mission.Deployable` objects that together define a + complete rocket mission. It acts as the single source-of-truth for the + simulation engine: when :class:`~rocketpy.simulation.Flight` is initialised + with a :class:`Mission`, it reads the stages and deployables from here. + + Parameters + ---------- + name : str, optional + Human-readable name for the mission (e.g. ``"Falcon9_Demo"``). + Defaults to ``"Mission"``. + root_vehicle : Rocket or FlightBody, optional + The assembled vehicle before any stage/deployable event occurs. + Defaults to ``None``. + + Attributes + ---------- + name : str + Mission name. + root_vehicle : Rocket, FlightBody, or None + The assembled vehicle flown before any stage/deployable event. + Not part of :meth:`attached_items` – it is the vehicle that + attached items detach *from*. + stages : list[:class:`~rocketpy.mission.Stage`] + Ordered list of rocket stages (first-to-fire first). + deployables : list[:class:`~rocketpy.mission.Deployable`] + List of deployable items that will be released during the mission. + + Examples + -------- + >>> from rocketpy.mission import Mission, Stage, Deployable + >>> mission = Mission(name="Demo") + >>> # mission.add_stage(some_stage) + >>> # mission.add_deployable(some_deployable) + >>> len(mission.stages) + 0 + """ + + def __init__(self, name: str = "Mission", root_vehicle=None): + self.name = name + self.root_vehicle = root_vehicle + self.stages: list = [] + self.deployables: list = [] + self._flight_inputs: dict[str, dict[str, Any]] = {} + self._root_flight_inputs: dict[str, Any] = {} + + # ------------------------------------------------------------------ + # Mutating helpers + # ------------------------------------------------------------------ + + def add_stage(self, stage): + """Append a stage to this mission. + + Parameters + ---------- + stage : :class:`~rocketpy.mission.Stage` + The stage to add. Will be validated before appending. + + Raises + ------ + ValueError + If *stage* fails its own :meth:`~rocketpy.mission.Stage.validate` + check. + TypeError + If *stage* is not a :class:`~rocketpy.mission.Stage` instance. + """ + from rocketpy.mission.stage import Stage # local import to avoid circularity + + if not isinstance(stage, Stage): + raise TypeError(f"Expected a Stage instance, got {type(stage).__name__!r}.") + stage.validate() + self.stages.append(stage) + self._flight_inputs.setdefault(self._item_key(stage), {}) + + def add_deployable(self, deployable): + """Append a deployable to this mission. + + Parameters + ---------- + deployable : :class:`~rocketpy.mission.Deployable` + The deployable to add. Will be validated before appending. + + Raises + ------ + ValueError + If *deployable* fails its own + :meth:`~rocketpy.mission.Deployable.validate` check. + TypeError + If *deployable* is not a + :class:`~rocketpy.mission.Deployable` instance. + """ + from rocketpy.mission.deployable import Deployable # local import + + if not isinstance(deployable, Deployable): + raise TypeError( + f"Expected a Deployable instance, got {type(deployable).__name__!r}." + ) + deployable.validate() + self.deployables.append(deployable) + self._flight_inputs.setdefault(self._item_key(deployable), {}) + + def set_root_vehicle(self, vehicle): + """Set the assembled vehicle flown before any stage/deployable event. + + Parameters + ---------- + vehicle : Rocket or FlightBody + The root vehicle. Unlike stages/deployables, it is not wrapped + in an :class:`~rocketpy.mission.AttachedItem` and is not + returned by :meth:`attached_items`. + """ + self.root_vehicle = vehicle + + def set_root_flight_inputs(self, **flight_inputs): + """Set flight inputs used for the root vehicle's flight. + + Parameters + ---------- + **flight_inputs + Keyword arguments forwarded to ``rocketpy.simulation.Flight`` + when the root vehicle is executed. + """ + self._root_flight_inputs.update(flight_inputs) + + def get_root_flight_inputs(self) -> dict[str, Any]: + """Return configured flight inputs for the root vehicle. + + Returns + ------- + dict[str, Any] + Copy of configured inputs. Empty dict when not configured. + """ + return dict(self._root_flight_inputs) + + # ------------------------------------------------------------------ + # Query helpers + # ------------------------------------------------------------------ + + def attached_items(self): + """Return all attached items (stages and deployables) in priority order. + + Stages are listed before deployables. + + Returns + ------- + list[:class:`~rocketpy.mission.AttachedItem`] + Combined list of :attr:`stages` followed by :attr:`deployables`. + """ + return list(self.stages) + list(self.deployables) + + @property + def number_of_stages(self) -> int: + """Number of stages registered in this mission.""" + return len(self.stages) + + def connection_map(self) -> dict[str, dict[str, Any]]: + """Return mission attachment metadata keyed by item name. + + Returns + ------- + dict[str, dict[str, Any]] + For each stage/deployable item, stores its parent/child attachment + frame positions, orientation and constraints. + """ + connections = {} + for item in self.attached_items(): + attachment = item.attachment + connections[item.name] = { + "parent_frame_position": attachment.parent_frame_position, + "child_frame_position": attachment.child_frame_position, + "orientation": attachment.orientation, + "constraints": attachment.constraints, + } + return connections + + def set_flight_inputs(self, item, **flight_inputs): + """Set per-item inputs used by :class:`MissionExecutor`. + + Parameters + ---------- + item : :class:`~rocketpy.mission.AttachedItem` or str + Attached item instance, or its name. + **flight_inputs + Keyword arguments forwarded to ``rocketpy.simulation.Flight`` + when this mission item is executed. + + Raises + ------ + KeyError + If *item* does not belong to this mission. + """ + key = self._resolve_item_key(item) + current = dict(self._flight_inputs.get(key, {})) + current.update(flight_inputs) + self._flight_inputs[key] = current + + def get_flight_inputs(self, item) -> dict[str, Any]: + """Return configured per-item flight inputs. + + Parameters + ---------- + item : :class:`~rocketpy.mission.AttachedItem` or str + Attached item instance, or its name. + + Returns + ------- + dict[str, Any] + Copy of configured inputs. Empty dict when not configured. + + Raises + ------ + KeyError + If *item* does not belong to this mission. + """ + key = self._resolve_item_key(item) + return dict(self._flight_inputs.get(key, {})) + + def describe(self) -> str: + """Return a human-readable, multi-line summary of this mission. + + Returns + ------- + str + Summary including the mission name, root vehicle, stage states, + deployables, and configured per-item flight inputs. + """ + lines = [f"Mission: {self.name!r}"] + if self.root_vehicle is None: + lines.append("root_vehicle: not set") + else: + root_name = getattr(self.root_vehicle, "name", None) + lines.append( + f"root_vehicle: {type(self.root_vehicle).__name__}" + + (f" (name={root_name!r})" if root_name else "") + ) + lines.append(f"stages ({self.number_of_stages}):") + for stage in self.stages: + lines.append(f" - {stage.name!r}: state={stage.state.name}") + lines.append(f"deployables ({len(self.deployables)}):") + for deployable in self.deployables: + lines.append(f" - {deployable.name!r}") + for item in self.attached_items(): + inputs = self.get_flight_inputs(item) + if inputs: + lines.append(f"flight_inputs[{item.name!r}]: {inputs}") + return "\n".join(lines) + + def validate(self, require_root_vehicle: bool = False): + """Validate the whole mission's configuration. + + Re-validates every attached item and checks mission-wide invariants + that individual item validation cannot catch, such as duplicate + item names and root vehicle presence. + + Parameters + ---------- + require_root_vehicle : bool, optional + If ``True``, raise when :attr:`root_vehicle` is unset instead of + only warning. Defaults to ``False``. + + Raises + ------ + ValueError + If any attached item fails validation, if two items share the + same name, or if *require_root_vehicle* is ``True`` and + :attr:`root_vehicle` is unset. + """ + errors = [] + + names_seen = set() + for item in self.attached_items(): + if item.name in names_seen: + errors.append(f"Duplicate attached item name: {item.name!r}.") + names_seen.add(item.name) + + try: + item.validate() + except ValueError as exc: + errors.append(f"{item.name!r}: {exc}") + + if errors: + raise ValueError( + f"Mission {self.name!r} failed validation:\n" + + "\n".join(f" - {error}" for error in errors) + ) + + if self.root_vehicle is None: + message = f"Mission {self.name!r} has no root_vehicle set." + if require_root_vehicle: + raise ValueError(message) + warnings.warn(message) + + def _item_key(self, item) -> str: + # Internal opaque identifier used only as dict key to avoid collisions + # between Stage and Deployable items sharing the same name. + return f"{type(item).__name__}:{item.name}" + + def _resolve_item_key(self, item) -> str: + if isinstance(item, str): + for attached_item in self.attached_items(): + if attached_item.name == item: + return self._item_key(attached_item) + raise KeyError(f"No attached mission item named {item!r}.") + + for attached_item in self.attached_items(): + if attached_item is item: + return self._item_key(attached_item) + raise KeyError("Attached mission item does not belong to this mission.") + + # ------------------------------------------------------------------ + # Dunder + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + return ( + f"Mission(name={self.name!r}, " + f"stages={len(self.stages)}, " + f"deployables={len(self.deployables)})" + ) + + def __len__(self) -> int: + return len(self.stages) + len(self.deployables) diff --git a/rocketpy/mission/mission_executor.py b/rocketpy/mission/mission_executor.py new file mode 100644 index 000000000..4cac9e8fd --- /dev/null +++ b/rocketpy/mission/mission_executor.py @@ -0,0 +1,203 @@ +"""MissionExecutor – high-level API to execute all mission flight branches.""" + +from dataclasses import dataclass + +from rocketpy.body import BodyResolver, FlightCompatibleRocket +from rocketpy.mission.branch_result import BranchResult +from rocketpy.mission.flight_config import FlightConfig +from rocketpy.mission.mission_result import MissionResult +from rocketpy.mission.stage import Stage +from rocketpy.mission.stage_state import StageState +from rocketpy.simulation import Flight + + +@dataclass(frozen=True) +class MissionExecutionResult: + """Single execution output for one mission item. + + .. deprecated:: + Kept for backward compatibility with :meth:`MissionExecutor.execute`. + Use :meth:`MissionExecutor.run` and :class:`BranchResult` instead. + """ + + item_name: str + flight: Flight + + +class MissionExecutor: + """Executes all mission stages/deployables from a :class:`Mission`. + + The executor provides a mission-first API: users configure mission items + and optional per-item flight inputs in :class:`~rocketpy.mission.Mission`, + then call :meth:`run` once to run all corresponding flight simulations. + + Each mission item's flight is built independently and chained + sequentially: an item's flight starts from the previous flight's final + state unless an explicit ``initial_solution`` is configured for it. This + is a sequential approximation of staging – it does not simulate + diverging trajectories (e.g. a spent stage falling away while the next + stage ascends) as two separate, simultaneous flights. True branching + physics is future-phase work; see :class:`~rocketpy.simulation.FlightBranch`. + """ + + def __init__( + self, + mission, + environment, + rail_length: float, + default_flight_inputs=None, + flight_class=Flight, + ): + self.mission = mission + self.environment = environment + self.rail_length = rail_length + self.default_flight_inputs = dict(default_flight_inputs or {}) + self.flight_class = flight_class + + def run(self) -> MissionResult: + """Execute the root vehicle (if any) and all mission attached items. + + Returns + ------- + MissionResult + Aggregated result containing the root flight (if + ``mission.root_vehicle`` is set) and one branch per attached + item, in mission order: stages first, then deployables. + """ + base_config = self._base_config() + + root_flight = None + if self.mission.root_vehicle is not None: + root_config = base_config.for_branch( + "root", self.mission.get_root_flight_inputs() + ) + root_flight = self._build_flight(self.mission.root_vehicle, root_config) + + branch_results = [] + previous_flight = root_flight + for item in self.mission.attached_items(): + branch = self._build_branch(item, base_config, previous_flight) + branch_results.append(branch) + previous_flight = branch.flight + + return MissionResult( + root_flight=root_flight, + branch_flights={branch.name: branch.flight for branch in branch_results}, + branch_results=branch_results, + ) + + def dry_run(self) -> list: + """Resolve every mission body and flight configuration without simulating. + + Validates that every mission body (including the root vehicle, if + set) can drive a real :class:`~rocketpy.simulation.Flight`, and + resolves the :class:`FlightConfig` each item would be built with. + This lets callers catch body-compatibility errors (e.g. an + unsupported :class:`~rocketpy.body.FlightBody`) or misconfigured + flight inputs before paying for any simulation. + + Returns + ------- + list[FlightConfig] + Resolved configs in the same order :meth:`run` would build + flights: the root vehicle's config first (if set), then one per + attached item. + """ + base_config = self._base_config() + configs = [] + + if self.mission.root_vehicle is not None: + BodyResolver.to_flight_rocket( + BodyResolver.to_body(self.mission.root_vehicle) + ) + configs.append( + base_config.for_branch("root", self.mission.get_root_flight_inputs()) + ) + + for item in self.mission.attached_items(): + BodyResolver.to_flight_rocket(BodyResolver.to_body(item.body)) + configs.append( + base_config.for_branch(item.name, self.mission.get_flight_inputs(item)) + ) + + return configs + + def execute(self) -> list: + """Execute configured mission stage/deployable flights in order. + + .. deprecated:: + Use :meth:`run` instead, which also builds the root vehicle's + flight and returns a richer :class:`~rocketpy.mission.MissionResult`. + + Returns + ------- + list[MissionExecutionResult] + Flight results in mission order: stages first, then deployables. + """ + base_config = self._base_config() + results = [] + previous_flight = None + + for item in self.mission.attached_items(): + branch = self._build_branch(item, base_config, previous_flight) + results.append( + MissionExecutionResult(item_name=branch.name, flight=branch.flight) + ) + previous_flight = branch.flight + + return results + + def _base_config(self) -> FlightConfig: + """Build the base :class:`FlightConfig` from ``default_flight_inputs``.""" + return FlightConfig(rail_length=self.rail_length).for_branch( + "default", self.default_flight_inputs + ) + + def _build_flight(self, body, config: FlightConfig, events=None): + """Resolve *body* and construct a :class:`~rocketpy.simulation.Flight`. + + *events* (the item's mission-lifecycle + :class:`~rocketpy.simulation.events.Event` instances, if any) are + forwarded as Flight's ``custom_events``, unless the config already + provides an explicit ``custom_events`` override. + """ + rocket = BodyResolver.to_flight_rocket(BodyResolver.to_body(body)) + kwargs = config.to_flight_kwargs() + if events: + kwargs.setdefault("custom_events", list(events)) + return self.flight_class( + rocket=rocket, + environment=self.environment, + rail_length=config.rail_length, + **kwargs, + ) + + def _build_branch( + self, item, base_config: FlightConfig, previous_flight + ) -> BranchResult: + """Build a single :class:`BranchResult` for *item*, chaining from + *previous_flight* when the item has no explicit ``initial_solution``. + """ + config = base_config.for_branch(item.name, self.mission.get_flight_inputs(item)) + if config.initial_solution is None and previous_flight is not None: + config.initial_solution = previous_flight + + flight = self._build_flight(item.body, config, events=item.events) + + if isinstance(item, Stage): + # Mission-level bookkeeping only: the sequential model does not + # simulate real staging events, so a stage is considered SPENT + # once its (fully independent) branch flight has been built. + item.state = StageState.SPENT + + return BranchResult( + name=item.name, + flight=flight, + item=item, + event_time=flight.t_initial, + initial_solution=( + list(flight.initial_solution) + if flight.initial_solution is not None + else [] + ), + ) diff --git a/rocketpy/mission/mission_result.py b/rocketpy/mission/mission_result.py new file mode 100644 index 000000000..86d439762 --- /dev/null +++ b/rocketpy/mission/mission_result.py @@ -0,0 +1,131 @@ +"""MissionResult – aggregated output of a full MissionExecutor.run().""" + +from dataclasses import dataclass, field +from typing import Any + +from rocketpy.mission.branch_result import BranchResult + + +@dataclass +class MissionResult: + """Aggregated result of running a :class:`~rocketpy.mission.Mission`. + + Parameters + ---------- + root_flight : :class:`~rocketpy.simulation.Flight` or None + Flight of the root vehicle, or ``None`` if the mission has no + ``root_vehicle`` set. + branch_flights : dict[str, Flight] + Mapping of attached item name to its simulated flight, in mission + order. + branch_results : list[:class:`BranchResult`] + Ordered, richer per-item execution records. + + Attributes + ---------- + root_flight : Flight or None + branch_flights : dict[str, Flight] + branch_results : list[BranchResult] + """ + + root_flight: Any + branch_flights: dict + branch_results: list = field(default_factory=list) + + def all_flights(self) -> list: + """Return every flight produced by this mission, in mission order. + + Returns + ------- + list[Flight] + ``[root_flight]`` (if set) followed by each branch's flight. + """ + flights = [] + if self.root_flight is not None: + flights.append(self.root_flight) + flights.extend(branch.flight for branch in self.branch_results) + return flights + + def get_flight(self, name: str): + """Return the flight for the given name. + + Parameters + ---------- + name : str + ``"root"`` for the root vehicle's flight, or an attached item's + name. + + Returns + ------- + Flight + The matching flight. + + Raises + ------ + KeyError + If *name* is ``"root"`` but no root flight exists, or if *name* + does not match any branch. + """ + if name == "root": + if self.root_flight is None: + raise KeyError("Mission has no root_flight.") + return self.root_flight + return self.branch_flights[name] + + def get_branch_result(self, name: str) -> BranchResult: + """Return the :class:`BranchResult` for the given attached item name. + + Parameters + ---------- + name : str + Attached item name. + + Returns + ------- + BranchResult + The matching branch result. + + Raises + ------ + KeyError + If *name* does not match any branch. + """ + for branch in self.branch_results: + if branch.name == name: + return branch + raise KeyError(f"No branch result named {name!r}.") + + def summary(self) -> str: + """Return a one-line-per-flight human-readable summary. + + Returns + ------- + str + For each flight: name, apogee, and impact velocity. + """ + lines = [] + if self.root_flight is not None: + lines.append( + f"root: apogee = {self.root_flight.apogee:.2f} m, " + f"impact velocity = {self.root_flight.impact_velocity:.2f} m/s" + ) + for branch in self.branch_results: + lines.append( + f"{branch.name}: apogee = {branch.flight.apogee:.2f} m, " + f"impact velocity = {branch.flight.impact_velocity:.2f} m/s" + ) + return "\n".join(lines) + + def plot_all(self): + """Plot the 3D trajectories of every flight in this mission result. + + Returns + ------- + CompareFlights + The comparison object used to render the plot. + """ + from rocketpy.plots import CompareFlights # local: avoid import-time dep + + comparison = CompareFlights(self.all_flights()) + comparison.trajectories_3d(legend=True) + return comparison diff --git a/rocketpy/mission/parent_update.py b/rocketpy/mission/parent_update.py new file mode 100644 index 000000000..83e244c0b --- /dev/null +++ b/rocketpy/mission/parent_update.py @@ -0,0 +1,44 @@ +"""ParentUpdate – interface for modifying a parent body after separation.""" + +from abc import ABC, abstractmethod + + +class ParentUpdate(ABC): + """Interface for updating a parent body when a child separates. + + When a :class:`~rocketpy.mission.Deployable` or + :class:`~rocketpy.mission.Stage` separates from its parent, the + parent body may need to be updated to account for the mass and + inertia change caused by the separation. Concrete implementations + of this interface perform that update in place. + """ + + @abstractmethod + def apply(self, parent_body): + """Mutate *parent_body* to reflect the separation. + + Parameters + ---------- + parent_body : :class:`~rocketpy.body.BodyLike` + The parent body to update. Implementations may, for example, + remove a nose-cone mass component or re-parametrize the mass + model. + """ + + +class NoOpParentUpdate(ParentUpdate): + """A :class:`ParentUpdate` that makes no changes to the parent. + + Use this when the parent body's properties do not change as a result + of the separation (e.g. the separated mass is negligible compared to + the remaining body). + """ + + def apply(self, parent_body): + """Do nothing. + + Parameters + ---------- + parent_body : :class:`~rocketpy.body.BodyLike` + Ignored. + """ diff --git a/rocketpy/mission/separation_model.py b/rocketpy/mission/separation_model.py new file mode 100644 index 000000000..c91571e18 --- /dev/null +++ b/rocketpy/mission/separation_model.py @@ -0,0 +1,66 @@ +"""SeparationModel – interface for child/parent separation dynamics.""" + +from abc import ABC, abstractmethod + + +class SeparationModel(ABC): + """Interface for modelling the separation impulse between two bodies. + + When a :class:`~rocketpy.mission.Deployable` or + :class:`~rocketpy.mission.Stage` separates from its parent, a + :class:`SeparationModel` is responsible for computing any + velocity/orientation perturbations applied to both the parent and + child bodies at the instant of separation. + + Implementors should override :meth:`apply` and may store any physical + parameters (e.g. spring constant, separation charge energy) as + instance attributes. + """ + + @abstractmethod + def apply(self, parent_state, child_state, context): + """Apply separation dynamics and return updated states. + + Parameters + ---------- + parent_state : object + State of the parent body just before separation. + child_state : object + State of the child body just before separation. + context : object + Simulation context (provides environment, time, etc.). + + Returns + ------- + tuple[object, object] + Updated ``(parent_state, child_state)`` after applying the + separation model. + """ + + +class InstantaneousSeparation(SeparationModel): + """Trivial separation model that applies no impulse. + + Both bodies continue with whatever velocity they had at the moment of + separation. This is the default model when no physical separation + mechanism is specified. + """ + + def apply(self, parent_state, child_state, context): + """Return states unchanged. + + Parameters + ---------- + parent_state : object + Parent state. + child_state : object + Child state. + context : object + Simulation context. + + Returns + ------- + tuple[object, object] + ``(parent_state, child_state)`` unmodified. + """ + return parent_state, child_state diff --git a/rocketpy/mission/stage.py b/rocketpy/mission/stage.py new file mode 100644 index 000000000..cf8e96809 --- /dev/null +++ b/rocketpy/mission/stage.py @@ -0,0 +1,93 @@ +"""Stage – a mission item representing a rocket stage.""" + +from rocketpy.mission.attached_item import AttachedItem +from rocketpy.mission.parent_update import NoOpParentUpdate +from rocketpy.mission.separation_model import InstantaneousSeparation +from rocketpy.mission.stage_state import StageState + + +class Stage(AttachedItem): + """A rocket stage that can ignite and/or separate from its parent. + + A :class:`Stage` is the mission-layer wrapper around the physical + body of a rocket stage. It tracks the stage lifecycle via + :class:`~rocketpy.mission.StageState` and holds references to the + events that govern separation and ignition. + + Separation and ignition are kept as independent concerns: a stage may + separate without igniting (e.g. a fairing) or may ignite before + separating (hot staging). + + Parameters + ---------- + name : str + Human-readable identifier (e.g. ``"second_stage"``). + body : :class:`~rocketpy.body.BodyLike` + Physical body of the stage. + attachment : :class:`~rocketpy.mission.Attachment` + Mechanical link to the parent body. + separation_event : :class:`~rocketpy.mission.StageSeparationEvent`, optional + Event that triggers mechanical separation. ``None`` if the stage + never separates during the simulated mission. + ignition_event : :class:`~rocketpy.mission.IgnitionEvent`, optional + Event that triggers motor ignition. ``None`` if the stage is + already burning when the simulation starts (i.e. it is the + first stage). + separation : :class:`~rocketpy.mission.SeparationModel`, optional + Separation dynamics model. Defaults to + :class:`~rocketpy.mission.InstantaneousSeparation`. + parent_update : :class:`~rocketpy.mission.ParentUpdate`, optional + Updates applied to the parent body after separation. Defaults + to :class:`~rocketpy.mission.NoOpParentUpdate`. + events : list[:class:`~rocketpy.mission.Event`], optional + Additional events besides *separation_event* and *ignition_event*. + + Attributes + ---------- + separation_event : StageSeparationEvent or None + ignition_event : IgnitionEvent or None + separation : SeparationModel + parent_update : ParentUpdate + state : StageState + """ + + def __init__( + self, + name, + body, + attachment, + separation_event=None, + ignition_event=None, + separation=None, + parent_update=None, + events=None, + ): + all_events = list(events or []) + if ignition_event is not None and ignition_event not in all_events: + all_events.insert(0, ignition_event) + if separation_event is not None and separation_event not in all_events: + all_events.insert(0, separation_event) + super().__init__(name, body, attachment, events=all_events) + + self.separation_event = separation_event + self.ignition_event = ignition_event + self.separation = ( + separation if separation is not None else InstantaneousSeparation() + ) + self.parent_update = ( + parent_update if parent_update is not None else NoOpParentUpdate() + ) + self.state = StageState.ATTACHED + + def validate(self): + """Validate that this stage is correctly configured. + + Raises + ------ + ValueError + If *body* or *attachment* are ``None``. + """ + if self.body is None: + raise ValueError(f"Stage '{self.name}': body must not be None.") + if self.attachment is None: + raise ValueError(f"Stage '{self.name}': attachment must not be None.") diff --git a/rocketpy/mission/stage_state.py b/rocketpy/mission/stage_state.py new file mode 100644 index 000000000..06e695871 --- /dev/null +++ b/rocketpy/mission/stage_state.py @@ -0,0 +1,33 @@ +"""StageState – lifecycle states for a Stage.""" + +from enum import Enum, auto + + +class StageState(Enum): + """Enumeration of the lifecycle states a :class:`Stage` can occupy. + + Typical lifecycle progressions are: + + - Powered stage: ``ATTACHED → IGNITED → SEPARATED → SPENT`` + - Unpowered drop stage (e.g. fairing): ``ATTACHED → SEPARATED`` + - Sustainer (never physically separated): ``ATTACHED → IGNITED → SPENT`` + + Attributes + ---------- + ATTACHED : + The stage is attached to the parent body and has not yet ignited. + IGNITED : + The stage motor has ignited but the stage is still mechanically + coupled to the parent. + SEPARATED : + The stage has mechanically separated from the parent and is flying + independently. + SPENT : + The stage motor has burned out. The stage may still be attached + (e.g. coast phase) or may have separated already. + """ + + ATTACHED = auto() + IGNITED = auto() + SEPARATED = auto() + SPENT = auto() diff --git a/rocketpy/rocket/rocket.py b/rocketpy/rocket/rocket.py index c16c799ce..e0027f708 100644 --- a/rocketpy/rocket/rocket.py +++ b/rocketpy/rocket/rocket.py @@ -345,6 +345,10 @@ def __init__( # pylint: disable=too-many-statements self.surfaces_cp_to_cdm = {} self.rail_buttons = Components() + # Multistage mission items (AttachmentHost interface) + self._stages = [] + self._deployables = [] + self.cp_position = Function( lambda mach: 0, inputs="Mach Number", @@ -2178,6 +2182,140 @@ def draw(self, vis_args=None, plane="xz", *, filename=None): """ self.plots.draw(vis_args, plane, filename=filename) + # ------------------------------------------------------------------ + # AttachmentHost interface – multistage mission support + # ------------------------------------------------------------------ + + def add_stage(self, stage): + """Attach a :class:`~rocketpy.mission.Stage` to this rocket. + + Parameters + ---------- + stage : :class:`~rocketpy.mission.Stage` + Stage to attach. Must pass its own ``validate()`` check. + + Raises + ------ + TypeError + If *stage* is not a :class:`~rocketpy.mission.Stage` instance. + ValueError + If *stage* fails validation. + """ + from rocketpy.mission.stage import Stage # local import avoids circularity + + if not isinstance(stage, Stage): + raise TypeError(f"Expected a Stage instance, got {type(stage).__name__!r}.") + stage.validate() + self._stages.append(stage) + + def add_deployable(self, deployable): + """Attach a :class:`~rocketpy.mission.Deployable` to this rocket. + + Parameters + ---------- + deployable : :class:`~rocketpy.mission.Deployable` + Deployable to attach. Must pass its own ``validate()`` check. + + Raises + ------ + TypeError + If *deployable* is not a + :class:`~rocketpy.mission.Deployable` instance. + ValueError + If *deployable* fails validation. + """ + from rocketpy.mission.deployable import Deployable # local import + + if not isinstance(deployable, Deployable): + raise TypeError( + f"Expected a Deployable instance, got {type(deployable).__name__!r}." + ) + deployable.validate() + self._deployables.append(deployable) + + def select_stage(self, stage): + """Return the :class:`~rocketpy.mission.Stage` identified by *stage*. + + Parameters + ---------- + stage : int or str + Either the zero-based index of the stage in :attr:`stages`, or + the ``name`` attribute of the desired stage. + + Returns + ------- + :class:`~rocketpy.mission.Stage` + The matching stage. + + Raises + ------ + IndexError + If *stage* is an integer that is out of range. + KeyError + If *stage* is a string that does not match any stage name. + TypeError + If *stage* is neither an int nor a str. + """ + if isinstance(stage, int): + return self._stages[stage] + elif isinstance(stage, str): + for s in self._stages: + if s.name == stage: + return s + raise KeyError(f"No stage named {stage!r} found.") + else: + raise TypeError(f"stage must be int or str, got {type(stage).__name__!r}.") + + def attached_items(self): + """Return all attached mission items (stages then deployables). + + Returns + ------- + list[:class:`~rocketpy.mission.AttachedItem`] + Combined list of :attr:`stages` + :attr:`deployables`. + """ + return list(self._stages) + list(self._deployables) + + def as_body(self): + """Wrap this rocket in a :class:`~rocketpy.body.RocketAdapter`. + + The adapter satisfies the :class:`~rocketpy.body.BodyLike` interface + so that the simulation engine can consume this rocket without + depending on the :class:`Rocket` class directly. + + Returns + ------- + :class:`~rocketpy.body.RocketAdapter` + Adapter wrapping ``self``. + """ + from rocketpy.body.rocket_adapter import RocketAdapter # local import + + return RocketAdapter(self) + + @property + def stages(self): + """Stages attached to this rocket. + + Returns + ------- + list[:class:`~rocketpy.mission.Stage`] + """ + return list(self._stages) + + @property + def deployables(self): + """Deployables attached to this rocket. + + Returns + ------- + list[:class:`~rocketpy.mission.Deployable`] + """ + return list(self._deployables) + + # ------------------------------------------------------------------ + # End AttachmentHost interface + # ------------------------------------------------------------------ + def info(self): """Prints out a summary of the data and graphs available about the Rocket. diff --git a/rocketpy/simulation/__init__.py b/rocketpy/simulation/__init__.py index f5e70e2ca..efd807bea 100644 --- a/rocketpy/simulation/__init__.py +++ b/rocketpy/simulation/__init__.py @@ -1,5 +1,6 @@ from .events.event import Event from .flight import Flight +from .flight_branch import FlightBranch from .flight_comparator import FlightComparator from .flight_data_exporter import FlightDataExporter from .flight_data_importer import FlightDataImporter @@ -9,6 +10,7 @@ __all__ = [ "Event", "Flight", + "FlightBranch", "FlightComparator", "FlightDataExporter", "FlightDataImporter", diff --git a/rocketpy/simulation/flight_branch.py b/rocketpy/simulation/flight_branch.py new file mode 100644 index 000000000..2cef3bee5 --- /dev/null +++ b/rocketpy/simulation/flight_branch.py @@ -0,0 +1,115 @@ +"""FlightBranch – a single contiguous integration arc in a multistage flight.""" + + +class FlightBranch: + """Represents one contiguous integration arc in a branching flight tree. + + When a :class:`~rocketpy.mission.Stage` separates or a + :class:`~rocketpy.mission.Deployable` is released, the simulation engine + creates one or more new :class:`FlightBranch` objects. Each branch tracks + its own body, initial state, and list of pending events. + + The branches together form a directed acyclic graph (DAG) rooted at the + first-stage branch. A :class:`~rocketpy.simulation.Flight` object holds + the root branch and drives the full simulation. + + Parameters + ---------- + body : :class:`~rocketpy.body.BodyLike` + The flight-ready body for this branch. A simulation-safe deep copy + should be provided (see + :meth:`~rocketpy.body.BodyLike.to_branch_ready_copy`). + start_state : object + Initial state vector (position, velocity, attitude, …) at the + start of this branch. + start_time : float + Simulation time, in seconds, at which this branch begins. + parent : :class:`FlightBranch` or None, optional + The branch from which this one was spawned. ``None`` for the root. + events : list[:class:`~rocketpy.mission.Event`], optional + Events that are pending on this branch. + + Attributes + ---------- + body : BodyLike + parent : FlightBranch or None + children : list[FlightBranch] + start_state : object + start_time : float + events : list[Event] + """ + + def __init__( + self, + body, + start_state=None, + start_time: float = 0.0, + parent=None, + events=None, + ): + self.body = body + self.start_state = start_state + self.start_time = start_time + self.parent = parent + self.children: list = [] + self.events: list = list(events or []) + + # Register this branch as a child of its parent. + if parent is not None: + parent.children.append(self) + + # ------------------------------------------------------------------ + # Tree helpers + # ------------------------------------------------------------------ + + def is_root(self) -> bool: + """Return ``True`` if this branch has no parent. + + Returns + ------- + bool + """ + return self.parent is None + + def spawn_child(self, body, start_state=None, start_time=None, events=None): + """Create a child branch and register it. + + Parameters + ---------- + body : :class:`~rocketpy.body.BodyLike` + Body for the new child branch. + start_state : object, optional + Initial state for the child. Defaults to this branch's + ``start_state``. + start_time : float, optional + Start time for the child in seconds. Defaults to this branch's + ``start_time``. + events : list[:class:`~rocketpy.mission.Event`], optional + Events for the child branch. + + Returns + ------- + FlightBranch + The newly created child branch. + """ + child = FlightBranch( + body=body, + start_state=start_state if start_state is not None else self.start_state, + start_time=start_time if start_time is not None else self.start_time, + parent=self, + events=events, + ) + return child + + # ------------------------------------------------------------------ + # Dunder + # ------------------------------------------------------------------ + + def __repr__(self) -> str: + body_name = getattr(self.body, "name", repr(self.body)) + return ( + f"FlightBranch(" + f"body={body_name!r}, " + f"start_time={self.start_time}, " + f"children={len(self.children)})" + ) diff --git a/test_nb.py b/test_nb.py new file mode 100644 index 000000000..601ebc84f --- /dev/null +++ b/test_nb.py @@ -0,0 +1,184 @@ +import datetime as dt +from pathlib import Path + +import rocketpy as rp +from rocketpy import ( + Attachment, + Deployable, + DeploymentEvent, + Environment, + FlightBody, + IgnitionEvent, + Mission, + MissionExecutor, + Parachute, + Rocket, + RocketAdapter, + SolidMotor, + Stage, + StageSeparationEvent, +) +from rocketpy.plots.compare import CompareFlights +env = Environment( + latitude=32.990254, + longitude=-106.974998, + elevation=1400, + datum="WGS84", +) + +tomorrow = dt.date.today() + dt.timedelta(days=1) +env.set_date((tomorrow.year, tomorrow.month, tomorrow.day, 12), timezone="UTC") +env.set_atmospheric_model( + type="custom_atmosphere", + pressure=101325, + temperature=298.15, + wind_u=[(0, 3.0), (2000, 5.0), (5000, 8.0)], + wind_v=[(0, -1.0), (2000, -2.0), (5000, -3.5)], +) +env.max_expected_height = 10000 +data_directory = Path(rp.__file__).resolve().parent.parent / "data" +motor_file = data_directory / "motors" / "cesaroni" / "Cesaroni_M1670.eng" +power_off_drag_file = data_directory / "rockets" / "calisto" / "powerOffDragCurve.csv" +power_on_drag_file = data_directory / "rockets" / "calisto" / "powerOnDragCurve.csv" + +first_stage_motor = SolidMotor( + thrust_source=str(motor_file), + burn_time=3.9, + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + center_of_dry_mass_position=0.317, + nozzle_position=0, + grain_number=5, + grain_density=1815, + nozzle_radius=33 / 1000, + throat_radius=11 / 1000, + grain_separation=5 / 1000, + grain_outer_radius=33 / 1000, + grain_initial_height=120 / 1000, + grains_center_of_mass_position=0.397, + grain_initial_inner_radius=15 / 1000, + interpolation_method="linear", + coordinate_system_orientation="nozzle_to_combustion_chamber", +) + +second_stage_motor = SolidMotor( + thrust_source=str(motor_file), + burn_time=3.9, + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + center_of_dry_mass_position=0.317, + nozzle_position=0, + grain_number=5, + grain_density=1815, + nozzle_radius=33 / 1000, + throat_radius=11 / 1000, + grain_separation=5 / 1000, + grain_outer_radius=33 / 1000, + grain_initial_height=120 / 1000, + grains_center_of_mass_position=0.397, + grain_initial_inner_radius=15 / 1000, + interpolation_method="linear", + coordinate_system_orientation="nozzle_to_combustion_chamber", +) + +first_stage_rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag=str(power_off_drag_file), + power_on_drag=str(power_on_drag_file), + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", +) +first_stage_rocket.name = "First Stage" +first_stage_rocket.add_motor(first_stage_motor, position=-1.255) +first_stage_rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, +) +first_stage_rocket.add_nose(length=0.55829, kind="von karman", position=1.278) +first_stage_rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + position=-1.04956, + cant_angle=0.5, +) +first_stage_rocket.add_tail( + top_radius=0.0635, + bottom_radius=0.0435, + length=0.060, + position=-1.194656, +) + +second_stage_rocket = Rocket( + radius=0.0635, + mass=9.500, + inertia=(6.321, 6.321, 0.034), + power_off_drag=str(power_off_drag_file), + power_on_drag=str(power_on_drag_file), + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", +) +second_stage_rocket.name = "Second Stage" +second_stage_rocket.add_motor(second_stage_motor, position=-1.000) +second_stage_rocket.add_nose(length=0.55829, kind="von karman", position=1.050) +second_stage_rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + position=-0.920, + cant_angle=0.5, +) +second_stage_rocket.add_tail( + top_radius=0.0635, + bottom_radius=0.0435, + length=0.060, + position=-1.194656, +) + +payload_body = FlightBody( + name="Payload", + geometry=0.055, + mass_model=lambda t: 4.5, + inertia_model=lambda t: (0.25, 0.25, 0.03, 0.0, 0.0, 0.0), + center_of_mass_model=lambda t: 0.0, + recovery_systems=[ + Parachute( + name="Payload Drogue", + cd_s=0.35, + trigger="apogee", + sampling_rate=105, + lag=1.0, + noise=(0, 4.0, 0.2), + ), + Parachute( + name="Payload Main", + cd_s=3.5, + trigger=700, + sampling_rate=105, + lag=1.0, + noise=(0, 4.0, 0.2), + ), + ], + coordinate_system_orientation="tail_to_nose", +) +# Deploy second-stage parachute at apogee. +# Parachute trigger callbacks can be strings like "apogee" or functions. +second_stage_rocket.add_parachute( + "Second Stage Main", + cd_s=5.0, + trigger="apogee", + sampling_rate=105, + lag=1.2, + noise=(0, 6.0, 0.25), +) + +print(f"Using motor file: {motor_file}") +print(f"Using drag curve files: {power_off_drag_file.name}, {power_on_drag_file.name}") +print(f"Payload body type: {type(payload_body).__name__}") +print(f"Payload dry mass at t=0 s: {payload_body.mass(0):.2f} kg") +print(f"Payload recovery systems: {len(payload_body.recovery_systems())}") diff --git a/test_nb2.py b/test_nb2.py new file mode 100644 index 000000000..803d27337 --- /dev/null +++ b/test_nb2.py @@ -0,0 +1,126 @@ + +import csv + +import cadquery as cq +import numpy as np + +from rocketpy import CylindricalTank, SphericalTank +# Create fixtures geometries +geometry_map = {} +geometry_map["sphere"] = {"spherical_oxidizer_tank": SphericalTank(0.05)} +geometry_map["cylinder"] = { + "cylindrical_oxidizer_tank": CylindricalTank(0.0744, 0.8068, True), + "cylindrical_pressurant_tank": CylindricalTank(0.135 / 2, 0.981, True), +} +def export_expected_parameters(name, datapoints): + with open(f"{name}_expected.csv", mode="w") as output_file: + writer = csv.writer(output_file) + writer.writerow(["level_height", "volume", "center_of_mass", "inertia"]) + for data_row in datapoints: + writer.writerow([*data_row]) +def evaluate_sphere_parameters(radius, level_height): + """Computes the volume, center of mass and inertia + (with respect to the origin) of a sphere 'filled' up + to a certain level height. + + Parameters + ---------- + radius : float + The radius of the sphere. + level_height : float + The height of the liquid inside the sphere. + + Returns + ------- + volume : float + The volume of the sphere. + center_of_mass : float + The center of mass of the sphere. + inertia : float + The inertia of the sphere with respect to the origin. + """ + sphere = cq.Workplane("XY").sphere(radius) + + # Cut the sphere to the level height + drill_height = 10 * radius + sphere = sphere.cut( + cq.Workplane("XY") + .cylinder(drill_height, radius) + .translate((0, 0, drill_height / 2 + level_height)) + ) + + # Uncomment to display the CAD + # display(sphere) + + primitive = sphere.val() + + volume = primitive.Volume() + center_of_mass = primitive.centerOfMass(primitive) + inertia_tensor = primitive.matrixOfInertia(primitive) + + return volume, center_of_mass.z, inertia_tensor[0][0] + volume * center_of_mass.z**2 +for name, sphere_geometry in geometry_map["sphere"].items(): + radius = sphere_geometry.total_height / 2 + datapoints = [] + for h in np.linspace(-radius, radius, 25): + datapoints.append([h, *evaluate_sphere_parameters(radius, h)]) + + export_expected_parameters(name, datapoints) +def evaluate_cylinder_with_caps(total_length, radius, level_height): + """Computes the volume, center of mass and inertia (with respect + to the origin) of a cylinder with spherical caps 'filled' up to a + certain level height. + + Parameters + ---------- + total_length : float + The total length of the cylinder (with caps). + radius : float + The radius of the cylinder. + + Returns + ------- + volume : float + The volume of the cylinder with caps. + center_of_mass : float + The z-coordinate of the center of mass. + inertia : float + The inertia of the cylinder with caps with respect to the origin. + """ + cylinder_height = total_length - 2 * radius + + cylinder = cq.Workplane("XY").cylinder(cylinder_height, radius) + top_cap = ( + cq.Workplane("XY").sphere(radius).translate((0, 0, total_length / 2 - radius)) + ) + bottom_cap = ( + cq.Workplane("XY").sphere(radius).translate((0, 0, -total_length / 2 + radius)) + ) + + solid = cylinder.union(top_cap).union(bottom_cap) + + # Remove solid above the level_height + drill_height = 10 * total_length + solid = solid.cut( + cq.Workplane("XY") + .cylinder(drill_height, radius) + .translate((0, 0, drill_height / 2 + level_height)) + ) + # Uncomment to display the CAD + # display(solid) + + primitive = solid.val() + + volume = primitive.Volume() + center_of_mass = primitive.centerOfMass(primitive) + inertia_tensor = primitive.matrixOfInertia(primitive) + + return volume, center_of_mass.z, inertia_tensor[0][0] + volume * center_of_mass.z**2 +for name, cylinder_geometry in geometry_map["cylinder"].items(): + radius = cylinder_geometry.radius(0) + length = cylinder_geometry.total_height + datapoints = [] + for h in np.linspace(-length / 2, length / 2, 25): + datapoints.append([h, *evaluate_cylinder_with_caps(length, radius, h)]) + + export_expected_parameters(name, datapoints) diff --git a/test_nb_all.py b/test_nb_all.py new file mode 100644 index 000000000..0d6cfa717 --- /dev/null +++ b/test_nb_all.py @@ -0,0 +1,295 @@ +import datetime as dt +from pathlib import Path + +import rocketpy as rp +from rocketpy import ( + Attachment, + Deployable, + DeploymentEvent, + Environment, + FlightBody, + IgnitionEvent, + Mission, + MissionExecutor, + Parachute, + Rocket, + RocketAdapter, + SolidMotor, + Stage, + StageSeparationEvent, +) +from rocketpy.plots.compare import CompareFlights +env = Environment( + latitude=32.990254, + longitude=-106.974998, + elevation=1400, + datum="WGS84", +) + +tomorrow = dt.date.today() + dt.timedelta(days=1) +env.set_date((tomorrow.year, tomorrow.month, tomorrow.day, 12), timezone="UTC") +env.set_atmospheric_model( + type="custom_atmosphere", + pressure=101325, + temperature=298.15, + wind_u=[(0, 3.0), (2000, 5.0), (5000, 8.0)], + wind_v=[(0, -1.0), (2000, -2.0), (5000, -3.5)], +) +env.max_expected_height = 10000 +data_directory = Path(rp.__file__).resolve().parent.parent / "data" +motor_file = data_directory / "motors" / "cesaroni" / "Cesaroni_M1670.eng" +power_off_drag_file = data_directory / "rockets" / "calisto" / "powerOffDragCurve.csv" +power_on_drag_file = data_directory / "rockets" / "calisto" / "powerOnDragCurve.csv" + +first_stage_motor = SolidMotor( + thrust_source=str(motor_file), + burn_time=3.9, + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + center_of_dry_mass_position=0.317, + nozzle_position=0, + grain_number=5, + grain_density=1815, + nozzle_radius=33 / 1000, + throat_radius=11 / 1000, + grain_separation=5 / 1000, + grain_outer_radius=33 / 1000, + grain_initial_height=120 / 1000, + grains_center_of_mass_position=0.397, + grain_initial_inner_radius=15 / 1000, + interpolation_method="linear", + coordinate_system_orientation="nozzle_to_combustion_chamber", +) + +second_stage_motor = SolidMotor( + thrust_source=str(motor_file), + burn_time=3.9, + dry_mass=1.815, + dry_inertia=(0.125, 0.125, 0.002), + center_of_dry_mass_position=0.317, + nozzle_position=0, + grain_number=5, + grain_density=1815, + nozzle_radius=33 / 1000, + throat_radius=11 / 1000, + grain_separation=5 / 1000, + grain_outer_radius=33 / 1000, + grain_initial_height=120 / 1000, + grains_center_of_mass_position=0.397, + grain_initial_inner_radius=15 / 1000, + interpolation_method="linear", + coordinate_system_orientation="nozzle_to_combustion_chamber", +) + +first_stage_rocket = Rocket( + radius=0.0635, + mass=14.426, + inertia=(6.321, 6.321, 0.034), + power_off_drag=str(power_off_drag_file), + power_on_drag=str(power_on_drag_file), + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", +) +first_stage_rocket.name = "First Stage" +first_stage_rocket.add_motor(first_stage_motor, position=-1.255) +first_stage_rocket.set_rail_buttons( + upper_button_position=0.0818, + lower_button_position=-0.618, + angular_position=45, +) +first_stage_rocket.add_nose(length=0.55829, kind="von karman", position=1.278) +first_stage_rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + position=-1.04956, + cant_angle=0.5, +) +first_stage_rocket.add_tail( + top_radius=0.0635, + bottom_radius=0.0435, + length=0.060, + position=-1.194656, +) + +second_stage_rocket = Rocket( + radius=0.0635, + mass=9.500, + inertia=(6.321, 6.321, 0.034), + power_off_drag=str(power_off_drag_file), + power_on_drag=str(power_on_drag_file), + center_of_mass_without_motor=0, + coordinate_system_orientation="tail_to_nose", +) +second_stage_rocket.name = "Second Stage" +second_stage_rocket.add_motor(second_stage_motor, position=-1.000) +second_stage_rocket.add_nose(length=0.55829, kind="von karman", position=1.050) +second_stage_rocket.add_trapezoidal_fins( + n=4, + root_chord=0.120, + tip_chord=0.060, + span=0.110, + position=-0.920, + cant_angle=0.5, +) +second_stage_rocket.add_tail( + top_radius=0.0635, + bottom_radius=0.0435, + length=0.060, + position=-1.194656, +) + +payload_body = FlightBody( + name="Payload", + geometry=0.055, + mass_model=lambda t: 4.5, + inertia_model=lambda t: (0.25, 0.25, 0.03, 0.0, 0.0, 0.0), + center_of_mass_model=lambda t: 0.0, + recovery_systems=[ + Parachute( + name="Payload Drogue", + cd_s=0.35, + trigger="apogee", + sampling_rate=105, + lag=1.0, + noise=(0, 4.0, 0.2), + ), + Parachute( + name="Payload Main", + cd_s=3.5, + trigger=700, + sampling_rate=105, + lag=1.0, + noise=(0, 4.0, 0.2), + ), + ], + coordinate_system_orientation="tail_to_nose", +) +# Deploy second-stage parachute right after stage-2 motor burnout. +# Parachute trigger callbacks use (pressure, height_agl, state, sensors). +second_stage_rocket.add_parachute( + "Second Stage Main", + cd_s=5.0, + trigger=lambda pressure, height_agl, state, sensors: state[0] + >= second_stage_motor.burn_out_time and state[5] <= 0, + sampling_rate=105, + lag=1.2, + noise=(0, 6.0, 0.25), +) + +print(f"Using motor file: {motor_file}") +print(f"Using drag curve files: {power_off_drag_file.name}, {power_on_drag_file.name}") +print(f"Payload body type: {type(payload_body).__name__}") +print(f"Payload dry mass at t=0 s: {payload_body.mass(0):.2f} kg") +print(f"Payload recovery systems: {len(payload_body.recovery_systems())}") +mission = Mission(name="Calisto Two-Stage + Deployable Mission") + +first_stage_attachment = Attachment( + parent_frame_position=[0.0, 0.0, 0.0], + child_frame_position=[0.0, 0.0, 0.0], + constraints="rigid", +) + +second_stage_attachment = Attachment( + parent_frame_position=[0.0, 0.0, 0.85], + child_frame_position=[0.0, 0.0, -0.45], + constraints="rigid", +) + +# In this tail_to_nose frame, z=0.55 m places the payload approximately +# halfway inserted into the first-stage airframe. +payload_attachment = Attachment( + parent_frame_position=[0.0, 0.0, 0.55], + child_frame_position=[0.0, 0.0, -0.30], + constraints="rigid", +) + +# In RocketPy state vectors, state[5] is vz (vertical velocity in m/s); +# vz <= 0 is used here as a descent/apogee crossing condition. +second_stage_sep = StageSeparationEvent( + name="Second Stage Separation", + trigger=lambda state, context: state[5] <= 0, +) +second_stage_ignition = IgnitionEvent( + name="Second Stage Ignition", + trigger=lambda state, context: state[5] <= 0, +) +payload_deploy_event = DeploymentEvent( + name="Payload Deployment", + trigger=lambda state, context: state[5] <= 0, +) + +first_stage = Stage( + name="first_stage", + body=RocketAdapter(first_stage_rocket), + attachment=first_stage_attachment, +) + +second_stage = Stage( + name="second_stage", + body=RocketAdapter(second_stage_rocket), + attachment=second_stage_attachment, + separation_event=second_stage_sep, + ignition_event=second_stage_ignition, +) + +payload = Deployable( + name="payload", + body=payload_body, + attachment=payload_attachment, + deployment_event=payload_deploy_event, +) + +mission.add_stage(first_stage) +mission.add_stage(second_stage) +mission.add_deployable(payload) +mission.set_flight_inputs( + "first_stage", + inclination=85, + heading=20, + terminate_on_apogee=True, + max_time=240, +) + +mission.set_flight_inputs( + "second_stage", + terminate_on_apogee=False, + max_time=240, +) + +mission.set_flight_inputs( + "payload", + terminate_on_apogee=False, + max_time=700, +) + +print("Mission Name:", mission.name) +print("Number of stages:", mission.number_of_stages) +print("Connection map:") +for item_name, metadata in mission.connection_map().items(): + print(f" - {item_name}: {metadata}") +executor = MissionExecutor( + mission=mission, + environment=env, + rail_length=5.2, + default_flight_inputs={"time_overshoot": True}, +) + +results = executor.execute() +flights_by_name = {result.item_name: result.flight for result in results} + +for result in results: + print( + f"{result.item_name}: apogee = {result.flight.apogee:.2f} m, " + f"impact velocity = {result.flight.impact_velocity:.2f} m/s" + ) +comparison = CompareFlights( + [ + flights_by_name["first_stage"], + flights_by_name["second_stage"], + flights_by_name["payload"], + ] +) + +comparison.trajectories_3d(legend=True) diff --git a/test_nb_seb.py b/test_nb_seb.py new file mode 100644 index 000000000..322488333 --- /dev/null +++ b/test_nb_seb.py @@ -0,0 +1,173 @@ +# These lines are here for debugging purposes only + +from rocketpy import ( + CylindricalTank, + Environment, + Flight, + Fluid, + Function, + LiquidMotor, + MassBasedTank, + Rocket, + UllageBasedTank, +) +# Define and set up LOX volume +lox_volume_liters = Function( + "../../data/rockets/berkeley/test124_Lox_Volume.csv", + extrapolation="zero", + inputs="Time (s)", + outputs="Volume (L)", +) +lox_volume = lox_volume_liters * 0.001 # Convert to m^3 +lox_volume.set_discrete(8.003, 19.984, 40, interpolation="linear") +lox_volume.set_outputs("Volume (m³)") +lox_volume.set_title("LOX Volume") + +# Plot LOX volume +lox_volume.plot(force_data=True) + +# Define and set up LOX tank ullage +lox_tank_ullage = 0.013167926436231077 - lox_volume +lox_tank_ullage.set_title("LOX Tank Ullage") +lox_tank_ullage.set_outputs("Ullage volume (m³)") + +# Plot LOX tank ullage +lox_tank_ullage.plot(8, 20, force_data=True) +# Define and set up Propane volume +propane_volume_liters = Function( + "../../data/rockets/berkeley/test124_Propane_Volume.csv", + inputs="Time (s)", + outputs="Volume (L)", +) +propane_volume = propane_volume_liters * 0.001 # Convert to m^3 +propane_volume.set_discrete(8.003, 19.984, 40, interpolation="linear") +propane_volume.set_outputs("Volume (m³)") + +# Plot Propane volume +propane_volume.plot(force_data=True) + +# Define and set up Propane tank ullage +propane_tank_ullage = 0.013167926436231077 - propane_volume +propane_tank_ullage.set_title("Propane Tank Ullage") +propane_tank_ullage.set_outputs("Ullage volume (m³)") + +# Plot Propane tank ullage +propane_tank_ullage.plot(force_data=True) +# Define fluids +lox = Fluid(name="LOX", density=1024) +propane = Fluid(name="Propane", density=566) + +# Define pressurizing gases with their respective pressures +lox_tank_pressurizing_gas = Fluid(name="N2", density=31.3 / 28) # 450 PSI +propane_tank_pressurizing_gas = Fluid( + name="N2", density=313 * 300 / 4500 / 28 +) # 300 PSI +pressurizing_gas = Fluid(name="N2", density=300) # 4500 PSI +lox_tank_geometry = CylindricalTank(0.0744, 0.8068, spherical_caps=True) +lox_tank = UllageBasedTank( + name="LOX Tank", + flux_time=(8, 20), + geometry=lox_tank_geometry, + gas=lox_tank_pressurizing_gas, + liquid=lox, + ullage=lox_tank_ullage, +) +lox_tank.fluid_mass() +lox_tank.net_mass_flow_rate() +lox_tank.liquid_height() +lox_tank.gas_height() +lox_tank.center_of_mass() +lox_tank.inertia() +propane_tank_geometry = CylindricalTank(0.0744, 0.8068, spherical_caps=True) +propane_tank = UllageBasedTank( + name="Propane Tank", + flux_time=(8, 20), + geometry=propane_tank_geometry, + gas=propane_tank_pressurizing_gas, + liquid=propane, + ullage=propane_tank_ullage, +) +propane_tank.fluid_mass() +propane_tank.net_mass_flow_rate() +propane_tank.liquid_height() +propane_tank.gas_height() +propane_tank.center_of_mass() +propane_tank.inertia() +pressure_tank_geometry = CylindricalTank(0.135 / 2, 0.981, spherical_caps=True) +pressure_tank = MassBasedTank( + name="Pressure Tank", + geometry=pressure_tank_geometry, + liquid_mass=0, + flux_time=(8, 20), + gas_mass="../../data/rockets/berkeley/pressurantMassFiltered.csv", + gas=pressurizing_gas, + liquid=pressurizing_gas, +) +pressure_tank.fluid_mass() +pressure_tank.net_mass_flow_rate() +pressure_tank.liquid_height() +pressure_tank.gas_height() +pressure_tank.center_of_mass() +pressure_tank.inertia() +liquid_motor = LiquidMotor( + thrust_source="../../data/rockets/berkeley/test124_Thrust_Curve.csv", + center_of_dry_mass_position=0, + dry_inertia=(0, 0, 0), + dry_mass=0, + burn_time=(8, 20), + nozzle_radius=0.069 / 2, + nozzle_position=-1.364, + coordinate_system_orientation="nozzle_to_combustion_chamber", +) + +liquid_motor.add_tank(propane_tank, position=-0.6446) +liquid_motor.add_tank(lox_tank, position=1.1144) +liquid_motor.add_tank(pressure_tank, position=2.4975) +liquid_motor.all_info() +berkeley_rocket = Rocket( + radius=0.098, + mass=63.4, + inertia=(25, 25, 1), + power_off_drag="../../data/rockets/berkeley/drag.csv", + power_on_drag="../../data/rockets/berkeley/drag.csv", + center_of_mass_without_motor=3.23, + coordinate_system_orientation="nose_to_tail", +) +berkeley_rocket.add_motor(liquid_motor, position=4.2) +nose = berkeley_rocket.add_nose(length=0.7, kind="vonKarman", position=0) +tail = berkeley_rocket.add_tail( + top_radius=0.098, bottom_radius=0.058, length=0.198, position=5.69 - 0.198 +) + +fins = berkeley_rocket.add_trapezoidal_fins( + n=4, + root_chord=0.355, + tip_chord=0.0803, + span=0.156, + position=5.25, + cant_angle=0, +) +berkeley_rocket.all_info() +env = Environment(latitude=35.347122986338356, longitude=-117.80893423073582) + +env.set_date((2022, 12, 3, 14 + 7, 0, 0)) # UTC + +env.set_atmospheric_model( + type="custom_atmosphere", + pressure=None, + temperature=None, + wind_u=[(0, 1), (500, 0), (1000, 5), (2500, 5.0), (5000, 10)], + wind_v=[(0, 0), (500, 3), (1600, 2), (2500, -3), (5000, 10)], +) +env.info() +test_flight = Flight( + rocket=berkeley_rocket, + environment=env, + rail_length=18.28, + inclination=90, + heading=23, + max_time_step=0.1, + terminate_on_apogee=True, +) +test_flight.angle_of_attack.plot(test_flight.out_of_rail_time, 15) +test_flight.all_info() diff --git a/tests/unit/body/__init__.py b/tests/unit/body/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/body/test_body.py b/tests/unit/body/test_body.py new file mode 100644 index 000000000..88d4bf68b --- /dev/null +++ b/tests/unit/body/test_body.py @@ -0,0 +1,310 @@ +"""Unit tests for rocketpy.body (BodyLike, FlightBody, RocketAdapter).""" + +import pytest + +from rocketpy.body import BodyLike, FlightBody, RocketAdapter + +# --------------------------------------------------------------------------- +# Helpers / fixtures +# --------------------------------------------------------------------------- + + +def _const_mass_model(value=10.0): + """Return a constant mass callable.""" + return lambda t: value + + +def _const_inertia_model(diag=1.0): + """Return a constant inertia callable that returns a diagonal tuple.""" + return lambda t: (diag, diag, diag, 0.0, 0.0, 0.0) + + +def _const_com_model(pos=0.5): + """Return a constant center-of-mass position callable.""" + return lambda t: pos + + +def _make_flight_body(**kwargs): + """Construct a minimal FlightBody for testing.""" + defaults = { + "name": "test_body", + "geometry": 0.05, + "mass_model": _const_mass_model(), + "inertia_model": _const_inertia_model(), + "center_of_mass_model": _const_com_model(), + } + defaults.update(kwargs) + return FlightBody(**defaults) + + +# --------------------------------------------------------------------------- +# BodyLike Protocol +# --------------------------------------------------------------------------- + + +class TestBodyLikeProtocol: + """BodyLike is a structural protocol.""" + + def test_structural_conformance_without_inheritance(self): + """Objects can satisfy BodyLike without inheriting from it.""" + + # Arrange + class StructuralBody: + name = "structural" + + def mass(self, t): + return 1.0 + + def inertia_tensor(self, t): + return (1.0, 1.0, 1.0, 0.0, 0.0, 0.0) + + def center_of_mass(self, t): + return 0.0 + + def aerodynamic_model(self): + return None + + def propulsion_model(self): + return None + + def recovery_systems(self): + return [] + + def sensors(self): + return [] + + def controllers(self): + return [] + + def coordinate_system_orientation(self): + return "tail_to_nose" + + def to_branch_ready_copy(self): + return self + + # Act + instance = StructuralBody() + + # Assert + assert isinstance(instance, BodyLike) + + +# --------------------------------------------------------------------------- +# FlightBody +# --------------------------------------------------------------------------- + + +class TestFlightBodyInit: + """FlightBody initializes attributes correctly.""" + + def test_name_stored(self): + """name attribute is stored and returned via .name property.""" + # Arrange & Act + body = _make_flight_body(name="rocket_stage_1") + # Assert + assert body.name == "rocket_stage_1" + + def test_default_coordinate_system_orientation(self): + """Default orientation is tail_to_nose.""" + body = _make_flight_body() + assert body.coordinate_system_orientation() == "tail_to_nose" + + def test_custom_coordinate_system_orientation(self): + """Custom orientation is stored correctly.""" + body = _make_flight_body(coordinate_system_orientation="nose_to_tail") + assert body.coordinate_system_orientation() == "nose_to_tail" + + def test_recovery_systems_defaults_to_empty(self): + """recovery_systems defaults to an empty list.""" + body = _make_flight_body() + assert body.recovery_systems() == [] + + def test_sensors_defaults_to_empty(self): + """sensors defaults to an empty list.""" + body = _make_flight_body() + assert body.sensors() == [] + + def test_controllers_defaults_to_empty(self): + """controllers defaults to an empty list.""" + body = _make_flight_body() + assert body.controllers() == [] + + +class TestFlightBodyInterface: + """FlightBody satisfies the BodyLike interface contracts.""" + + def test_mass_returns_float(self): + """mass(t) delegates to mass_model and returns a float.""" + body = _make_flight_body(mass_model=_const_mass_model(15.5)) + assert body.mass(0.0) == pytest.approx(15.5) + + def test_inertia_tensor_delegates_to_model(self): + """inertia_tensor(t) returns the value from inertia_model.""" + body = _make_flight_body(inertia_model=_const_inertia_model(2.0)) + result = body.inertia_tensor(0.0) + assert result[0] == pytest.approx(2.0) + + def test_center_of_mass_delegates_to_model(self): + """center_of_mass(t) returns value from center_of_mass_model.""" + body = _make_flight_body(center_of_mass_model=_const_com_model(1.2)) + assert body.center_of_mass(0.0) == pytest.approx(1.2) + + def test_aerodynamic_model_returns_stored_model(self): + """aerodynamic_model() returns the aero_model passed at construction.""" + aero = object() + body = _make_flight_body(aero_model=aero) + assert body.aerodynamic_model() is aero + + def test_propulsion_model_returns_stored_model(self): + """propulsion_model() returns the propulsion passed at construction.""" + prop = object() + body = _make_flight_body(propulsion=prop) + assert body.propulsion_model() is prop + + +class TestFlightBodyMutators: + """FlightBody mutating helpers work correctly.""" + + def test_add_recovery_system_appends(self): + """add_recovery_system appends to internal list.""" + body = _make_flight_body() + chute = object() + body.add_recovery_system(chute) + assert chute in body.recovery_systems() + + def test_add_sensor_appends(self): + """add_sensor appends to internal list.""" + body = _make_flight_body() + sensor = object() + body.add_sensor(sensor) + assert sensor in body.sensors() + + def test_add_controller_appends(self): + """add_controller appends to internal list.""" + body = _make_flight_body() + ctrl = object() + body.add_controller(ctrl) + assert ctrl in body.controllers() + + +class TestFlightBodyCopy: + """to_branch_ready_copy produces an independent deep copy.""" + + def test_copy_is_different_object(self): + """The copy is not the same object.""" + body = _make_flight_body() + copy = body.to_branch_ready_copy() + assert copy is not body + + def test_copy_has_same_name(self): + """The copy preserves the name.""" + body = _make_flight_body(name="original") + copy = body.to_branch_ready_copy() + assert copy.name == "original" + + def test_copy_is_independent(self): + """Mutating the copy does not affect the original.""" + body = _make_flight_body() + copy = body.to_branch_ready_copy() + copy.add_sensor(object()) + assert len(body.sensors()) == 0 + + +# --------------------------------------------------------------------------- +# RocketAdapter +# --------------------------------------------------------------------------- + + +class _FakeRocket: + """Minimal fake rocket for testing RocketAdapter.""" + + coordinate_system_orientation = "tail_to_nose" + parachutes = ["parachute_a"] + _controllers = [] + + def total_mass(self, t): + return 20.0 + + def I_11(self, t): + return 1.0 + + def I_22(self, t): + return 1.0 + + def I_33(self, t): + return 0.05 + + def I_12(self, t): + return 0.0 + + def I_13(self, t): + return 0.0 + + def I_23(self, t): + return 0.0 + + def center_of_mass(self, t): + return 0.8 + + @property + def aerodynamic_surfaces(self): + return ["fin_set"] + + @property + def motor(self): + return "solid_motor" + + @property + def sensors(self): + return [] + + +class TestRocketAdapter: + """RocketAdapter wraps a rocket and implements BodyLike.""" + + def test_is_body_like(self): + """RocketAdapter is an instance of BodyLike.""" + adapter = RocketAdapter(_FakeRocket()) + assert isinstance(adapter, BodyLike) + + def test_name_unnamed(self): + """name returns '' when the rocket has no name attribute.""" + adapter = RocketAdapter(_FakeRocket()) + assert adapter.name == "" + + def test_mass_delegates(self): + """mass(t) delegates to rocket.total_mass(t).""" + adapter = RocketAdapter(_FakeRocket()) + assert adapter.mass(0.0) == pytest.approx(20.0) + + def test_center_of_mass_delegates(self): + """center_of_mass(t) delegates to rocket.center_of_mass(t).""" + adapter = RocketAdapter(_FakeRocket()) + assert adapter.center_of_mass(0.0) == pytest.approx(0.8) + + def test_aerodynamic_model_returns_surfaces(self): + """aerodynamic_model() returns rocket.aerodynamic_surfaces.""" + adapter = RocketAdapter(_FakeRocket()) + assert adapter.aerodynamic_model() == ["fin_set"] + + def test_propulsion_model_returns_motor(self): + """propulsion_model() returns rocket.motor.""" + adapter = RocketAdapter(_FakeRocket()) + assert adapter.propulsion_model() == "solid_motor" + + def test_recovery_systems_returns_parachutes(self): + """recovery_systems() returns rocket.parachutes.""" + adapter = RocketAdapter(_FakeRocket()) + assert adapter.recovery_systems() == ["parachute_a"] + + def test_coordinate_system_orientation_delegates(self): + """coordinate_system_orientation() delegates to the rocket.""" + adapter = RocketAdapter(_FakeRocket()) + assert adapter.coordinate_system_orientation() == "tail_to_nose" + + def test_to_branch_ready_copy_is_deep(self): + """to_branch_ready_copy produces a deep copy.""" + adapter = RocketAdapter(_FakeRocket()) + copy = adapter.to_branch_ready_copy() + assert copy is not adapter + assert copy.rocket is not adapter.rocket diff --git a/tests/unit/body/test_body_resolver.py b/tests/unit/body/test_body_resolver.py new file mode 100644 index 000000000..cecbd6090 --- /dev/null +++ b/tests/unit/body/test_body_resolver.py @@ -0,0 +1,111 @@ +"""Unit tests for rocketpy.body.BodyResolver.""" + +import pytest + +from rocketpy.body import BodyResolver, FlightBody, RocketAdapter + + +def _make_flight_body(name="flight_body"): + return FlightBody( + name=name, + geometry=0.05, + mass_model=lambda t: 10.0, + inertia_model=lambda t: (1.0, 1.0, 0.05, 0.0, 0.0, 0.0), + center_of_mass_model=lambda t: 0.5, + ) + + +class _FakeRocket: + """Rocket-like object exposing as_body(), as real Rocket does.""" + + def __init__(self, name="fake_rocket"): + self.name = name + + def as_body(self): + return RocketAdapter(self) + + def add_motor(self, motor, position): + """No-op helper to satisfy FlightCompatibleRocket.""" + + def total_mass(self, t): + return 1.0 + + def center_of_mass(self, t): + return 0.0 + + +class _ProtocolOnlyRocket: + """Satisfies FlightCompatibleRocket but has no as_body().""" + + def add_motor(self, motor, position): + """No-op helper to satisfy FlightCompatibleRocket.""" + + def total_mass(self, t): + return 1.0 + + def center_of_mass(self, t): + return 0.0 + + +class _UnsupportedBody: + """Satisfies neither BodyLike nor FlightCompatibleRocket.""" + + +class TestBodyResolverToBody: + """BodyResolver.to_body resolves supported sources to a BodyLike.""" + + def test_resolves_rocket_via_as_body(self): + """A Rocket-like object with as_body() is resolved via that method.""" + rocket = _FakeRocket() + body = BodyResolver.to_body(rocket) + assert isinstance(body, RocketAdapter) + assert body.rocket is rocket + + def test_passes_through_rocket_adapter(self): + """An existing RocketAdapter is returned unchanged.""" + adapter = RocketAdapter(_FakeRocket()) + assert BodyResolver.to_body(adapter) is adapter + + def test_passes_through_flight_body(self): + """A bare FlightBody is a legitimate BodyLike and is returned unchanged.""" + flight_body = _make_flight_body() + assert BodyResolver.to_body(flight_body) is flight_body + + def test_wraps_protocol_only_rocket(self): + """An object satisfying FlightCompatibleRocket but lacking as_body() + is wrapped in a RocketAdapter.""" + rocket = _ProtocolOnlyRocket() + body = BodyResolver.to_body(rocket) + assert isinstance(body, RocketAdapter) + assert body.rocket is rocket + + def test_raises_for_unsupported_type(self): + """An unsupported type raises TypeError.""" + with pytest.raises(TypeError, match="_UnsupportedBody"): + BodyResolver.to_body(_UnsupportedBody()) + + +class TestBodyResolverToFlightRocket: + """BodyResolver.to_flight_rocket gates what can drive a real Flight.""" + + def test_unwraps_rocket_adapter(self): + """A RocketAdapter unwraps to its underlying rocket.""" + rocket = _FakeRocket() + adapter = RocketAdapter(rocket) + assert BodyResolver.to_flight_rocket(adapter) is rocket + + def test_passes_through_protocol_compatible_rocket(self): + """A bare object satisfying FlightCompatibleRocket passes through.""" + rocket = _ProtocolOnlyRocket() + assert BodyResolver.to_flight_rocket(rocket) is rocket + + def test_raises_clear_error_for_flight_body(self): + """A bare FlightBody raises a clear, actionable TypeError.""" + flight_body = _make_flight_body("payload") + with pytest.raises(TypeError, match="FlightBody"): + BodyResolver.to_flight_rocket(flight_body) + + def test_raises_for_unsupported_type(self): + """An unsupported type raises TypeError.""" + with pytest.raises(TypeError): + BodyResolver.to_flight_rocket(_UnsupportedBody()) diff --git a/tests/unit/mission/__init__.py b/tests/unit/mission/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/unit/mission/test_flight_config.py b/tests/unit/mission/test_flight_config.py new file mode 100644 index 000000000..f962d86ee --- /dev/null +++ b/tests/unit/mission/test_flight_config.py @@ -0,0 +1,72 @@ +"""Unit tests for rocketpy.mission.FlightConfig.""" + +from rocketpy.mission import FlightConfig + + +class TestFlightConfigDefaults: + """FlightConfig field defaults match Flight's own constructor defaults.""" + + def test_defaults(self): + """Only rail_length is required; other fields carry Flight-matching + defaults.""" + config = FlightConfig(rail_length=5.0) + assert config.rail_length == 5.0 + assert config.inclination == 80.0 + assert config.heading == 90.0 + assert config.initial_solution is None + assert config.max_time == 600 + assert config.terminate_on_apogee is False + assert config.time_overshoot is True + assert config.extra_kwargs == {} + + +class TestFlightConfigToFlightKwargs: + """to_flight_kwargs() builds the keyword mapping passed to Flight.""" + + def test_shape(self): + """to_flight_kwargs() returns every field except rail_length.""" + config = FlightConfig(rail_length=5.0, inclination=85.0) + kwargs = config.to_flight_kwargs() + assert "rail_length" not in kwargs + assert kwargs["inclination"] == 85.0 + assert kwargs["heading"] == 90.0 + assert kwargs["max_time"] == 600 + assert kwargs["terminate_on_apogee"] is False + assert kwargs["time_overshoot"] is True + + def test_extra_kwargs_are_merged(self): + """extra_kwargs entries are merged into the returned mapping.""" + config = FlightConfig(rail_length=5.0, extra_kwargs={"max_time_step": 0.01}) + kwargs = config.to_flight_kwargs() + assert kwargs["max_time_step"] == 0.01 + + +class TestFlightConfigForBranch: + """for_branch() produces a new, independently overridden FlightConfig.""" + + def test_returns_new_instance(self): + """for_branch() does not mutate the original config.""" + base = FlightConfig(rail_length=5.0, inclination=80.0) + branch = base.for_branch("stage_1", {"inclination": 85.0}) + assert base.inclination == 80.0 + assert branch.inclination == 85.0 + assert branch is not base + + def test_known_field_override_precedence(self): + """Per-branch overrides win over the base config's values.""" + base = FlightConfig(rail_length=5.0, heading=90.0) + branch = base.for_branch("stage_1", {"heading": 15.0}) + assert branch.heading == 15.0 + + def test_unrecognized_keys_land_in_extra_kwargs(self): + """Unknown override keys are routed into extra_kwargs.""" + base = FlightConfig(rail_length=5.0) + branch = base.for_branch("stage_1", {"max_time_step": 0.01}) + assert branch.extra_kwargs["max_time_step"] == 0.01 + + def test_no_overrides_inherits_base_values(self): + """Calling for_branch() with no overrides copies the base config.""" + base = FlightConfig(rail_length=5.0, inclination=82.0) + branch = base.for_branch("stage_1") + assert branch.inclination == 82.0 + assert branch.rail_length == 5.0 diff --git a/tests/unit/mission/test_mission.py b/tests/unit/mission/test_mission.py new file mode 100644 index 000000000..95d75fc1e --- /dev/null +++ b/tests/unit/mission/test_mission.py @@ -0,0 +1,909 @@ +"""Unit tests for rocketpy.mission package. + +Covers: Mission, AttachedItem, Deployable, Stage, Attachment, Event +hierarchy, SeparationModel, ParentUpdate, StageState. +""" + +import pytest + +from rocketpy.body import FlightBody, RocketAdapter +from rocketpy.mission import ( + Attachment, + Deployable, + DeploymentEvent, + FlightConfig, + IgnitionEvent, + InstantaneousSeparation, + Mission, + MissionExecutor, + NoOpParentUpdate, + ParentUpdate, + RecoveryEvent, + SeparationModel, + Stage, + StageSeparationEvent, + StageState, +) +from rocketpy.simulation import Event + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_body(name="body", **kwargs): + """Return a minimal FlightBody for testing.""" + defaults = dict( + name=name, + geometry=0.05, + mass_model=lambda t: 10.0, + inertia_model=lambda t: (1.0, 1.0, 0.05, 0.0, 0.0, 0.0), + center_of_mass_model=lambda t: 0.5, + ) + defaults.update(kwargs) + return FlightBody(**defaults) + + +def _make_attachment(): + """Return a minimal Attachment.""" + return Attachment( + parent_frame_position=[0.0, 0.0, 1.0], + child_frame_position=[0.0, 0.0, 0.0], + ) + + +def _always_trigger(**kwargs): + return True + + +def _never_trigger(**kwargs): + return False + + +def _make_deployment_event(name="deploy", trigger=None): + return DeploymentEvent(name, trigger or _always_trigger) + + +def _make_deployable(**kwargs): + defaults = { + "name": "payload", + "body": _make_body("payload_body"), + "attachment": _make_attachment(), + "deployment_event": _make_deployment_event(), + } + defaults.update(kwargs) + return Deployable(**defaults) + + +def _make_stage(**kwargs): + defaults = { + "name": "stage_2", + "body": _make_body("stage_body"), + "attachment": _make_attachment(), + } + defaults.update(kwargs) + return Stage(**defaults) + + +class FakeRocket: + """Minimal fake rocket object for MissionExecutor tests.""" + + def __init__(self, name="fake_rocket"): + self.name = name + + def add_motor(self, motor, position): + """No-op helper to satisfy rocket-like interface in tests.""" + + def total_mass(self, t): + """Return a deterministic mass for test doubles.""" + return 1.0 + + def center_of_mass(self, t): + """Return a deterministic center-of-mass value for test doubles.""" + return 0.0 + + +class _UnsupportedBody: + """Minimal stub used to assert unsupported-body TypeError paths.""" + + +def _make_rocket_adapter(name="rocket_body"): + """Create a RocketAdapter with a minimal fake rocket object.""" + return RocketAdapter(FakeRocket(name=name)) + + +# --------------------------------------------------------------------------- +# StageState +# --------------------------------------------------------------------------- + + +class TestStageState: + """StageState enum covers all lifecycle states.""" + + def test_all_states_exist(self): + """All four expected states are present.""" + assert StageState.ATTACHED + assert StageState.IGNITED + assert StageState.SEPARATED + assert StageState.SPENT + + def test_states_are_distinct(self): + """Each state is a unique enum member.""" + states = [ + StageState.ATTACHED, + StageState.IGNITED, + StageState.SEPARATED, + StageState.SPENT, + ] + assert len(set(states)) == 4 + + +# --------------------------------------------------------------------------- +# Attachment +# --------------------------------------------------------------------------- + + +class TestAttachment: + """Attachment stores positions and resolves child pose.""" + + def test_stores_positions(self): + """parent_frame_position and child_frame_position are stored.""" + parent_pos = [1.0, 0.0, 0.5] + child_pos = [0.0, 0.0, 0.0] + attachment = Attachment(parent_pos, child_pos) + assert attachment.parent_frame_position == parent_pos + assert attachment.child_frame_position == child_pos + + def test_default_constraints(self): + """Default constraint string is 'rigid'.""" + attachment = Attachment([0, 0, 0], [0, 0, 0]) + assert attachment.constraints == "rigid" + + def test_custom_tags_stored(self): + """Custom tags dict is stored.""" + tags = {"zone": "aft"} + attachment = Attachment([0, 0, 0], [0, 0, 0], tags=tags) + assert attachment.tags["zone"] == "aft" + + def test_resolve_child_pose_returns_parent_state(self): + """resolve_child_pose returns the parent state unchanged (stub).""" + attachment = _make_attachment() + state = {"x": 0, "y": 0, "z": 100} + result = attachment.resolve_child_pose(state) + assert result is state + + +# --------------------------------------------------------------------------- +# Event hierarchy +# --------------------------------------------------------------------------- + + +class TestEventHierarchy: + """Mission events build directly on rocketpy.simulation.events.Event.""" + + @pytest.mark.parametrize( + "event_class", + [DeploymentEvent, StageSeparationEvent, IgnitionEvent, RecoveryEvent], + ) + def test_is_a_simulation_event(self, event_class): + """Each mission event subclasses rocketpy.simulation.events.Event.""" + ev = event_class("ev", _always_trigger) + assert isinstance(ev, Event) + + def test_deployment_event_fires_on_true_trigger(self): + """DeploymentEvent fires (trigger + callback) when its trigger is True.""" + ev = DeploymentEvent("deploy", _always_trigger) + assert ev() is True + + def test_deployment_event_does_not_fire_on_false_trigger(self): + """DeploymentEvent does not fire when its trigger is False.""" + ev = DeploymentEvent("deploy", _never_trigger) + assert ev() is False + + def test_stage_separation_event_fires(self): + """StageSeparationEvent respects its trigger.""" + ev = StageSeparationEvent("sep", _always_trigger) + assert ev() is True + + def test_ignition_event_fires(self): + """IgnitionEvent respects its trigger.""" + ev = IgnitionEvent("ignite", _always_trigger) + assert ev() is True + + def test_recovery_event_fires(self): + """RecoveryEvent respects its trigger.""" + ev = RecoveryEvent("recover", _always_trigger) + assert ev() is True + + def test_event_priority_defaults_to_custom(self): + """priority defaults to 4 (custom/user-defined events), not 0 (core + Flight events like out-of-rail/apogee/impact).""" + ev = DeploymentEvent("deploy", _always_trigger) + assert ev.priority == 4 + + def test_event_priority_stored(self): + """priority attribute is stored correctly when overridden.""" + ev = DeploymentEvent("hi_prio", _always_trigger, priority=5) + assert ev.priority == 5 + + @pytest.mark.parametrize( + "event_class", + [DeploymentEvent, StageSeparationEvent, IgnitionEvent, RecoveryEvent], + ) + def test_callback_does_not_raise(self, event_class): + """Triggering the event (trigger + callback) does not raise.""" + ev = event_class("ev", _always_trigger) + ev() # must not raise + + @pytest.mark.parametrize( + "event_class", + [DeploymentEvent, StageSeparationEvent, IgnitionEvent, RecoveryEvent], + ) + def test_configured_as_one_shot(self, event_class): + """Mission lifecycle events are one-shot: deployment, separation, and + ignition should not keep re-firing on every step of a trigger + condition that stays true (e.g. vz <= 0 throughout a descent). + Flight applies this by disabling the event once commands are + processed; here we confirm the event is configured for that and + queues the disable command once it fires.""" + ev = event_class("ev", _always_trigger) + assert ev.trigger_only_once is True + assert ev() is True + assert ev.commands._disabled is True + + +# --------------------------------------------------------------------------- +# SeparationModel +# --------------------------------------------------------------------------- + + +class TestSeparationModel: + """SeparationModel ABC and InstantaneousSeparation.""" + + def test_separation_model_is_abstract(self): + """SeparationModel cannot be instantiated directly.""" + with pytest.raises(TypeError): + SeparationModel() # type: ignore[abstract] + + def test_instantaneous_returns_states_unchanged(self): + """InstantaneousSeparation.apply returns (parent_state, child_state) unmodified.""" + model = InstantaneousSeparation() + parent = {"v": 300} + child = {"v": 300} + p_out, c_out = model.apply(parent, child, None) + assert p_out is parent + assert c_out is child + + +# --------------------------------------------------------------------------- +# ParentUpdate +# --------------------------------------------------------------------------- + + +class TestParentUpdate: + """ParentUpdate ABC and NoOpParentUpdate.""" + + def test_parent_update_is_abstract(self): + """ParentUpdate cannot be instantiated directly.""" + with pytest.raises(TypeError): + ParentUpdate() # type: ignore[abstract] + + def test_no_op_does_not_raise(self): + """NoOpParentUpdate.apply runs without raising.""" + update = NoOpParentUpdate() + update.apply(None) # must not raise + + +# --------------------------------------------------------------------------- +# Deployable +# --------------------------------------------------------------------------- + + +class TestDeployable: + """Deployable attaches a body with a deployment event.""" + + def test_basic_creation(self): + """Deployable can be created with minimal arguments.""" + d = _make_deployable() + assert d.name == "payload" + + def test_deployment_event_in_events_list(self): + """deployment_event is included in the events list.""" + ev = _make_deployment_event() + d = _make_deployable(deployment_event=ev) + assert ev in d.events + + def test_default_separation_model(self): + """Default separation model is InstantaneousSeparation.""" + d = _make_deployable() + assert isinstance(d.separation, InstantaneousSeparation) + + def test_default_parent_update(self): + """Default parent update is NoOpParentUpdate.""" + d = _make_deployable() + assert isinstance(d.parent_update, NoOpParentUpdate) + + def test_validate_passes_with_valid_config(self): + """validate() does not raise for a properly configured deployable.""" + d = _make_deployable() + d.validate() # must not raise + + def test_validate_raises_on_missing_body(self): + """validate() raises ValueError when body is None.""" + d = _make_deployable(body=None) + with pytest.raises(ValueError, match="body"): + d.validate() + + def test_validate_raises_on_missing_attachment(self): + """validate() raises ValueError when attachment is None.""" + d = _make_deployable(attachment=None) + with pytest.raises(ValueError, match="attachment"): + d.validate() + + +# --------------------------------------------------------------------------- +# Stage +# --------------------------------------------------------------------------- + + +class TestStage: + """Stage tracks lifecycle and holds separation/ignition events.""" + + def test_initial_state_is_attached(self): + """Stage starts in ATTACHED state.""" + stage = _make_stage() + assert stage.state == StageState.ATTACHED + + def test_validate_passes_with_valid_config(self): + """validate() does not raise for a well-configured stage.""" + stage = _make_stage() + stage.validate() + + def test_validate_raises_on_missing_body(self): + """validate() raises ValueError when body is None.""" + stage = _make_stage(body=None) + with pytest.raises(ValueError, match="body"): + stage.validate() + + def test_validate_raises_on_missing_attachment(self): + """validate() raises ValueError when attachment is None.""" + stage = _make_stage(attachment=None) + with pytest.raises(ValueError, match="attachment"): + stage.validate() + + def test_separation_event_in_events(self): + """separation_event is included in the events list.""" + sep_ev = StageSeparationEvent("sep", _always_trigger) + stage = _make_stage(separation_event=sep_ev) + assert sep_ev in stage.events + + def test_ignition_event_in_events(self): + """ignition_event is included in the events list.""" + ign_ev = IgnitionEvent("ign", _always_trigger) + stage = _make_stage(ignition_event=ign_ev) + assert ign_ev in stage.events + + def test_default_separation_model(self): + """Default separation model is InstantaneousSeparation.""" + stage = _make_stage() + assert isinstance(stage.separation, InstantaneousSeparation) + + def test_default_parent_update(self): + """Default parent update is NoOpParentUpdate.""" + stage = _make_stage() + assert isinstance(stage.parent_update, NoOpParentUpdate) + + +# --------------------------------------------------------------------------- +# Mission +# --------------------------------------------------------------------------- + + +class TestMissionInit: + """Mission stores name and starts with empty lists.""" + + def test_default_name(self): + """Default mission name is 'Mission'.""" + m = Mission() + assert m.name == "Mission" + + def test_custom_name(self): + """Custom name is stored correctly.""" + m = Mission("ASTER_X") + assert m.name == "ASTER_X" + + def test_stages_starts_empty(self): + """stages list starts empty.""" + m = Mission() + assert m.stages == [] + + def test_deployables_starts_empty(self): + """deployables list starts empty.""" + m = Mission() + assert m.deployables == [] + + +class TestMissionAddStage: + """Mission.add_stage validates and appends stages.""" + + def test_add_valid_stage(self): + """add_stage appends a valid Stage.""" + m = Mission() + s = _make_stage() + m.add_stage(s) + assert s in m.stages + + def test_add_multiple_stages(self): + """add_stage appends multiple stages in order.""" + m = Mission() + s1 = _make_stage(name="stage_1") + s2 = _make_stage(name="stage_2") + m.add_stage(s1) + m.add_stage(s2) + assert m.stages == [s1, s2] + + def test_add_stage_raises_for_non_stage(self): + """add_stage raises TypeError when given a non-Stage object.""" + m = Mission() + with pytest.raises(TypeError): + m.add_stage("not_a_stage") + + def test_add_stage_raises_for_invalid_stage(self): + """add_stage raises ValueError when stage.validate() fails.""" + m = Mission() + bad_stage = _make_stage(body=None) # body=None will fail validate() + with pytest.raises(ValueError): + m.add_stage(bad_stage) + + +class TestMissionAddDeployable: + """Mission.add_deployable validates and appends deployables.""" + + def test_add_valid_deployable(self): + """add_deployable appends a valid Deployable.""" + m = Mission() + d = _make_deployable() + m.add_deployable(d) + assert d in m.deployables + + def test_add_deployable_raises_for_non_deployable(self): + """add_deployable raises TypeError when given a non-Deployable.""" + m = Mission() + with pytest.raises(TypeError): + m.add_deployable(42) + + def test_add_deployable_raises_for_invalid_deployable(self): + """add_deployable raises ValueError when deployable.validate() fails.""" + m = Mission() + bad = _make_deployable(body=None) + with pytest.raises(ValueError): + m.add_deployable(bad) + + +class TestMissionAttachedItems: + """Mission.attached_items returns all items in stages-first order.""" + + def test_attached_items_combines_stages_and_deployables(self): + """attached_items returns stages then deployables.""" + m = Mission() + s = _make_stage() + d = _make_deployable() + m.add_stage(s) + m.add_deployable(d) + items = m.attached_items() + assert items == [s, d] + + def test_attached_items_empty_when_nothing_added(self): + """attached_items returns empty list when no items added.""" + m = Mission() + assert m.attached_items() == [] + + def test_len_counts_all_items(self): + """len(mission) counts stages + deployables.""" + m = Mission() + m.add_stage(_make_stage()) + m.add_deployable(_make_deployable()) + assert len(m) == 2 + + +class TestMissionMissionMetadata: + """Mission exposes mission-wide stage and connection metadata.""" + + def test_number_of_stages_matches_added_stages(self): + """number_of_stages returns the stage count.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1")) + mission.add_stage(_make_stage(name="stage_2")) + assert mission.number_of_stages == 2 + + def test_connection_map_contains_attachment_data(self): + """connection_map returns attachment metadata for mission items.""" + mission = Mission() + stage = _make_stage(name="stage_1") + mission.add_stage(stage) + connection = mission.connection_map()["stage_1"] + assert connection["parent_frame_position"] == [0.0, 0.0, 1.0] + assert connection["child_frame_position"] == [0.0, 0.0, 0.0] + assert connection["constraints"] == "rigid" + + def test_set_and_get_flight_inputs_by_name(self): + """set_flight_inputs/get_flight_inputs store and return per-item inputs.""" + mission = Mission() + stage = _make_stage(name="stage_1") + mission.add_stage(stage) + mission.set_flight_inputs("stage_1", inclination=85, heading=10) + configured = mission.get_flight_inputs(stage) + assert configured["inclination"] == 85 + assert configured["heading"] == 10 + + def test_set_flight_inputs_raises_for_unknown_item(self): + """set_flight_inputs raises KeyError when item is unknown.""" + mission = Mission() + with pytest.raises(KeyError): + mission.set_flight_inputs("missing_stage", inclination=85) + + +class TestMissionRootVehicle: + """Mission.root_vehicle is a distinguished, non-attached-item vehicle.""" + + def test_default_root_vehicle_is_none(self): + """root_vehicle defaults to None.""" + assert Mission().root_vehicle is None + + def test_root_vehicle_via_constructor(self): + """root_vehicle can be set via the constructor.""" + vehicle = _make_rocket_adapter("root") + mission = Mission(root_vehicle=vehicle) + assert mission.root_vehicle is vehicle + + def test_set_root_vehicle(self): + """set_root_vehicle updates root_vehicle.""" + mission = Mission() + vehicle = _make_rocket_adapter("root") + mission.set_root_vehicle(vehicle) + assert mission.root_vehicle is vehicle + + def test_root_vehicle_excluded_from_attached_items(self): + """root_vehicle is not part of attached_items().""" + mission = Mission(root_vehicle=_make_rocket_adapter("root")) + mission.add_stage(_make_stage(name="stage_1")) + assert len(mission.attached_items()) == 1 + + def test_root_flight_inputs_round_trip(self): + """set_root_flight_inputs/get_root_flight_inputs store and return inputs.""" + mission = Mission() + mission.set_root_flight_inputs(inclination=85, heading=10) + inputs = mission.get_root_flight_inputs() + assert inputs == {"inclination": 85, "heading": 10} + + def test_get_root_flight_inputs_empty_by_default(self): + """get_root_flight_inputs returns an empty dict when unconfigured.""" + assert Mission().get_root_flight_inputs() == {} + + +class TestMissionDescribe: + """Mission.describe() returns a human-readable summary.""" + + def test_describe_without_root_vehicle(self): + """describe() reports root_vehicle as not set.""" + mission = Mission(name="Demo") + assert "root_vehicle: not set" in mission.describe() + assert "Demo" in mission.describe() + + def test_describe_with_root_vehicle(self): + """describe() reports the root vehicle's type.""" + mission = Mission(root_vehicle=_make_rocket_adapter("root")) + assert "RocketAdapter" in mission.describe() + + def test_describe_lists_stages_and_deployables(self): + """describe() lists each stage's name/state and each deployable's name.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1")) + mission.add_deployable(_make_deployable(name="payload")) + description = mission.describe() + assert "stage_1" in description + assert "payload" in description + assert "ATTACHED" in description + + +class TestMissionValidate: + """Mission.validate() aggregates per-item validation plus mission invariants.""" + + def test_validate_passes_for_well_formed_mission(self): + """validate() does not raise for a valid mission.""" + mission = Mission(root_vehicle=_make_rocket_adapter("root")) + mission.add_stage(_make_stage(name="stage_1")) + mission.validate() # must not raise + + def test_validate_raises_on_duplicate_names(self): + """validate() raises ValueError when two items share a name.""" + mission = Mission() + mission.add_stage(_make_stage(name="dup")) + mission.add_deployable(_make_deployable(name="dup")) + with pytest.raises(ValueError, match="Duplicate"): + mission.validate() + + def test_validate_aggregates_item_errors(self): + """validate() collects every failing item's error into one ValueError.""" + mission = Mission() + stage = _make_stage(name="stage_1") + deployable = _make_deployable(name="payload") + mission.add_stage(stage) + mission.add_deployable(deployable) + # Mutate after adding to bypass add_stage/add_deployable's own checks. + stage.body = None + deployable.body = None + + with pytest.raises(ValueError) as exc_info: + mission.validate() + assert "stage_1" in str(exc_info.value) + assert "payload" in str(exc_info.value) + + def test_validate_warns_when_root_vehicle_unset(self): + """validate() warns (does not raise) when root_vehicle is unset.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1")) + with pytest.warns(UserWarning, match="root_vehicle"): + mission.validate() + + def test_validate_raises_when_root_vehicle_required(self): + """validate(require_root_vehicle=True) raises when root_vehicle is unset.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1")) + with pytest.raises(ValueError, match="root_vehicle"): + mission.validate(require_root_vehicle=True) + + +class TestMissionExecutor: + """MissionExecutor runs mission items without requiring manual Flight setup.""" + + class FakeFlight: + """Simple stand-in for rocketpy.simulation.Flight. + + Mimics just enough of Flight's initial_solution-chaining contract + (see Flight.__init_flight_state) for MissionExecutor's sequential + chaining logic to be exercised: passing a previous FakeFlight as + initial_solution resolves to that flight's own final state. + """ + + def __init__(self, rocket, environment, rail_length, **kwargs): + self.rocket = rocket + self.environment = environment + self.rail_length = rail_length + self.kwargs = kwargs + + initial_solution = kwargs.get("initial_solution") + if hasattr(initial_solution, "initial_solution"): + initial_solution = initial_solution.initial_solution + self.initial_solution = ( + list(initial_solution) if initial_solution else [0.0] + ) + self.t_initial = self.initial_solution[0] + self.apogee = 100.0 + self.impact_velocity = -5.0 + + def test_execute_runs_stage_and_deployable(self): + """execute runs all mission attached items in mission order.""" + mission = Mission() + stage = _make_stage(name="stage_1", body=_make_rocket_adapter("stage_rocket")) + deployable = _make_deployable( + name="payload", body=_make_rocket_adapter("payload_rocket") + ) + mission.add_stage(stage) + mission.add_deployable(deployable) + mission.set_flight_inputs("stage_1", heading=15) + mission.set_flight_inputs("payload", heading=45) + + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + default_flight_inputs={"inclination": 80}, + flight_class=self.FakeFlight, + ) + + results = executor.execute() + + assert [result.item_name for result in results] == ["stage_1", "payload"] + assert results[0].flight.kwargs["inclination"] == 80 + assert results[0].flight.kwargs["heading"] == 15 + assert results[1].flight.kwargs["heading"] == 45 + assert results[1].flight.kwargs["initial_solution"] is results[0].flight + assert stage.state == StageState.SPENT + + def test_execute_forwards_item_events_as_custom_events(self): + """execute() forwards an item's mission events as Flight's custom_events.""" + sep_event = StageSeparationEvent("sep", _always_trigger) + mission = Mission() + stage = _make_stage( + name="stage_1", + body=_make_rocket_adapter("stage_rocket"), + separation_event=sep_event, + ) + mission.add_stage(stage) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + + results = executor.execute() + + assert results[0].flight.kwargs["custom_events"] == [sep_event] + + def test_execute_raises_for_non_rocket_body(self): + """execute raises TypeError when body is not RocketAdapter-backed.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1", body=_UnsupportedBody())) + + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + + with pytest.raises(TypeError, match="FlightBody"): + executor.execute() + + def test_execute_accepts_protocol_compatible_rocket_body(self): + """execute accepts bodies satisfying FlightCompatibleRocket protocol.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1", body=FakeRocket("raw_stage"))) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + results = executor.execute() + assert results[0].flight.rocket.name == "raw_stage" + + def test_execute_raises_for_flight_body(self): + """execute raises a clear TypeError for a bare FlightBody, since it + cannot drive a real Flight (no total_mass()/add_motor()).""" + mission = Mission() + body = _make_body("flight_body") + mission.add_stage(_make_stage(name="stage_1", body=body)) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + with pytest.raises(TypeError, match="FlightBody"): + executor.execute() + + +class TestMissionExecutorRun: + """MissionExecutor.run()/dry_run() build on top of execute()'s logic.""" + + FakeFlight = TestMissionExecutor.FakeFlight + + def test_run_without_root_vehicle(self): + """run() returns a MissionResult with root_flight None when + mission.root_vehicle is unset.""" + mission = Mission() + mission.add_stage( + _make_stage(name="stage_1", body=_make_rocket_adapter("stage_rocket")) + ) + mission.add_deployable( + _make_deployable( + name="payload", body=_make_rocket_adapter("payload_rocket") + ) + ) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + + result = executor.run() + + assert result.root_flight is None + assert list(result.branch_flights) == ["stage_1", "payload"] + assert [branch.name for branch in result.branch_results] == [ + "stage_1", + "payload", + ] + + def test_run_with_root_vehicle_chains_from_root(self): + """run() builds a root flight first and chains the first item from it.""" + mission = Mission(root_vehicle=_make_rocket_adapter("root_rocket")) + mission.add_stage( + _make_stage(name="stage_1", body=_make_rocket_adapter("stage_rocket")) + ) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + + result = executor.run() + + assert result.root_flight is not None + assert result.branch_results[0].flight.kwargs["initial_solution"] is ( + result.root_flight + ) + + def test_run_raises_for_flight_body_item(self): + """run() raises the BodyResolver error at the executor boundary for a + bare FlightBody attached item.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1", body=_make_body("flight_body"))) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + with pytest.raises(TypeError, match="FlightBody"): + executor.run() + + def test_run_raises_for_flight_body_root_vehicle(self): + """run() raises the same clear error for a FlightBody root_vehicle.""" + mission = Mission(root_vehicle=_make_body("flight_body_root")) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + with pytest.raises(TypeError, match="FlightBody"): + executor.run() + + def test_run_marks_stage_spent(self): + """run() transitions Stage.state to SPENT once its branch is built.""" + mission = Mission() + stage = _make_stage(name="stage_1", body=_make_rocket_adapter("stage_rocket")) + mission.add_stage(stage) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + + executor.run() + + assert stage.state == StageState.SPENT + + def test_dry_run_never_constructs_a_flight(self): + """dry_run() resolves bodies/configs without invoking flight_class.""" + + def _failing_flight_class(*args, **kwargs): + raise AssertionError("dry_run() must not construct a Flight.") + + mission = Mission(root_vehicle=_make_rocket_adapter("root_rocket")) + mission.add_stage( + _make_stage(name="stage_1", body=_make_rocket_adapter("stage_rocket")) + ) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + default_flight_inputs={"inclination": 80}, + flight_class=_failing_flight_class, + ) + + configs = executor.dry_run() + + assert [config.rail_length for config in configs] == [5.0, 5.0] + assert all(isinstance(config, FlightConfig) for config in configs) + assert all(config.inclination == 80 for config in configs) + + def test_dry_run_raises_for_flight_body(self): + """dry_run() still surfaces the FlightBody guard, cheaply.""" + mission = Mission() + mission.add_stage(_make_stage(name="stage_1", body=_make_body("flight_body"))) + executor = MissionExecutor( + mission=mission, + environment=object(), + rail_length=5.0, + flight_class=self.FakeFlight, + ) + with pytest.raises(TypeError, match="FlightBody"): + executor.dry_run() diff --git a/tests/unit/mission/test_mission_result.py b/tests/unit/mission/test_mission_result.py new file mode 100644 index 000000000..bd3511fe2 --- /dev/null +++ b/tests/unit/mission/test_mission_result.py @@ -0,0 +1,146 @@ +"""Unit tests for rocketpy.mission.MissionResult and BranchResult.""" + +import pytest + +from rocketpy.mission import BranchResult, MissionResult + + +class _FakeFlight: + def __init__(self, apogee=100.0, impact_velocity=-5.0): + self.apogee = apogee + self.impact_velocity = impact_velocity + + +def _make_branch_result(name, flight=None): + flight = flight or _FakeFlight() + return BranchResult( + name=name, flight=flight, item=object(), event_time=0.0, initial_solution=[] + ) + + +class TestMissionResultAllFlights: + """all_flights() orders root_flight (if any) before branch flights.""" + + def test_includes_root_flight_first(self): + """root_flight comes before branch flights when set.""" + root_flight = _FakeFlight() + branch = _make_branch_result("stage_1") + result = MissionResult( + root_flight=root_flight, + branch_flights={"stage_1": branch.flight}, + branch_results=[branch], + ) + assert result.all_flights() == [root_flight, branch.flight] + + def test_excludes_root_flight_when_none(self): + """all_flights() omits root_flight when it is None.""" + branch = _make_branch_result("stage_1") + result = MissionResult( + root_flight=None, + branch_flights={"stage_1": branch.flight}, + branch_results=[branch], + ) + assert result.all_flights() == [branch.flight] + + +class TestMissionResultGetFlight: + """get_flight() looks up flights by name, including 'root'.""" + + def test_get_flight_by_name(self): + """get_flight(name) returns the matching branch flight.""" + branch = _make_branch_result("stage_1") + result = MissionResult( + root_flight=None, + branch_flights={"stage_1": branch.flight}, + branch_results=[branch], + ) + assert result.get_flight("stage_1") is branch.flight + + def test_get_flight_root(self): + """get_flight('root') returns root_flight when set.""" + root_flight = _FakeFlight() + result = MissionResult( + root_flight=root_flight, branch_flights={}, branch_results=[] + ) + assert result.get_flight("root") is root_flight + + def test_get_flight_root_raises_when_unset(self): + """get_flight('root') raises KeyError when root_flight is None.""" + result = MissionResult(root_flight=None, branch_flights={}, branch_results=[]) + with pytest.raises(KeyError): + result.get_flight("root") + + def test_get_flight_raises_for_unknown_name(self): + """get_flight(name) raises KeyError for an unknown branch name.""" + result = MissionResult(root_flight=None, branch_flights={}, branch_results=[]) + with pytest.raises(KeyError): + result.get_flight("unknown") + + +class TestMissionResultGetBranchResult: + """get_branch_result() looks up BranchResult records by name.""" + + def test_returns_matching_branch(self): + """get_branch_result(name) returns the matching BranchResult.""" + branch = _make_branch_result("stage_1") + result = MissionResult( + root_flight=None, + branch_flights={"stage_1": branch.flight}, + branch_results=[branch], + ) + assert result.get_branch_result("stage_1") is branch + + def test_raises_for_unknown_name(self): + """get_branch_result(name) raises KeyError for an unknown name.""" + result = MissionResult(root_flight=None, branch_flights={}, branch_results=[]) + with pytest.raises(KeyError): + result.get_branch_result("unknown") + + +class TestMissionResultSummary: + """summary() reports one line per flight.""" + + def test_summary_contains_each_flight_name(self): + """summary() mentions 'root' and each branch's name.""" + root_flight = _FakeFlight(apogee=500.0, impact_velocity=-2.0) + branch = _make_branch_result("stage_1", _FakeFlight(apogee=400.0)) + result = MissionResult( + root_flight=root_flight, + branch_flights={"stage_1": branch.flight}, + branch_results=[branch], + ) + summary = result.summary() + assert "root" in summary + assert "stage_1" in summary + assert "500.00" in summary + + +class TestMissionResultPlotAll: + """plot_all() delegates to rocketpy.plots.CompareFlights.""" + + def test_plot_all_calls_compare_flights(self, monkeypatch): + """plot_all() builds a CompareFlights over all_flights() and renders it.""" + calls = {} + + class _FakeCompareFlights: + def __init__(self, flights): + calls["flights"] = flights + + def trajectories_3d(self, legend=None): + calls["legend"] = legend + + monkeypatch.setattr("rocketpy.plots.CompareFlights", _FakeCompareFlights) + + root_flight = _FakeFlight() + branch = _make_branch_result("stage_1") + result = MissionResult( + root_flight=root_flight, + branch_flights={"stage_1": branch.flight}, + branch_results=[branch], + ) + + comparison = result.plot_all() + + assert isinstance(comparison, _FakeCompareFlights) + assert calls["flights"] == [root_flight, branch.flight] + assert calls["legend"] is True diff --git a/tests/unit/rocket/test_rocket_attachment_host.py b/tests/unit/rocket/test_rocket_attachment_host.py new file mode 100644 index 000000000..5bdeea037 --- /dev/null +++ b/tests/unit/rocket/test_rocket_attachment_host.py @@ -0,0 +1,214 @@ +"""Unit tests for the Rocket AttachmentHost interface methods.""" + +import pytest + +from rocketpy import Rocket +from rocketpy.body import FlightBody, RocketAdapter +from rocketpy.mission import ( + Attachment, + Deployable, + DeploymentEvent, + IgnitionEvent, + Stage, + StageSeparationEvent, +) + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_rocket(): + """Return a minimal Rocket instance.""" + return Rocket( + radius=0.0635, + mass=16.235, + inertia=(6.321, 6.321, 0.0346), + power_off_drag=0.5, + power_on_drag=0.5, + center_of_mass_without_motor=0, + ) + + +def _make_body(name="test_body"): + return FlightBody( + name=name, + geometry=0.05, + mass_model=lambda t: 5.0, + inertia_model=lambda t: (0.5, 0.5, 0.01, 0.0, 0.0, 0.0), + center_of_mass_model=lambda t: 0.4, + ) + + +def _make_attachment(): + return Attachment([0.0, 0.0, 1.5], [0.0, 0.0, 0.0]) + + +def _always(**kwargs): + return True + + +def _make_stage(**kwargs): + defaults = dict( + name="upper_stage", + body=_make_body("upper_body"), + attachment=_make_attachment(), + separation_event=StageSeparationEvent("sep", _always), + ) + defaults.update(kwargs) + return Stage(**defaults) + + +def _make_deployable(**kwargs): + defaults = dict( + name="payload", + body=_make_body("payload"), + attachment=_make_attachment(), + deployment_event=DeploymentEvent("deploy", _always), + ) + defaults.update(kwargs) + return Deployable(**defaults) + + +# --------------------------------------------------------------------------- +# Tests – stages / deployables lists +# --------------------------------------------------------------------------- + + +class TestRocketMissionLists: + """Rocket starts with empty mission lists.""" + + def test_stages_empty_by_default(self): + """stages is empty when no stages have been added.""" + r = _make_rocket() + assert r.stages == [] + + def test_deployables_empty_by_default(self): + """deployables is empty when no deployables have been added.""" + r = _make_rocket() + assert r.deployables == [] + + +# --------------------------------------------------------------------------- +# Tests – add_stage / add_deployable +# --------------------------------------------------------------------------- + + +class TestRocketAddStage: + """Rocket.add_stage validates and stores stages.""" + + def test_add_valid_stage(self): + """add_stage appends a Stage to the internal list.""" + r = _make_rocket() + s = _make_stage() + r.add_stage(s) + assert s in r.stages + + def test_add_stage_raises_for_non_stage(self): + """add_stage raises TypeError when given a non-Stage object.""" + r = _make_rocket() + with pytest.raises(TypeError): + r.add_stage("not a stage") + + def test_add_stage_raises_for_invalid_stage(self): + """add_stage raises ValueError when stage.validate() fails.""" + r = _make_rocket() + bad_stage = _make_stage(body=None) + with pytest.raises(ValueError): + r.add_stage(bad_stage) + + +class TestRocketAddDeployable: + """Rocket.add_deployable validates and stores deployables.""" + + def test_add_valid_deployable(self): + """add_deployable appends a Deployable.""" + r = _make_rocket() + d = _make_deployable() + r.add_deployable(d) + assert d in r.deployables + + def test_add_deployable_raises_for_non_deployable(self): + """add_deployable raises TypeError for a non-Deployable object.""" + r = _make_rocket() + with pytest.raises(TypeError): + r.add_deployable({"not": "a deployable"}) + + def test_add_deployable_raises_for_invalid_deployable(self): + """add_deployable raises ValueError when deployable.validate() fails.""" + r = _make_rocket() + bad = _make_deployable(body=None) + with pytest.raises(ValueError): + r.add_deployable(bad) + + +# --------------------------------------------------------------------------- +# Tests – select_stage +# --------------------------------------------------------------------------- + + +class TestRocketSelectStage: + """Rocket.select_stage retrieves stages by index or name.""" + + def test_select_by_index(self): + """select_stage(0) returns the first stage.""" + r = _make_rocket() + s = _make_stage(name="booster") + r.add_stage(s) + assert r.select_stage(0) is s + + def test_select_by_name(self): + """select_stage('booster') returns the stage with that name.""" + r = _make_rocket() + s = _make_stage(name="booster") + r.add_stage(s) + assert r.select_stage("booster") is s + + def test_select_by_index_out_of_range_raises(self): + """select_stage raises IndexError for an out-of-range index.""" + r = _make_rocket() + with pytest.raises(IndexError): + r.select_stage(0) + + def test_select_by_name_not_found_raises(self): + """select_stage raises KeyError for an unknown stage name.""" + r = _make_rocket() + with pytest.raises(KeyError): + r.select_stage("nonexistent") + + def test_select_with_invalid_type_raises(self): + """select_stage raises TypeError for a non-int/str argument.""" + r = _make_rocket() + with pytest.raises(TypeError): + r.select_stage(3.14) # type: ignore[arg-type] + + +# --------------------------------------------------------------------------- +# Tests – attached_items / as_body +# --------------------------------------------------------------------------- + + +class TestRocketAttachmentHostHelpers: + """Rocket.attached_items and Rocket.as_body satisfy the interface.""" + + def test_attached_items_combines_stages_and_deployables(self): + """attached_items returns stages + deployables.""" + r = _make_rocket() + s = _make_stage() + d = _make_deployable() + r.add_stage(s) + r.add_deployable(d) + items = r.attached_items() + assert items == [s, d] + + def test_attached_items_empty_by_default(self): + """attached_items is empty before adding anything.""" + r = _make_rocket() + assert r.attached_items() == [] + + def test_as_body_returns_rocket_adapter(self): + """as_body() returns a RocketAdapter wrapping this rocket.""" + r = _make_rocket() + adapter = r.as_body() + assert isinstance(adapter, RocketAdapter) + assert adapter.rocket is r diff --git a/tests/unit/simulation/test_flight_branch.py b/tests/unit/simulation/test_flight_branch.py new file mode 100644 index 000000000..d19c1695a --- /dev/null +++ b/tests/unit/simulation/test_flight_branch.py @@ -0,0 +1,129 @@ +"""Unit tests for rocketpy.simulation.FlightBranch.""" + +import pytest + +from rocketpy.simulation import FlightBranch + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +class _FakeBody: + name = "fake_body" + + +# --------------------------------------------------------------------------- +# FlightBranch initialization +# --------------------------------------------------------------------------- + + +class TestFlightBranchInit: + """FlightBranch stores its constructor arguments correctly.""" + + def test_body_stored(self): + """body attribute is stored.""" + body = _FakeBody() + branch = FlightBranch(body=body) + assert branch.body is body + + def test_default_start_time_is_zero(self): + """start_time defaults to 0.0 when not provided.""" + branch = FlightBranch(body=None) + assert branch.start_time == pytest.approx(0.0) + + def test_custom_start_time(self): + """start_time is stored from the constructor argument.""" + branch = FlightBranch(body=None, start_time=5.5) + assert branch.start_time == pytest.approx(5.5) + + def test_parent_defaults_to_none(self): + """parent is None when not provided.""" + branch = FlightBranch(body=None) + assert branch.parent is None + + def test_children_starts_empty(self): + """children list starts empty.""" + branch = FlightBranch(body=None) + assert branch.children == [] + + def test_events_starts_empty(self): + """events list starts empty when not provided.""" + branch = FlightBranch(body=None) + assert branch.events == [] + + def test_events_stored(self): + """events list is stored from constructor argument.""" + ev = object() + branch = FlightBranch(body=None, events=[ev]) + assert ev in branch.events + + +# --------------------------------------------------------------------------- +# Tree structure +# --------------------------------------------------------------------------- + + +class TestFlightBranchTree: + """FlightBranch tracks parent/child relationships.""" + + def test_is_root_for_no_parent(self): + """is_root() returns True when parent is None.""" + root = FlightBranch(body=None) + assert root.is_root() is True + + def test_is_not_root_when_parent_set(self): + """is_root() returns False when a parent is set.""" + root = FlightBranch(body=None) + child = FlightBranch(body=None, parent=root) + assert child.is_root() is False + + def test_parent_registers_child(self): + """Creating a child with a parent registers it in parent.children.""" + root = FlightBranch(body=None) + child = FlightBranch(body=None, parent=root) + assert child in root.children + + def test_spawn_child_creates_child(self): + """spawn_child() creates and registers a child branch.""" + root = FlightBranch(body=_FakeBody(), start_time=0.0) + child = root.spawn_child(body=_FakeBody(), start_time=10.0) + assert child.parent is root + assert child in root.children + + def test_spawn_child_inherits_start_state_if_not_given(self): + """spawn_child inherits parent start_state when not provided.""" + state = {"x": 0} + root = FlightBranch(body=None, start_state=state) + child = root.spawn_child(body=None) + assert child.start_state is state + + def test_spawn_child_uses_provided_start_time(self): + """spawn_child uses the provided start_time.""" + root = FlightBranch(body=None, start_time=0.0) + child = root.spawn_child(body=None, start_time=42.0) + assert child.start_time == pytest.approx(42.0) + + def test_multiple_children(self): + """A single parent can have multiple child branches.""" + root = FlightBranch(body=None) + c1 = root.spawn_child(body=_FakeBody()) + c2 = root.spawn_child(body=_FakeBody()) + assert len(root.children) == 2 + assert c1 in root.children + assert c2 in root.children + + +# --------------------------------------------------------------------------- +# Repr +# --------------------------------------------------------------------------- + + +class TestFlightBranchRepr: + """FlightBranch has a useful __repr__.""" + + def test_repr_contains_body_name(self): + """__repr__ includes the body name.""" + body = _FakeBody() + branch = FlightBranch(body=body) + assert "fake_body" in repr(branch)