isar 1.15.0__py3-none-any.whl → 1.34.9__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- isar/__init__.py +2 -5
- isar/apis/api.py +159 -66
- isar/apis/models/__init__.py +0 -1
- isar/apis/models/models.py +22 -12
- isar/apis/models/start_mission_definition.py +128 -123
- isar/apis/robot_control/robot_controller.py +41 -0
- isar/apis/schedule/scheduling_controller.py +135 -121
- isar/apis/security/authentication.py +5 -5
- isar/config/certs/ca-cert.pem +32 -32
- isar/config/keyvault/keyvault_service.py +1 -2
- isar/config/log.py +47 -39
- isar/config/logging.conf +16 -31
- isar/config/open_telemetry.py +102 -0
- isar/config/predefined_mission_definition/default_exr.json +49 -0
- isar/config/predefined_mission_definition/default_mission.json +1 -5
- isar/config/predefined_mission_definition/default_turtlebot.json +4 -11
- isar/config/predefined_missions/default.json +67 -87
- isar/config/predefined_missions/default_extra_capabilities.json +107 -0
- isar/config/settings.py +119 -142
- isar/eventhandlers/eventhandler.py +123 -0
- isar/mission_planner/local_planner.py +6 -20
- isar/mission_planner/mission_planner_interface.py +1 -1
- isar/models/events.py +184 -0
- isar/models/status.py +18 -0
- isar/modules.py +118 -205
- isar/robot/robot.py +377 -0
- isar/robot/robot_battery.py +60 -0
- isar/robot/robot_monitor_mission.py +357 -0
- isar/robot/robot_pause_mission.py +74 -0
- isar/robot/robot_resume_mission.py +67 -0
- isar/robot/robot_start_mission.py +66 -0
- isar/robot/robot_status.py +61 -0
- isar/robot/robot_stop_mission.py +68 -0
- isar/robot/robot_upload_inspection.py +75 -0
- isar/script.py +171 -0
- isar/services/service_connections/mqtt/mqtt_client.py +47 -11
- isar/services/service_connections/mqtt/robot_heartbeat_publisher.py +32 -0
- isar/services/service_connections/mqtt/robot_info_publisher.py +4 -3
- isar/services/service_connections/persistent_memory.py +69 -0
- isar/services/utilities/mqtt_utilities.py +93 -0
- isar/services/utilities/robot_utilities.py +20 -0
- isar/services/utilities/scheduling_utilities.py +393 -65
- isar/state_machine/state_machine.py +227 -486
- isar/state_machine/states/__init__.py +0 -7
- isar/state_machine/states/await_next_mission.py +114 -0
- isar/state_machine/states/blocked_protective_stop.py +60 -0
- isar/state_machine/states/going_to_lockdown.py +95 -0
- isar/state_machine/states/going_to_recharging.py +92 -0
- isar/state_machine/states/home.py +115 -0
- isar/state_machine/states/intervention_needed.py +77 -0
- isar/state_machine/states/lockdown.py +38 -0
- isar/state_machine/states/maintenance.py +36 -0
- isar/state_machine/states/monitor.py +137 -166
- isar/state_machine/states/offline.py +60 -0
- isar/state_machine/states/paused.py +92 -23
- isar/state_machine/states/pausing.py +48 -0
- isar/state_machine/states/pausing_return_home.py +48 -0
- isar/state_machine/states/recharging.py +80 -0
- isar/state_machine/states/resuming.py +57 -0
- isar/state_machine/states/resuming_return_home.py +64 -0
- isar/state_machine/states/return_home_paused.py +109 -0
- isar/state_machine/states/returning_home.py +217 -0
- isar/state_machine/states/stopping.py +61 -0
- isar/state_machine/states/stopping_due_to_maintenance.py +61 -0
- isar/state_machine/states/stopping_go_to_lockdown.py +60 -0
- isar/state_machine/states/stopping_go_to_recharge.py +51 -0
- isar/state_machine/states/stopping_return_home.py +77 -0
- isar/state_machine/states/unknown_status.py +72 -0
- isar/state_machine/states_enum.py +22 -5
- isar/state_machine/transitions/mission.py +192 -0
- isar/state_machine/transitions/return_home.py +106 -0
- isar/state_machine/transitions/robot_status.py +80 -0
- isar/state_machine/utils/common_event_handlers.py +73 -0
- isar/storage/blob_storage.py +71 -45
- isar/storage/local_storage.py +28 -14
- isar/storage/storage_interface.py +28 -6
- isar/storage/uploader.py +184 -55
- isar/storage/utilities.py +35 -27
- isar-1.34.9.dist-info/METADATA +496 -0
- isar-1.34.9.dist-info/RECORD +135 -0
- {isar-1.15.0.dist-info → isar-1.34.9.dist-info}/WHEEL +1 -1
- isar-1.34.9.dist-info/entry_points.txt +3 -0
- robot_interface/models/exceptions/__init__.py +0 -7
- robot_interface/models/exceptions/robot_exceptions.py +274 -4
- robot_interface/models/initialize/__init__.py +0 -1
- robot_interface/models/inspection/__init__.py +0 -13
- robot_interface/models/inspection/inspection.py +43 -34
- robot_interface/models/mission/mission.py +18 -14
- robot_interface/models/mission/status.py +20 -25
- robot_interface/models/mission/task.py +156 -92
- robot_interface/models/robots/battery_state.py +6 -0
- robot_interface/models/robots/media.py +13 -0
- robot_interface/models/robots/robot_model.py +7 -7
- robot_interface/robot_interface.py +135 -66
- robot_interface/telemetry/mqtt_client.py +84 -12
- robot_interface/telemetry/payloads.py +111 -12
- robot_interface/utilities/json_service.py +7 -1
- isar/config/predefined_missions/default_turtlebot.json +0 -110
- isar/config/predefined_poses/__init__.py +0 -0
- isar/config/predefined_poses/predefined_poses.py +0 -616
- isar/config/settings.env +0 -26
- isar/mission_planner/sequential_task_selector.py +0 -23
- isar/mission_planner/task_selector_interface.py +0 -31
- isar/models/communication/__init__.py +0 -0
- isar/models/communication/message.py +0 -12
- isar/models/communication/queues/__init__.py +0 -4
- isar/models/communication/queues/queue_io.py +0 -12
- isar/models/communication/queues/queue_timeout_error.py +0 -2
- isar/models/communication/queues/queues.py +0 -19
- isar/models/communication/queues/status_queue.py +0 -20
- isar/models/mission_metadata/__init__.py +0 -0
- isar/services/readers/__init__.py +0 -0
- isar/services/readers/base_reader.py +0 -37
- isar/services/service_connections/mqtt/robot_status_publisher.py +0 -93
- isar/services/service_connections/stid/__init__.py +0 -0
- isar/services/service_connections/stid/stid_service.py +0 -45
- isar/services/utilities/queue_utilities.py +0 -39
- isar/state_machine/states/idle.py +0 -40
- isar/state_machine/states/initialize.py +0 -60
- isar/state_machine/states/initiate.py +0 -129
- isar/state_machine/states/off.py +0 -18
- isar/state_machine/states/stop.py +0 -78
- isar/storage/slimm_storage.py +0 -181
- isar-1.15.0.dist-info/METADATA +0 -417
- isar-1.15.0.dist-info/RECORD +0 -113
- robot_interface/models/initialize/initialize_params.py +0 -9
- robot_interface/models/mission/step.py +0 -211
- {isar-1.15.0.dist-info → isar-1.34.9.dist-info/licenses}/LICENSE +0 -0
- {isar-1.15.0.dist-info → isar-1.34.9.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
from typing import TYPE_CHECKING, Callable, Optional
|
|
2
|
+
|
|
3
|
+
from isar.apis.models.models import ControlMissionResponse, MissionStartResponse
|
|
4
|
+
from isar.models.events import Event
|
|
5
|
+
from robot_interface.models.mission.mission import Mission
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from isar.state_machine.state_machine import StateMachine
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def start_mission_event_handler(
|
|
12
|
+
state_machine: "StateMachine",
|
|
13
|
+
event: Event[Mission],
|
|
14
|
+
response: Event[MissionStartResponse],
|
|
15
|
+
) -> Optional[Callable]:
|
|
16
|
+
mission: Optional[Mission] = event.consume_event()
|
|
17
|
+
if not mission:
|
|
18
|
+
return None
|
|
19
|
+
|
|
20
|
+
if not state_machine.battery_level_is_above_mission_start_threshold():
|
|
21
|
+
response.trigger_event(
|
|
22
|
+
MissionStartResponse(
|
|
23
|
+
mission_id=mission.id,
|
|
24
|
+
mission_started=False,
|
|
25
|
+
mission_not_started_reason="Robot battery too low",
|
|
26
|
+
)
|
|
27
|
+
)
|
|
28
|
+
return None
|
|
29
|
+
state_machine.start_mission(mission=mission)
|
|
30
|
+
response.trigger_event(MissionStartResponse(mission_started=True))
|
|
31
|
+
return state_machine.start_mission_monitoring # type: ignore
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def return_home_event_handler(
|
|
35
|
+
state_machine: "StateMachine", event: Event[bool]
|
|
36
|
+
) -> Optional[Callable]:
|
|
37
|
+
if not event.consume_event():
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
state_machine.events.api_requests.return_home.response.trigger_event(True)
|
|
41
|
+
state_machine.start_return_home_mission()
|
|
42
|
+
return state_machine.start_return_home_monitoring # type: ignore
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def stop_mission_event_handler(
|
|
46
|
+
state_machine: "StateMachine", event: Event[str]
|
|
47
|
+
) -> Optional[Callable]:
|
|
48
|
+
mission_id: str = event.consume_event()
|
|
49
|
+
if mission_id is None:
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
if state_machine.shared_state.mission_id.check() == mission_id or mission_id == "":
|
|
53
|
+
state_machine.events.api_requests.stop_mission.response.trigger_event(
|
|
54
|
+
ControlMissionResponse(success=True)
|
|
55
|
+
)
|
|
56
|
+
state_machine.events.state_machine_events.stop_mission.trigger_event(True)
|
|
57
|
+
return state_machine.stop # type: ignore
|
|
58
|
+
else:
|
|
59
|
+
state_machine.events.api_requests.stop_mission.response.trigger_event(
|
|
60
|
+
ControlMissionResponse(success=False, failure_reason="Mission not found")
|
|
61
|
+
)
|
|
62
|
+
return None
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def mission_started_event_handler(
|
|
66
|
+
state_machine: "StateMachine",
|
|
67
|
+
event: Event[bool],
|
|
68
|
+
) -> Optional[Callable]:
|
|
69
|
+
if not event.consume_event():
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
state_machine.logger.info("Received confirmation that mission has started")
|
|
73
|
+
return None
|
isar/storage/blob_storage.py
CHANGED
|
@@ -2,74 +2,100 @@ import logging
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from azure.core.exceptions import ResourceExistsError
|
|
5
|
-
from azure.storage.blob import
|
|
6
|
-
from injector import inject
|
|
5
|
+
from azure.storage.blob import BlobServiceClient, ContainerClient
|
|
7
6
|
|
|
8
7
|
from isar.config.keyvault.keyvault_service import Keyvault
|
|
9
8
|
from isar.config.settings import settings
|
|
10
|
-
from
|
|
11
|
-
|
|
9
|
+
from isar.storage.storage_interface import (
|
|
10
|
+
BlobStoragePath,
|
|
11
|
+
StorageException,
|
|
12
|
+
StorageInterface,
|
|
13
|
+
StoragePaths,
|
|
14
|
+
)
|
|
12
15
|
from isar.storage.utilities import construct_metadata_file, construct_paths
|
|
13
|
-
from robot_interface.models.inspection.inspection import
|
|
16
|
+
from robot_interface.models.inspection.inspection import InspectionBlob
|
|
17
|
+
from robot_interface.models.mission.mission import Mission
|
|
14
18
|
|
|
15
19
|
|
|
16
20
|
class BlobStorage(StorageInterface):
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
self.container_name = container_name
|
|
26
|
-
|
|
27
|
-
self.blob_service_client = self._get_blob_service_client()
|
|
28
|
-
self.container_client = self._get_container_client(
|
|
29
|
-
blob_service_client=self.blob_service_client
|
|
21
|
+
def __init__(self, keyvault: Keyvault) -> None:
|
|
22
|
+
self.logger = logging.getLogger("uploader")
|
|
23
|
+
|
|
24
|
+
self.container_client_data = self._get_container_client(
|
|
25
|
+
keyvault, "AZURE-STORAGE-CONNECTION-STRING-DATA"
|
|
26
|
+
)
|
|
27
|
+
self.container_client_metadata = self._get_container_client(
|
|
28
|
+
keyvault, "AZURE-STORAGE-CONNECTION-STRING-METADATA"
|
|
30
29
|
)
|
|
31
30
|
|
|
32
|
-
|
|
31
|
+
def _get_container_client(self, keyvault: Keyvault, secret_name: str):
|
|
32
|
+
storage_connection_string = keyvault.get_secret(secret_name).value
|
|
33
|
+
|
|
34
|
+
if storage_connection_string is None:
|
|
35
|
+
raise RuntimeError(f"{secret_name} from keyvault is None")
|
|
36
|
+
|
|
37
|
+
try:
|
|
38
|
+
blob_service_client = BlobServiceClient.from_connection_string(
|
|
39
|
+
storage_connection_string
|
|
40
|
+
)
|
|
41
|
+
except Exception as e:
|
|
42
|
+
self.logger.error("Unable to retrieve blob service client. Error: %s", e)
|
|
43
|
+
raise e
|
|
44
|
+
|
|
45
|
+
container_client = blob_service_client.get_container_client(
|
|
46
|
+
settings.BLOB_CONTAINER
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
if not container_client.exists():
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"The configured blob container %s does not exist",
|
|
52
|
+
settings.BLOB_CONTAINER,
|
|
53
|
+
)
|
|
54
|
+
return container_client
|
|
55
|
+
|
|
56
|
+
def store(
|
|
57
|
+
self, inspection: InspectionBlob, mission: Mission
|
|
58
|
+
) -> StoragePaths[BlobStoragePath]:
|
|
59
|
+
if inspection.data is None:
|
|
60
|
+
raise StorageException("Nothing to store. The inspection data is empty")
|
|
33
61
|
|
|
34
|
-
|
|
35
|
-
data_path, metadata_path = construct_paths(
|
|
62
|
+
data_filename, metadata_filename = construct_paths(
|
|
36
63
|
inspection=inspection, mission=mission
|
|
37
64
|
)
|
|
38
65
|
|
|
39
66
|
metadata_bytes: bytes = construct_metadata_file(
|
|
40
|
-
inspection=inspection, mission=mission, filename=
|
|
67
|
+
inspection=inspection, mission=mission, filename=data_filename.name
|
|
41
68
|
)
|
|
42
69
|
|
|
43
|
-
self._upload_file(
|
|
44
|
-
|
|
70
|
+
data_path = self._upload_file(
|
|
71
|
+
filename=data_filename,
|
|
72
|
+
data=inspection.data,
|
|
73
|
+
container_client=self.container_client_data,
|
|
74
|
+
)
|
|
75
|
+
metadata_path = self._upload_file(
|
|
76
|
+
filename=metadata_filename,
|
|
77
|
+
data=metadata_bytes,
|
|
78
|
+
container_client=self.container_client_metadata,
|
|
79
|
+
)
|
|
80
|
+
return StoragePaths(data_path=data_path, metadata_path=metadata_path)
|
|
45
81
|
|
|
46
|
-
def _upload_file(
|
|
47
|
-
|
|
82
|
+
def _upload_file(
|
|
83
|
+
self, filename: Path, data: bytes, container_client: ContainerClient
|
|
84
|
+
) -> BlobStoragePath:
|
|
85
|
+
blob_client = container_client.get_blob_client(filename.as_posix())
|
|
48
86
|
try:
|
|
49
|
-
|
|
87
|
+
blob_client.upload_blob(data=data)
|
|
50
88
|
except ResourceExistsError as e:
|
|
51
89
|
self.logger.error(
|
|
52
|
-
|
|
90
|
+
"Blob %s already exists in container. Error: %s", filename.as_posix(), e
|
|
53
91
|
)
|
|
54
92
|
raise StorageException from e
|
|
55
93
|
except Exception as e:
|
|
56
94
|
self.logger.error("An unexpected error occurred while uploading blob")
|
|
57
95
|
raise StorageException from e
|
|
58
|
-
return blob_properties["etag"]
|
|
59
96
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
except Exception as e:
|
|
66
|
-
self.logger.error(f"Unable to retrieve blob service client. Error: {e}")
|
|
67
|
-
raise e
|
|
68
|
-
|
|
69
|
-
def _get_container_client(
|
|
70
|
-
self, blob_service_client: BlobServiceClient
|
|
71
|
-
) -> ContainerClient:
|
|
72
|
-
return blob_service_client.get_container_client(self.container_name)
|
|
73
|
-
|
|
74
|
-
def _get_blob_client(self, path_to_blob: Path) -> BlobClient:
|
|
75
|
-
return self.container_client.get_blob_client(path_to_blob.as_posix())
|
|
97
|
+
return BlobStoragePath(
|
|
98
|
+
storage_account=settings.BLOB_STORAGE_ACCOUNT,
|
|
99
|
+
blob_container=settings.BLOB_CONTAINER,
|
|
100
|
+
blob_name=blob_client.blob_name,
|
|
101
|
+
)
|
isar/storage/local_storage.py
CHANGED
|
@@ -2,10 +2,15 @@ import logging
|
|
|
2
2
|
from pathlib import Path
|
|
3
3
|
|
|
4
4
|
from isar.config.settings import settings
|
|
5
|
-
from
|
|
6
|
-
|
|
5
|
+
from isar.storage.storage_interface import (
|
|
6
|
+
LocalStoragePath,
|
|
7
|
+
StorageException,
|
|
8
|
+
StorageInterface,
|
|
9
|
+
StoragePaths,
|
|
10
|
+
)
|
|
7
11
|
from isar.storage.utilities import construct_metadata_file, construct_paths
|
|
8
|
-
from robot_interface.models.inspection.inspection import
|
|
12
|
+
from robot_interface.models.inspection.inspection import InspectionBlob
|
|
13
|
+
from robot_interface.models.mission.mission import Mission
|
|
9
14
|
|
|
10
15
|
|
|
11
16
|
class LocalStorage(StorageInterface):
|
|
@@ -13,29 +18,35 @@ class LocalStorage(StorageInterface):
|
|
|
13
18
|
self.root_folder: Path = Path(settings.LOCAL_STORAGE_PATH)
|
|
14
19
|
self.logger = logging.getLogger("uploader")
|
|
15
20
|
|
|
16
|
-
def store(
|
|
17
|
-
|
|
21
|
+
def store(
|
|
22
|
+
self, inspection: InspectionBlob, mission: Mission
|
|
23
|
+
) -> StoragePaths[LocalStoragePath]:
|
|
24
|
+
if inspection.data is None:
|
|
25
|
+
raise StorageException("Nothing to store. The inspection data is empty")
|
|
26
|
+
|
|
27
|
+
local_filename, local_metadata_filename = construct_paths(
|
|
18
28
|
inspection=inspection, mission=mission
|
|
19
29
|
)
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
data_path: Path = self.root_folder.joinpath(local_filename)
|
|
32
|
+
metadata_path: Path = self.root_folder.joinpath(local_metadata_filename)
|
|
23
33
|
|
|
24
|
-
|
|
34
|
+
data_path.parent.mkdir(parents=True, exist_ok=True)
|
|
25
35
|
|
|
26
36
|
metadata_bytes: bytes = construct_metadata_file(
|
|
27
|
-
inspection=inspection, mission=mission, filename=
|
|
37
|
+
inspection=inspection, mission=mission, filename=local_filename.name
|
|
28
38
|
)
|
|
29
39
|
try:
|
|
30
|
-
with
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
with (
|
|
41
|
+
open(data_path, "wb") as file,
|
|
42
|
+
open(metadata_path, "wb") as metadata_file,
|
|
43
|
+
):
|
|
33
44
|
file.write(inspection.data)
|
|
34
45
|
metadata_file.write(metadata_bytes)
|
|
35
46
|
except IOError as e:
|
|
36
47
|
self.logger.warning(
|
|
37
48
|
f"Failed open/write for one of the following files: \n"
|
|
38
|
-
f"{
|
|
49
|
+
f"{data_path}\n{metadata_path}"
|
|
39
50
|
)
|
|
40
51
|
raise StorageException from e
|
|
41
52
|
except Exception as e:
|
|
@@ -43,4 +54,7 @@ class LocalStorage(StorageInterface):
|
|
|
43
54
|
"An unexpected error occurred while writing to local storage"
|
|
44
55
|
)
|
|
45
56
|
raise StorageException from e
|
|
46
|
-
return
|
|
57
|
+
return StoragePaths(
|
|
58
|
+
data_path=LocalStoragePath(file_path=data_path),
|
|
59
|
+
metadata_path=LocalStoragePath(file_path=metadata_path),
|
|
60
|
+
)
|
|
@@ -1,24 +1,46 @@
|
|
|
1
1
|
from abc import ABCMeta, abstractmethod
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from typing import Generic, TypeVar
|
|
2
4
|
|
|
5
|
+
from pydantic import BaseModel
|
|
6
|
+
|
|
7
|
+
from robot_interface.models.inspection.inspection import InspectionBlob
|
|
3
8
|
from robot_interface.models.mission.mission import Mission
|
|
4
|
-
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class BlobStoragePath(BaseModel):
|
|
12
|
+
storage_account: str
|
|
13
|
+
blob_container: str
|
|
14
|
+
blob_name: str
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class LocalStoragePath(BaseModel):
|
|
18
|
+
file_path: Path
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
TPath = TypeVar("TPath", BlobStoragePath, LocalStoragePath)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class StoragePaths(BaseModel, Generic[TPath]):
|
|
25
|
+
data_path: TPath
|
|
26
|
+
metadata_path: TPath
|
|
5
27
|
|
|
6
28
|
|
|
7
29
|
class StorageInterface(metaclass=ABCMeta):
|
|
8
30
|
@abstractmethod
|
|
9
|
-
def store(self, inspection:
|
|
31
|
+
def store(self, inspection: InspectionBlob, mission: Mission) -> StoragePaths:
|
|
10
32
|
"""
|
|
11
33
|
Parameters
|
|
12
34
|
----------
|
|
35
|
+
inspection : InspectionBlob
|
|
36
|
+
The inspection object to be stored.
|
|
13
37
|
mission : Mission
|
|
14
38
|
Mission the inspection is a part of.
|
|
15
|
-
inspection : Inspection
|
|
16
|
-
The inspection object to be stored.
|
|
17
39
|
|
|
18
40
|
Returns
|
|
19
41
|
----------
|
|
20
|
-
|
|
21
|
-
|
|
42
|
+
StoragePaths
|
|
43
|
+
Paths to the data and metadata
|
|
22
44
|
|
|
23
45
|
Raises
|
|
24
46
|
----------
|