healthcare_phony 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
Files changed (60) hide show
  1. checksums.yaml +7 -0
  2. data/.gitattributes +1 -0
  3. data/.github/workflows/gem-push.yml +42 -0
  4. data/.github/workflows/ruby.yml +33 -0
  5. data/.gitignore +64 -0
  6. data/.rdoc_options +23 -0
  7. data/.rubocop.yml +10 -0
  8. data/CODE_OF_CONDUCT.md +84 -0
  9. data/Gemfile +8 -0
  10. data/LICENSE.txt +21 -0
  11. data/README.md +285 -0
  12. data/Rakefile +12 -0
  13. data/VERSION +1 -0
  14. data/healthcare_phony.gemspec +36 -0
  15. data/lib/healthcare_phony.rb +88 -0
  16. data/lib/healthcare_phony/address.rb +89 -0
  17. data/lib/healthcare_phony/assigning_authority.rb +6 -0
  18. data/lib/healthcare_phony/cell_phone_number.rb +20 -0
  19. data/lib/healthcare_phony/data_files/address_type.yml +7 -0
  20. data/lib/healthcare_phony/data_files/adt_event_types.yml +59 -0
  21. data/lib/healthcare_phony/data_files/degree.yml +9 -0
  22. data/lib/healthcare_phony/data_files/discharge_disposition.yml +15 -0
  23. data/lib/healthcare_phony/data_files/ethnic_group.yml +10 -0
  24. data/lib/healthcare_phony/data_files/hl7_message_types.yml +4 -0
  25. data/lib/healthcare_phony/data_files/language.yml +7 -0
  26. data/lib/healthcare_phony/data_files/marital_status.yml +49 -0
  27. data/lib/healthcare_phony/data_files/mdm_event_types.yml +12 -0
  28. data/lib/healthcare_phony/data_files/oru_event_types.yml +2 -0
  29. data/lib/healthcare_phony/data_files/race.yml +13 -0
  30. data/lib/healthcare_phony/data_files/religion.yml +250 -0
  31. data/lib/healthcare_phony/data_files/tele_equipment_type.yml +10 -0
  32. data/lib/healthcare_phony/data_files/tele_use_code.yml +9 -0
  33. data/lib/healthcare_phony/diagnosis.rb +12 -0
  34. data/lib/healthcare_phony/doctor.rb +25 -0
  35. data/lib/healthcare_phony/email.rb +25 -0
  36. data/lib/healthcare_phony/ethnic_group.rb +34 -0
  37. data/lib/healthcare_phony/gender.rb +22 -0
  38. data/lib/healthcare_phony/helper.rb +72 -0
  39. data/lib/healthcare_phony/hl7_message.rb +136 -0
  40. data/lib/healthcare_phony/home_phone_number.rb +20 -0
  41. data/lib/healthcare_phony/identifier.rb +23 -0
  42. data/lib/healthcare_phony/insurance.rb +6 -0
  43. data/lib/healthcare_phony/language.rb +30 -0
  44. data/lib/healthcare_phony/marital_status.rb +31 -0
  45. data/lib/healthcare_phony/patient.rb +114 -0
  46. data/lib/healthcare_phony/patient_visit.rb +96 -0
  47. data/lib/healthcare_phony/person_name.rb +104 -0
  48. data/lib/healthcare_phony/phone_number.rb +85 -0
  49. data/lib/healthcare_phony/procedure.rb +6 -0
  50. data/lib/healthcare_phony/race.rb +30 -0
  51. data/lib/healthcare_phony/religion.rb +32 -0
  52. data/lib/healthcare_phony/templates/adt_example.erb +6 -0
  53. data/lib/healthcare_phony/templates/csv_example.erb +4 -0
  54. data/lib/healthcare_phony/version.rb +5 -0
  55. data/lib/healthcare_phony/visit_admission.rb +53 -0
  56. data/lib/healthcare_phony/visit_discharge.rb +62 -0
  57. data/lib/healthcare_phony/visit_doctors.rb +19 -0
  58. data/lib/healthcare_phony/visit_location.rb +106 -0
  59. data/lib/healthcare_phony/work_phone_number.rb +20 -0
  60. metadata +185 -0
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HealthcarePhony
4
+ # Public: Generates information needed to build an HL7v2 message
5
+ class Hl7Message
6
+ attr_accessor :message_type,
7
+ :trigger_event,
8
+ :message_control_id,
9
+ :version,
10
+ :sending_application,
11
+ :sending_facility,
12
+ :receiving_application,
13
+ :receiving_facility,
14
+ :message_datetime,
15
+ :processing_id
16
+
17
+ # Public: Initializes an Address. Pass in hash of different parameters, currently this includes:
18
+ # version - HL7v2 version (MSH.12)
19
+ # processing_id - Typically P or T (MSH.11)
20
+ # types - Array of Message Types (MSH.9.1) to randomly choose from. Specified as comma separated String or
21
+ # Ruby array.
22
+ # message_type_file - Location of file containing Message Types (MSH.9.1). If not specified then included
23
+ # {hl7_message_types.yml}[https://github.com/austinmoody/healthcare_phony/blob/main/lib/healthcare_phony/data_files/hl7_message_types.yml] file will be used.
24
+ # events - Array of Trigger Events (MSH.9.2) to randomly choose from. Specified as command separated String or
25
+ # Ruby array.
26
+ # adt_events_file - Location of file containing ADT Trigger Events (MSH.9.2). If not specified then included
27
+ # {adt_event_types.yml}[https://github.com/austinmoody/healthcare_phony/blob/main/lib/healthcare_phony/data_files/adt_event_types.yml] file will be used.
28
+ # oru_events_file - Location of file containing ORU Trigger Events (MSH.9.2). If not specified then included
29
+ # {oru_event_types.yml}[https://github.com/austinmoody/healthcare_phony/blob/main/lib/healthcare_phony/data_files/adt_event_types.yml] file will be used.
30
+ # mdm_events_file - Location of file containing MDM Trigger Events (MSH.9.2). If not specified then included
31
+ # {mdm_event_types.yml}[https://github.com/austinmoody/healthcare_phony/blob/main/lib/healthcare_phony/data_files/adt_event_types.yml] file will be used.
32
+ # control_id_pattern - Regex pattern used to randomly generate MSH.10 values. Default is PHONY\d{10} which will
33
+ # generate a value like: PHONY6850295805
34
+ # sending_facility - Array of Sending Facilities (MSH.4) to randomly choose from. Specified as comma separated
35
+ # String or Ruby Array.
36
+ # sending_application - Array of Sending Applications (MSH.3) to randomly choose from. Specified as comma
37
+ # separated String or Ruby Array.
38
+ # receiving_application - Array of Receiving Applications (MSH.5) to randomly choose from. Specified as comma
39
+ # separated String or Ruby Array.
40
+ # receiving_facility - Array of Receiving Facilities (MSH.6) to randomly choose from. Specified as comma separated
41
+ # String or Ruby Array.
42
+ def initialize(**init_args)
43
+ define_message_type(init_args)
44
+ define_trigger_event(init_args)
45
+ define_control_id(init_args)
46
+ @version = init_args[:version].nil? ? '2.5.1' : init_args[:version]
47
+ define_sending_facility(init_args)
48
+ define_sending_application(init_args)
49
+ define_receiving_application(init_args)
50
+ define_receiving_facility(init_args)
51
+
52
+ # Potential use case to allow you to provide begin/end date?
53
+ @message_datetime = Time.now
54
+
55
+ @processing_id = init_args[:processing_id].nil? ? 'P' : init_args[:processing_id]
56
+ end
57
+
58
+ private
59
+
60
+ def define_message_type(**init_args)
61
+ file_name = "#{::File.expand_path(::File.join("..", "data_files"), __FILE__)}/hl7_message_types.yml"
62
+ file_name = init_args[:message_type_file] unless init_args[:message_type_file].nil?
63
+ hl7_message_types = if !init_args[:types].nil?
64
+ Helper.get_array(init_args[:types])
65
+ else
66
+ Psych.load_file(file_name)
67
+ end
68
+ @message_type = hl7_message_types.nil? ? '' : hl7_message_types.sample
69
+ end
70
+
71
+ def define_trigger_event(**init_args)
72
+ @trigger_event = Helper.get_array(init_args[:events]).sample
73
+ @trigger_event = define_adt_trigger_event(init_args) unless @message_type != 'ADT'
74
+ @trigger_event = define_oru_trigger_event(init_args) unless @message_type != 'ORU'
75
+ @trigger_event = define_mdm_trigger_event(init_args) unless @message_type != 'MDM'
76
+ end
77
+
78
+ def define_adt_trigger_event(**init_args)
79
+ event_types = get_adt_events(init_args)
80
+ event_types&.sample
81
+ end
82
+
83
+ def define_oru_trigger_event(**init_args)
84
+ event_types = get_oru_events(init_args)
85
+ event_types&.sample
86
+ end
87
+
88
+ def define_mdm_trigger_event(**init_args)
89
+ event_types = get_mdm_events(init_args)
90
+ event_types&.sample
91
+ end
92
+
93
+ def get_adt_events(**init_args)
94
+ file_name = "#{::File.expand_path(::File.join("..", "data_files"), __FILE__)}/adt_event_types.yml"
95
+ file_name = init_args[:adt_events_file] unless init_args[:adt_events_file].nil?
96
+ Psych.load_file(file_name)
97
+ end
98
+
99
+ def get_oru_events(**init_args)
100
+ file_name = "#{::File.expand_path(::File.join("..", "data_files"), __FILE__)}/oru_event_types.yml"
101
+ file_name = init_args[:oru_events_file] unless init_args[:oru_events_file].nil?
102
+ Psych.load_file(file_name)
103
+ end
104
+
105
+ def get_mdm_events(**init_args)
106
+ file_name = "#{::File.expand_path(::File.join("..", "data_files"), __FILE__)}/mdm_event_types.yml"
107
+ file_name = init_args[:mdm_events_file] unless init_args[:mdm_events_file].nil?
108
+ Psych.load_file(file_name)
109
+ end
110
+
111
+ def define_control_id(**init_args)
112
+ control_id_pattern = init_args[:control_id_pattern].nil? ? 'PHONY\d{10}' : init_args[:control_id_pattern]
113
+ @message_control_id = Regexp.new(control_id_pattern).random_example
114
+ end
115
+
116
+ def define_sending_facility(**init_args)
117
+ sf_choices = Helper.get_array(init_args[:sending_facility])
118
+ @sending_facility = !sf_choices.empty? ? sf_choices.sample : ''
119
+ end
120
+
121
+ def define_sending_application(**init_args)
122
+ sa_choices = Helper.get_array(init_args[:sending_application])
123
+ @sending_application = !sa_choices.empty? ? sa_choices.sample : ''
124
+ end
125
+
126
+ def define_receiving_application(**init_args)
127
+ ra_choices = Helper.get_array(init_args[:receiving_application])
128
+ @receiving_application = !ra_choices.empty? ? ra_choices.sample : ''
129
+ end
130
+
131
+ def define_receiving_facility(**init_args)
132
+ rf_choices = Helper.get_array(init_args[:receiving_facility])
133
+ @receiving_facility = !rf_choices.empty? ? rf_choices.sample : ''
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,20 @@
1
+ # frozen_string_literal: true
2
+
3
+ require File.expand_path('phone_number', __dir__)
4
+
5
+ module HealthcarePhony
6
+ # Public: Generates a fake home phone number
7
+ class HomePhoneNumber < PhoneNumber
8
+ # Public: Initializes a home phone number. Pass in hash of different parameters, currently this includes:
9
+ # blank - An integer representing the % of times phone number components should be blank.
10
+ # use_code - Allows specification of the phone use code (PID.13.2)
11
+ # equipment_type - Allows specification of the phone equipment type (PID.13.3)
12
+ def initialize(**init_args)
13
+ super(init_args)
14
+ @use_code = init_args[:use_code].nil? ? 'PRN' : init_args[:use_code]
15
+ @use_code = '' unless @set_blank == false
16
+ @equipment_type = init_args[:equipment_type].nil? ? 'PH' : init_args[:equipment_type]
17
+ @equipment_type = '' unless @set_blank == false
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'regexp-examples'
4
+
5
+ module HealthcarePhony
6
+ # Public: Randomly generates an identifier.
7
+ class Identifier
8
+ attr_accessor :identifier,
9
+ :identifier_type_code
10
+
11
+ # Public: Initializes an Address. Pass in hash of different parameters, currently this includes:
12
+ # type_code - Identifier Type Code, example PID.3.5. HL7 Data Table 0203
13
+ # pattern - Regex pattern used to randomly generate the identifier. Default is \d{10} which would generate an
14
+ # identifier like 5992657933.
15
+ def initialize(**init_args)
16
+ @identifier_type_code = init_args[:type_code].nil? ? '' : init_args[:type_code]
17
+
18
+ identifier_pattern = init_args[:pattern].nil? ? '\d{10}' : init_args[:pattern]
19
+
20
+ @identifier = Regexp.new(identifier_pattern).random_example
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,6 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HealthcarePhony
4
+ class Insurance
5
+ end
6
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HealthcarePhony
4
+ # Public: Generate random Language using data from YAML file.
5
+ class Language
6
+ attr_accessor :code,
7
+ :description,
8
+ :coding_system
9
+
10
+ # Public: Initializes an Address. Pass in hash of different parameters, currently this includes:
11
+ # language_data_file - Location of YAML file containing Language data (Code, Description, and Coding System) if a
12
+ # different set of random values is desired. Otherwise the default file {language.yml}[https://github.com/austinmoody/healthcare_phony/blob/main/lib/healthcare_phony/data_files/language.yml] will be used.
13
+ def initialize(**init_args)
14
+ # TODO: allow a way for caller to pass in a custom set of codes to choose from.
15
+ # TODO: allow a way for caller to pass in % blank
16
+
17
+ data_file = if !init_args[:language_data_file].nil?
18
+ init_args[:language_data_file]
19
+ else
20
+ "#{::File.expand_path(::File.join("..", "data_files"), __FILE__)}/language.yml"
21
+ end
22
+ language_array = Psych.load_file(data_file)
23
+ random_language = language_array.nil? ? '' : language_array.sample
24
+
25
+ @code = random_language[:code]
26
+ @description = random_language[:description]
27
+ @coding_system = random_language[:coding_system]
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HealthcarePhony
4
+ # Public: Generates a random MaritalStatus using data from a YAML file.
5
+ class MaritalStatus
6
+ attr_accessor :code,
7
+ :description,
8
+ :coding_system
9
+
10
+ # Public: Initializes an Address. Pass in hash of different parameters, currently this includes:
11
+ # marital_status_data_file - Location of YAML file containing Language data (Code, Description, and Coding System)
12
+ # if a different set of random values is desired. Otherwise the default file {marital_status.yml}[https://github.com/austinmoody/healthcare_phony/blob/main/lib/healthcare_phony/data_files/marital_status.yml] will be used.
13
+ def initialize(**init_args)
14
+ # TODO: allow a way for caller to pass in a custom set of codes to choose from
15
+ # TODO: allow a way for caller to pass in % blank
16
+
17
+ data_file = if !init_args[:marital_status_data_file].nil?
18
+ init_args[:marital_status_data_file]
19
+ else
20
+ "#{::File.expand_path(::File.join("..", "data_files"), __FILE__)}/marital_status.yml"
21
+ end
22
+ ms_array = Psych.load_file(data_file)
23
+
24
+ random_ms = ms_array.nil? ? '' : ms_array.sample
25
+
26
+ @code = random_ms[:code]
27
+ @description = random_ms[:description]
28
+ @coding_system = random_ms[:coding_system]
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,114 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HealthcarePhony
4
+ # Public: Randomly generate a Patient
5
+ class Patient
6
+ attr_accessor :names,
7
+ :medical_record_number,
8
+ :account_number,
9
+ :addresses,
10
+ :date_of_birth,
11
+ :gender,
12
+ :races,
13
+ :home_phone, # TODO: allow for > 1
14
+ :cell_phone, # TODO: allow for > 1
15
+ :work_phone, # TODO: allow for > 1
16
+ :email,
17
+ :language,
18
+ :marital_status,
19
+ :religion,
20
+ :ssn,
21
+ :drivers_license,
22
+ :ethnic_group,
23
+ :multiple_birth_indicator,
24
+ :birth_order,
25
+ :death_indicator,
26
+ :death_datetime
27
+
28
+ def initialize(**init_args)
29
+ define_gender(init_args)
30
+ define_names(init_args)
31
+ define_addresses(init_args)
32
+ define_phones(init_args)
33
+ define_dob(init_args)
34
+ define_race(init_args)
35
+ define_other
36
+ define_identifiers
37
+ define_birth_order
38
+ define_death
39
+ end
40
+
41
+ private
42
+
43
+ def define_gender(**init_args)
44
+ @gender = if !init_args[:gender].nil? && init_args[:gender].is_a?(HealthcarePhony::Gender)
45
+ init_args[:gender]
46
+ else
47
+ HealthcarePhony::Gender.new(init_args)
48
+ end
49
+ end
50
+
51
+ def define_names(**init_args)
52
+ init_args[:gender] = @gender
53
+ names_count = init_args[:names_count].nil? || init_args[:names_count] < 1 ? 1 : init_args[:names_count]
54
+ @names = []
55
+ while names_count.positive?
56
+ @names.push(PersonName.new(init_args))
57
+ names_count -= 1
58
+ end
59
+ end
60
+
61
+ def define_addresses(**init_args)
62
+ address_count = init_args[:address_count].nil? || init_args[:address_count] < 1 ? 1 : init_args[:address_count]
63
+ @addresses = []
64
+ while address_count.positive?
65
+ @addresses.push(Address.new)
66
+ address_count -= 1
67
+ end
68
+ end
69
+
70
+ def define_phones(**init_args)
71
+ @home_phone = HomePhoneNumber.new(init_args)
72
+ @cell_phone = CellPhoneNumber.new(init_args)
73
+ @work_phone = WorkPhoneNumber.new(init_args)
74
+ end
75
+
76
+ def define_dob(**init_args)
77
+ min_age = init_args[:min_age].nil? ? 1 : init_args[:min_age]
78
+ max_age = init_args[:max_age].nil? ? 99 : init_args[:max_age]
79
+ @date_of_birth = Faker::Date.birthday(min_age: min_age, max_age: max_age)
80
+ end
81
+
82
+ def define_race(**init_args)
83
+ races_count = init_args[:race_count].nil? || init_args[:race_count] < 1 ? 1 : init_args[:race_count]
84
+ @races = []
85
+ while races_count.positive?
86
+ @races.push(Race.new)
87
+ races_count -= 1
88
+ end
89
+ end
90
+
91
+ def define_identifiers
92
+ @medical_record_number = Identifier.new(type_code: 'MR')
93
+ @account_number = Identifier.new(type_code: 'AN')
94
+ @ssn = Faker::IDNumber.ssn_valid
95
+ end
96
+
97
+ def define_death
98
+ @death_indicator = %w[Y N].sample
99
+ @death_datetime = @death_indicator == 'Y' ? Faker::Time.between(from: @date_of_birth.to_date, to: Time.now) : ''
100
+ end
101
+
102
+ def define_birth_order
103
+ @multiple_birth_indicator = %w[Y N].sample
104
+ @birth_order = @multiple_birth_indicator == 'Y' ? /[1-2]/.random_example : ''
105
+ end
106
+
107
+ def define_other
108
+ @language = Language.new
109
+ @marital_status = MaritalStatus.new
110
+ @religion = Religion.new
111
+ @ethnic_group = EthnicGroup.new
112
+ end
113
+ end
114
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module HealthcarePhony
4
+ # Public: Randomly generates data for a PatientVisit (PV1 segment)
5
+ class PatientVisit
6
+ attr_accessor :patient_class,
7
+ :admission, :doctors,
8
+ :hospital_service, :readmission_indicator,
9
+ :ambulatory_status, :vip_indicator, :patient_type,
10
+ :visit_number, :bed_status,
11
+ :discharge, :location
12
+
13
+ # Public: Initializes an Address. Pass in hash of different parameters, currently this includes:
14
+ # hospital_service - Array of Hospital Service codes (PV1.10) to randomly choose from. Specified as comma separated
15
+ # String or Ruby array. Otherwise default HL7 v2.5.1 Table 0069 values are used.
16
+ # patient_class - Array of Patient Class codes (PV1.2) to randomly choose from. Specified as comma separated
17
+ # String or Ruby array. Otherwise default HL7 v2.5.1 Table 0004 values are used.
18
+ # ambulatory_status - Array of Ambulatory Status codes (PV1.15) to randomly choose from. Specified as comma
19
+ # separated String or Ruby array. Otherwise default HL7 v2.5.1 Table 0009 values are used.
20
+ # bed_status - Array of Bed Status codes (PV1.40) to randomly choose from. Specified as comma separated String or
21
+ # Ruby array. Otherwise default HL7 v2.5.1 Table 0116 values are used.
22
+ # patient_type - Array of Patient Type codes (PV1.18) to randomly choose from. Specified as comma separated String
23
+ # or Ruby array. Otherwise this field is left blank.
24
+ # vip_indicator - Array of Patient Type codes (PV1.18) to randomly choose from. Specified as comma separated String
25
+ # or Ruby array. Otherwise this field is left blank.
26
+ def initialize(**init_args)
27
+ @doctors = VisitDoctors.new
28
+ @location = VisitLocation.new(init_args)
29
+ @admission = VisitAdmission.new(init_args)
30
+ @bed_status = define_bed_status(init_args)
31
+ @visit_number = Identifier.new(type_code: 'VN')
32
+ @readmission_indicator = Helper.random_with_blank('R', 50)
33
+ @patient_type = define_patient_type(init_args)
34
+ @vip_indicator = define_vip(init_args)
35
+ @ambulatory_status = define_ambulatory_status(init_args)
36
+ @patient_class = define_patient_class(init_args)
37
+ @hospital_service = define_hospital_service(init_args)
38
+ @discharge = VisitDischarge.new(init_args.merge({ admit_datetime: @admission.datetime }))
39
+ end
40
+
41
+ private
42
+
43
+ def define_hospital_service(**init_args)
44
+ standard_hospital_service = %w[CAR MED PUL SUR URO]
45
+ hs_choices = Helper.get_array(init_args[:hospital_service])
46
+ if !hs_choices.empty?
47
+ hs_choices.sample
48
+ else
49
+ standard_hospital_service.sample
50
+ end
51
+ end
52
+
53
+ def define_patient_class(**init_args)
54
+ standard_pc_choices = %w[B C E I N O P R U]
55
+ pc_choices = Helper.get_array(init_args[:patient_class])
56
+ !pc_choices.empty? ? pc_choices.sample : standard_pc_choices.sample
57
+ end
58
+
59
+ def define_ambulatory_status(**init_args)
60
+ standard_ambulatory_status = %w[A0 A1 A2 A3 A4 A5 A6 A7 A8 A9 B1 B2 B3 B4 B5 B6]
61
+ as_choices = Helper.get_array(init_args[:ambulatory_status])
62
+ if !as_choices.empty?
63
+ as_choices.sample
64
+ else
65
+ standard_ambulatory_status.sample
66
+ end
67
+ end
68
+
69
+ def define_bed_status(**init_args)
70
+ bs_choices = Helper.get_array(init_args[:bed_status])
71
+ if !bs_choices.empty?
72
+ bs_choices.sample
73
+ else
74
+ %w[C H I K O U].sample
75
+ end
76
+ end
77
+
78
+ def define_patient_type(**init_args)
79
+ pt_choices = Helper.get_array(init_args[:patient_type])
80
+ if !pt_choices.empty?
81
+ pt_choices.sample
82
+ else
83
+ ''
84
+ end
85
+ end
86
+
87
+ def define_vip(**init_args)
88
+ vip_choices = Helper.get_array(init_args[:vip_indicator])
89
+ if !vip_choices.empty?
90
+ vip_choices.sample
91
+ else
92
+ ''
93
+ end
94
+ end
95
+ end
96
+ end