openehr 2.0.2 → 2.3.0

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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.rdoc +19 -6
  3. data/lib/openehr/am/archetype/constraint_model.rb +1 -1
  4. data/lib/openehr/aql/engine/contains_resolver.rb +98 -7
  5. data/lib/openehr/aql/engine/dataset.rb +3 -1
  6. data/lib/openehr/aql/engine/predicate_evaluator.rb +70 -3
  7. data/lib/openehr/aql/engine.rb +45 -12
  8. data/lib/openehr/assumed_library_types.rb +20 -17
  9. data/lib/openehr/parser/adl_parser.rb +0 -4
  10. data/lib/openehr/parser/opt_parser.rb +12 -429
  11. data/lib/openehr/parser/xml_archetype_parser.rb +255 -0
  12. data/lib/openehr/parser/xml_constraint_parsing.rb +264 -0
  13. data/lib/openehr/parser/xml_domain_type_parsing.rb +128 -0
  14. data/lib/openehr/parser/xml_primitive_parsing.rb +145 -0
  15. data/lib/openehr/parser.rb +1 -0
  16. data/lib/openehr/rm/common/generic.rb +6 -9
  17. data/lib/openehr/rm/common/resource.rb +0 -1
  18. data/lib/openehr/rm/composition/content/entry.rb +4 -1
  19. data/lib/openehr/rm/data_structures/item_structure/representation.rb +12 -2
  20. data/lib/openehr/rm/data_structures/item_structure.rb +0 -1
  21. data/lib/openehr/rm/data_types/basic.rb +11 -21
  22. data/lib/openehr/rm/data_types/encapsulated.rb +18 -2
  23. data/lib/openehr/rm/data_types/quantity/date_time.rb +203 -28
  24. data/lib/openehr/rm/data_types/quantity.rb +66 -10
  25. data/lib/openehr/rm/data_types/time_specification.rb +101 -27
  26. data/lib/openehr/rm/data_types/uri.rb +0 -8
  27. data/lib/openehr/rm/ehr.rb +6 -6
  28. data/lib/openehr/rm/factory.rb +22 -0
  29. data/lib/openehr/rm/security.rb +3 -1
  30. data/lib/openehr/rm/type_name.rb +0 -1
  31. data/lib/openehr/serializer/adl_serializer.rb +239 -84
  32. data/lib/openehr/serializer/base.rb +2 -0
  33. data/lib/openehr/serializer/rm_json_serializer.rb +0 -1
  34. data/lib/openehr/serializer/xml_serializer.rb +351 -103
  35. data/lib/openehr/version.rb +1 -1
  36. metadata +9 -144
@@ -0,0 +1,145 @@
1
+ module OpenEHR
2
+ module Parser
3
+ # The 8 C_PRIMITIVE readers (boolean/string/integer/real/date/
4
+ # date_time/time/duration), shared between OPTParser and
5
+ # XMLArchetypeParser - both read a <item xsi:type="C_*">-wrapped
6
+ # element with the same internal shape. Extracted from opt_parser.rb
7
+ # without behavior changes, except C_REAL's range now reads Float
8
+ # bounds instead of silently truncating them to Integer via
9
+ # numeric_interval(real: true) (see xml_constraint_parsing.rb).
10
+ module XMLPrimitiveParsing
11
+ private
12
+
13
+ def c_date(xml)
14
+ pattern = xml.at('pattern')
15
+ range = xml.at('range')
16
+ if pattern
17
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new(pattern: pattern.text)
18
+ elsif range
19
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new(range: occurrences(range))
20
+ else
21
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDate.new
22
+ end
23
+ end
24
+
25
+ def c_date_time(xml)
26
+ pattern = xml.at('pattern')
27
+ range = xml.at('range')
28
+ if pattern
29
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new(pattern: pattern.text)
30
+ elsif range
31
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new(range: occurrences(range))
32
+ else
33
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDateTime.new
34
+ end
35
+ end
36
+
37
+ def c_integer(xml)
38
+ range = xml.at('range')
39
+ list = xml.xpath('list')
40
+ if range
41
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new(range: occurrences(range))
42
+ elsif !list.empty?
43
+ list_values = list.map { |item| item.text.to_i }
44
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new(list: list_values)
45
+ else
46
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CInteger.new
47
+ end
48
+ end
49
+
50
+ def c_real(xml)
51
+ range = xml.at('range')
52
+ list = xml.xpath('list')
53
+ if range
54
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new(range: numeric_interval(range, real: true))
55
+ elsif !list.empty?
56
+ list_values = list.map { |item| item.text.to_f }
57
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new(list: list_values)
58
+ else
59
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CReal.new
60
+ end
61
+ end
62
+
63
+ def c_duration(xml)
64
+ pattern = xml.at('pattern')
65
+ range_xml = xml.at('range')
66
+ list = xml.xpath('list')
67
+ if pattern
68
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(pattern: pattern.text)
69
+ elsif range_xml
70
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(range: duration_range(range_xml))
71
+ elsif !list.empty?
72
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new(list: list.map(&:text))
73
+ else
74
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CDuration.new
75
+ end
76
+ end
77
+
78
+ # A C_DURATION range's bounds are ISO8601 duration strings (e.g.
79
+ # PT24H), not plain numbers, so occurrences() (built for numeric
80
+ # Interval bounds) doesn't apply here; wrap each bound as a
81
+ # DV_DURATION instead, matching what CDuration#valid_value?
82
+ # already expects its range bounds to be.
83
+ def duration_range(range_xml)
84
+ lower = duration_bound(range_xml.at('lower'), range_xml.at('lower_unbounded'))
85
+ upper = duration_bound(range_xml.at('upper'), range_xml.at('upper_unbounded'))
86
+ return nil if lower.nil? && upper.nil?
87
+
88
+ OpenEHR::AssumedLibraryTypes::Interval.new(
89
+ lower: lower, upper: upper,
90
+ lower_included: lower.nil? ? nil : bool_node(range_xml.at('lower_included'), true),
91
+ upper_included: upper.nil? ? nil : bool_node(range_xml.at('upper_included'), true))
92
+ end
93
+
94
+ def duration_bound(value_node, unbounded_node)
95
+ return nil if bool_node(unbounded_node, false)
96
+ return nil if value_node.nil? || value_node.text.empty?
97
+
98
+ OpenEHR::RM::DataTypes::Quantity::DateTime::DvDuration.new(value: value_node.text)
99
+ end
100
+
101
+ def bool_node(node, default)
102
+ node ? to_bool(node.text) : default
103
+ end
104
+
105
+ def c_time(xml)
106
+ pattern = xml.at('pattern')
107
+ range = xml.at('range')
108
+ if pattern
109
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new(pattern: pattern.text)
110
+ elsif range
111
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new(range: occurrences(range))
112
+ else
113
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CTime.new
114
+ end
115
+ end
116
+
117
+ def c_boolean(xml)
118
+ true_valid = xml.at('true_valid')
119
+ false_valid = xml.at('false_valid')
120
+ assumed_value = xml.at('assumed_value')
121
+
122
+ true_valid_value = true_valid ? to_bool(true_valid.text) : nil
123
+ false_valid_value = false_valid ? to_bool(false_valid.text) : nil
124
+ assumed_value_value = assumed_value ? to_bool(assumed_value.text) : nil
125
+
126
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CBoolean.new(
127
+ true_valid: true_valid_value,
128
+ false_valid: false_valid_value,
129
+ assumed_value: assumed_value_value
130
+ )
131
+ end
132
+
133
+ def c_string(attr_xml)
134
+ if attr_xml.at('pattern')
135
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CString.new(pattern: attr_xml.at('pattern').text)
136
+ else
137
+ list = attr_xml.xpath('.//list').map do |str|
138
+ str.text
139
+ end
140
+ OpenEHR::AM::Archetype::ConstraintModel::Primitive::CString.new(list: list)
141
+ end
142
+ end
143
+ end
144
+ end
145
+ end
@@ -19,6 +19,7 @@ module OpenEHR
19
19
  require_relative 'parser/exception'
20
20
  require_relative 'parser/adl_parser'
21
21
  require_relative 'parser/opt_parser'
22
+ require_relative 'parser/xml_archetype_parser'
22
23
  require_relative 'parser/archetype_validator'
23
24
  end
24
25
  end
@@ -167,8 +167,8 @@ module OpenEHR
167
167
  end
168
168
 
169
169
  class Participation
170
- attr_reader :performer, :function, :mode
171
- attr_accessor :time
170
+ attr_reader :performer, :function
171
+ attr_accessor :time, :mode
172
172
 
173
173
  def initialize(args ={ })
174
174
  self.performer = args[:performer]
@@ -186,16 +186,13 @@ module OpenEHR
186
186
  raise ArgumentError, 'function is mandatory' if function.nil?
187
187
  @function = function
188
188
  end
189
-
190
- def mode=(mode)
191
- raise ArgumentError, 'mode is mandatory' if mode.nil?
192
- @mode = mode
193
- end
189
+ # mode is optional (0..1) since RM 1.0.4 - attr_accessor above
190
+ # covers it, no validation needed.
194
191
  end
195
192
 
196
193
  class Attestation < AuditDetails
197
- attr_reader :reason
198
- attr_accessor :proof, :attested_view, :is_pending, :items
194
+ attr_reader :reason, :items
195
+ attr_accessor :proof, :attested_view, :is_pending
199
196
 
200
197
  def initialize(args = { })
201
198
  super(args)
@@ -1,4 +1,3 @@
1
- require 'set'
2
1
 
3
2
  module OpenEHR
4
3
  module RM
@@ -258,9 +258,12 @@ module OpenEHR
258
258
  @current_state = current_state
259
259
  end
260
260
 
261
+ # transition is optional (0..1) since RM 1.1.0; when present
262
+ # its code is still validated against the terminology.
261
263
  def transition=(transition)
262
264
  if transition.nil?
263
- raise ArgumentError, 'transition is mandatory'
265
+ @transition = nil
266
+ return
264
267
  end
265
268
  unless OpenEHR::TerminologyService.has_code_for_group?('ism transition careflow transition', transition.defining_code.code_string)
266
269
  raise ArgumentError, 'transition code is invalid'
@@ -21,12 +21,13 @@ module OpenEHR
21
21
  NULL_FLAVOUR_CODES = %w[271 272 273 253].freeze
22
22
 
23
23
  attr_accessor :value
24
- attr_reader :null_flavor
25
- path_attribute :value, :null_flavour
24
+ attr_reader :null_flavor, :null_reason
25
+ path_attribute :value, :null_flavour, :null_reason
26
26
  def initialize(args = {})
27
27
  super(args)
28
28
  self.value = args[:value]
29
29
  self.null_flavor= args[:null_flavor]
30
+ self.null_reason = args[:null_reason]
30
31
  end
31
32
 
32
33
  def null_flavor=(null_flavor)
@@ -40,6 +41,15 @@ module OpenEHR
40
41
  alias null_flavour null_flavor
41
42
  alias null_flavour= null_flavor=
42
43
 
44
+ # null_reason is DV_TEXT [0..1] since RM 1.1.0. Invariant: if
45
+ # set, null_flavor must also be set.
46
+ def null_reason=(null_reason)
47
+ if !null_reason.nil? && @null_flavor.nil?
48
+ raise ArgumentError, 'null_reason requires null_flavor to be set'
49
+ end
50
+ @null_reason = null_reason
51
+ end
52
+
43
53
  def is_null?
44
54
  return @value.nil?
45
55
  end
@@ -2,7 +2,6 @@
2
2
  # ItemStructure module
3
3
  # http://www.openehr.org/uml/release-1.0.1/Browsable/_9_0_76d0249_1109346709572_859750_3810Report.html
4
4
  # refs #54
5
- require 'set'
6
5
 
7
6
  require_relative '../data_structures'
8
7
  require_relative 'item_structure/representation'
@@ -25,20 +25,11 @@ module OpenEHR
25
25
  end
26
26
 
27
27
  class DvBoolean < DataValue
28
- def initialize(args)
29
- super(args)
30
- end
31
-
32
28
  def value=(value)
33
29
  raise ArgumentError, "value must not be nil" if value.nil?
34
- if value == true
35
- @value = true
36
- elsif value == false
37
- @value = false
38
- elsif /TRUE/i =~ value
39
- @value = true
40
- elsif /FALSE/i =~ value
41
- @value = false
30
+ case value
31
+ when true, /TRUE/i then @value = true
32
+ when false, /FALSE/i then @value = false
42
33
  end
43
34
  end
44
35
 
@@ -84,17 +75,17 @@ module OpenEHR
84
75
  self.type = args[:type]
85
76
  end
86
77
 
78
+ # issuer/assigner/type are optional (0..1) since RM 1.0.4 - only
79
+ # id is mandatory. A non-nil value must still not be empty.
87
80
  def issuer=(issuer)
88
- if issuer.nil? or issuer.empty?
89
- raise ArgumentError, 'issuer is mandatory'
90
- end
81
+ raise ArgumentError, 'issuer must not be empty' if !issuer.nil? && issuer.empty?
82
+
91
83
  @issuer = issuer
92
84
  end
93
85
 
94
86
  def assigner=(assigner)
95
- if assigner.nil? or assigner.empty?
96
- raise ArgumentError, 'assigner is mandatory'
97
- end
87
+ raise ArgumentError, 'assigner must not be empty' if !assigner.nil? && assigner.empty?
88
+
98
89
  @assigner = assigner
99
90
  end
100
91
 
@@ -106,9 +97,8 @@ module OpenEHR
106
97
  end
107
98
 
108
99
  def type=(type)
109
- if type.nil? or type.empty?
110
- raise ArgumentError, 'type is mandatory'
111
- end
100
+ raise ArgumentError, 'type must not be empty' if !type.nil? && type.empty?
101
+
112
102
  @type = type
113
103
  end
114
104
  end #end of DvIdentifier
@@ -37,10 +37,10 @@ module OpenEHR
37
37
  @charset = charset
38
38
  end
39
39
 
40
- private
41
-
42
40
  CHARSET_LIST_PATH = File.expand_path('charset.lst', __dir__)
43
41
 
42
+ private
43
+
44
44
  def charset_valid?(charset)
45
45
  result = false
46
46
  File.open(CHARSET_LIST_PATH) do |file|
@@ -65,6 +65,7 @@ module OpenEHR
65
65
  def initialize(args = {})
66
66
  super(args)
67
67
  self.media_type = args[:media_type]
68
+ self.size = args[:size]
68
69
  self.uri = args[:uri]
69
70
  self.data = args[:data]
70
71
  self.compression_algorithm = args[:compression_algorithm]
@@ -81,6 +82,21 @@ module OpenEHR
81
82
  @media_type = media_type
82
83
  end
83
84
 
85
+ # size is Integer [1..1] since RM 1.1.0 (size in bytes of the raw
86
+ # data content). Left unset (nil), it falls back to the inherited
87
+ # DvEncapsulated#size (@value.size) for backward compatibility.
88
+ def size
89
+ @size.nil? ? super : @size
90
+ end
91
+
92
+ def size=(size)
93
+ unless size.nil?
94
+ raise ArgumentError, 'size must be an Integer' unless size.is_a?(Integer)
95
+ raise ArgumentError, 'size must not be negative' if size.negative?
96
+ end
97
+ @size = size
98
+ end
99
+
84
100
  def is_external?
85
101
  !@uri.nil?
86
102
  end
@@ -30,6 +30,17 @@ module OpenEHR
30
30
  end
31
31
 
32
32
  undef magnitude=
33
+
34
+ private
35
+
36
+ # Shared guard for add/subtract on DvDate/DvTime/DvDateTime:
37
+ # DvAbsoluteQuantity#add/subtract assume magnitude=-based
38
+ # reconstruction, which DvTemporal subclasses can't support
39
+ # (see the undef above), so each subclass implements its own
40
+ # add/subtract against a DvDuration directly instead.
41
+ def validate_duration_operand(a_diff)
42
+ raise ArgumentError, 'a_diff must be a DvDuration' unless a_diff.is_a?(DvDuration)
43
+ end
33
44
  end
34
45
 
35
46
  class DvDate < DvTemporal
@@ -55,20 +66,8 @@ module OpenEHR
55
66
  else
56
67
  past, future = self, other
57
68
  end
58
- year, month, day = 0, 0, 0
59
- if (future.day >= past.day)
60
- day = future.day - past.day
61
- else
62
- month = -1
63
- previous_month = future.month - 1
64
- if previous_month == 0
65
- previous_month = 12
66
- end
67
- day = DAYS_IN_MONTH[previous_month] + future.day - past.day
68
- if leapyear?(future.year) && (previous_month == 2)
69
- day += 1
70
- end
71
- end
69
+ year = 0
70
+ month, day = day_borrow(past, future)
72
71
  week = day / 7
73
72
  if (future.month >= past.month)
74
73
  month += future.month - past.month
@@ -82,11 +81,48 @@ module OpenEHR
82
81
  end
83
82
  year += future.year - past.year
84
83
  return DvDuration.new(:value =>
85
- 'P' + year.to_s + 'Y' + month.to_s + 'M' +
84
+ 'P' + year.to_s + 'Y' + month.to_s + 'M' +
86
85
  week.to_s + 'W' + day.to_s + 'D')
87
86
  end
87
+
88
+ def add(a_diff)
89
+ validate_duration_operand(a_diff)
90
+ raise ArgumentError, 'a_diff must not have a time component' if time_component?(a_diff)
91
+
92
+ DvDate.new(:value => shift_by(a_diff).iso8601)
93
+ end
94
+
95
+ def subtract(a_diff)
96
+ add(-a_diff)
97
+ end
98
+
99
+ private
100
+
101
+ def time_component?(a_diff)
102
+ [a_diff.hours, a_diff.minutes, a_diff.seconds].any? { |v| v && !v.zero? }
103
+ end
104
+
105
+ def shift_by(a_diff)
106
+ sign = a_diff.negative? ? -1 : 1
107
+ months = (((a_diff.years || 0) * MONTH_IN_YEAR) + (a_diff.months || 0)) * sign
108
+ days = (((a_diff.weeks || 0) * DAYS_IN_WEEK) + (a_diff.days || 0)) * sign
109
+ (Date.new(@year, @month, @day) >> months) + days
110
+ end
111
+
112
+ # Returns [month_adjustment, day] for diff: when future's
113
+ # day-of-month is behind past's, a month is borrowed from
114
+ # future's calendar to make day non-negative.
115
+ def day_borrow(past, future)
116
+ return [0, future.day - past.day] if future.day >= past.day
117
+
118
+ previous_month = future.month - 1
119
+ previous_month = 12 if previous_month == 0
120
+ day = DAYS_IN_MONTH[previous_month] + future.day - past.day
121
+ day += 1 if leapyear?(future.year) && previous_month == 2
122
+ [-1, day]
123
+ end
88
124
  end
89
-
125
+
90
126
  class DvTime < DvTemporal
91
127
  include OpenEHR::AssumedLibraryTypes::ISO8601TimeModule
92
128
 
@@ -104,19 +140,19 @@ module OpenEHR
104
140
  if @fractional_second.nil? && @second.nil? && @minute.nil?
105
141
  return @hour * 60 * 60
106
142
  elsif @fractional_second.nil? && @second.nil?
107
- return @hour * 60 * 60 + @minute * 60
143
+ return (@hour * 60 * 60) + (@minute * 60)
108
144
  elsif @fractional_second.nil?
109
- return @hour * 60 * 60 + @minute * 60 + @second
145
+ return (@hour * 60 * 60) + (@minute * 60) + @second
110
146
  else
111
- return @hour*60*60+@minute* 60 + @second + @fractional_second
147
+ return (@hour*60*60)+(@minute* 60) + @second + @fractional_second
112
148
  end
113
149
  end
114
150
 
115
151
  def diff(other)
116
152
  diff = (other.magnitude - self.magnitude).abs
117
153
  hour = (diff / 60 / 60).to_i
118
- minute = ((diff - hour*60*60)/60).to_i
119
- second = (diff - hour * 60 * 60 - minute * 60).to_i
154
+ minute = ((diff - (hour*60*60))/60).to_i
155
+ second = (diff - (hour * 60 * 60) - (minute * 60)).to_i
120
156
  fractional_second = ((diff - diff.to_i)*1000000.0).to_i/1000000.0
121
157
  str = 'P0Y0M0W0DT' + hour.to_s + 'H' +
122
158
  minute.to_s + 'M' + second.to_s
@@ -127,6 +163,48 @@ module OpenEHR
127
163
  end
128
164
  return DvDuration.new(:value => str)
129
165
  end
166
+
167
+ def add(a_diff)
168
+ validate_duration_operand(a_diff)
169
+ raise ArgumentError, 'a_diff must not have a date component' if date_component?(a_diff)
170
+
171
+ DvTime.new(:value => shifted_time_string(a_diff))
172
+ end
173
+
174
+ def subtract(a_diff)
175
+ add(-a_diff)
176
+ end
177
+
178
+ private
179
+
180
+ def date_component?(a_diff)
181
+ [a_diff.years, a_diff.months, a_diff.weeks, a_diff.days].any? { |v| v && !v.zero? }
182
+ end
183
+
184
+ def duration_seconds(a_diff)
185
+ ((a_diff.hours || 0) * MINUTES_IN_HOUR * SECONDS_IN_MINUTE) +
186
+ ((a_diff.minutes || 0) * SECONDS_IN_MINUTE) +
187
+ (a_diff.seconds || 0) +
188
+ (a_diff.fractional_second || 0)
189
+ end
190
+
191
+ def shifted_time_string(a_diff)
192
+ sign = a_diff.negative? ? -1 : 1
193
+ day_seconds = HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE
194
+ total = (magnitude + (sign * duration_seconds(a_diff))) % day_seconds
195
+ formatted_time(total) + timezone
196
+ end
197
+
198
+ def formatted_time(total_seconds)
199
+ hour = (total_seconds / (MINUTES_IN_HOUR * SECONDS_IN_MINUTE)).to_i
200
+ remainder = total_seconds - (hour * MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
201
+ minute = (remainder / SECONDS_IN_MINUTE).to_i
202
+ second_with_fraction = remainder - (minute * SECONDS_IN_MINUTE)
203
+ second = second_with_fraction.to_i
204
+ fractional = (second_with_fraction - second).round(6)
205
+ str = format('%02d:%02d:%02d', hour, minute, second)
206
+ fractional.zero? ? str : str + fractional.to_s[1..]
207
+ end
130
208
  end
131
209
 
132
210
  class DvDateTime < DvTemporal
@@ -156,11 +234,11 @@ module OpenEHR
156
234
  minute += @timezone.minute
157
235
  end
158
236
  end
159
- seconds = (((@year * 365.24 +
160
- @month * 30.42 +
161
- @day) * 24 +
162
- hour) * 60 +
163
- minute) * 60 + @second
237
+ seconds = (((((((@year * 365.24) +
238
+ (@month * 30.42) +
239
+ @day) * 24) +
240
+ hour) * 60) +
241
+ minute) * 60) + @second
164
242
  if @fractional_second.nil?
165
243
  return seconds
166
244
  else
@@ -168,6 +246,9 @@ module OpenEHR
168
246
  end
169
247
  end
170
248
 
249
+ # rubocop:disable Metrics/AbcSize -- dense calendar arithmetic; refactoring
250
+ # without broader leap-year/borrow coverage (only 1 example exercises this
251
+ # method) is riskier than the lint value gained
171
252
  def diff(other)
172
253
  if self.magnitude >= other.magnitude
173
254
  past, future = other, self
@@ -183,8 +264,8 @@ module OpenEHR
183
264
  end
184
265
  date_duration = past_date.diff(future_date)
185
266
  hour = (time_diff / 60 / 60).to_i
186
- minute = ((time_diff - hour*60*60)/60).to_i
187
- second = (time_diff - hour * 60 * 60 - minute * 60).to_i
267
+ minute = ((time_diff - (hour*60*60))/60).to_i
268
+ second = (time_diff - (hour * 60 * 60) - (minute * 60)).to_i
188
269
  str = date_duration.value + 'T' + hour.to_s + 'H' +
189
270
  minute.to_s + 'M' + second.to_s
190
271
  if @fractional_second.nil?
@@ -196,12 +277,57 @@ module OpenEHR
196
277
  fractional_second.to_s[1..-1] + 'S')
197
278
  end
198
279
  end
280
+ # rubocop:enable Metrics/AbcSize
281
+
282
+ def add(a_diff)
283
+ validate_duration_operand(a_diff)
284
+
285
+ shifted_date = date_part_shifted_by(a_diff)
286
+ day_carry, wrapped_seconds = time_of_day_seconds_shifted_by(a_diff).divmod(day_seconds)
287
+ DvDateTime.new(:value => combine(shifted_date + day_carry, wrapped_seconds) + timezone)
288
+ end
289
+
290
+ def subtract(a_diff)
291
+ add(-a_diff)
292
+ end
199
293
 
200
294
  private
295
+
201
296
  def split_date_time(date_time)
202
297
  /^(.*)T(.*)$/ =~ date_time.as_string
203
298
  return DvDate.new(:value => $1), DvTime.new(:value => $2)
204
299
  end
300
+
301
+ def day_seconds
302
+ HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE
303
+ end
304
+
305
+ def date_part_shifted_by(a_diff)
306
+ sign = a_diff.negative? ? -1 : 1
307
+ months = (((a_diff.years || 0) * MONTH_IN_YEAR) + (a_diff.months || 0)) * sign
308
+ days = (((a_diff.weeks || 0) * DAYS_IN_WEEK) + (a_diff.days || 0)) * sign
309
+ (Date.new(@year, @month, @day) >> months) + days
310
+ end
311
+
312
+ def time_of_day_seconds_shifted_by(a_diff)
313
+ sign = a_diff.negative? ? -1 : 1
314
+ own_seconds = (@hour * MINUTES_IN_HOUR * SECONDS_IN_MINUTE) + (@minute * SECONDS_IN_MINUTE) +
315
+ @second + (@fractional_second || 0)
316
+ diff_seconds = ((a_diff.hours || 0) * MINUTES_IN_HOUR * SECONDS_IN_MINUTE) +
317
+ ((a_diff.minutes || 0) * SECONDS_IN_MINUTE) + (a_diff.seconds || 0) + (a_diff.fractional_second || 0)
318
+ own_seconds + (sign * diff_seconds)
319
+ end
320
+
321
+ def combine(date, total_seconds)
322
+ hour = (total_seconds / (MINUTES_IN_HOUR * SECONDS_IN_MINUTE)).to_i
323
+ remainder = total_seconds - (hour * MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
324
+ minute = (remainder / SECONDS_IN_MINUTE).to_i
325
+ second_with_fraction = remainder - (minute * SECONDS_IN_MINUTE)
326
+ second = second_with_fraction.to_i
327
+ fractional = (second_with_fraction - second).round(6)
328
+ str = date.iso8601 + format('T%02d:%02d:%02d', hour, minute, second)
329
+ fractional.zero? ? str : str + fractional.to_s[1..]
330
+ end
205
331
  end
206
332
 
207
333
  class DvDuration < DvTemporal
@@ -254,6 +380,55 @@ module OpenEHR
254
380
  text = value.start_with?('-') ? value[1..-1] : "-#{value}"
255
381
  DvDuration.new(:value => text)
256
382
  end
383
+
384
+ # DV_DURATION is a DV_AMOUNT in the openEHR RM even though it
385
+ # inherits DvAbsoluteQuantity's add/subtract/diff method names
386
+ # here (see DvTemporal); those assume magnitude=-based
387
+ # reconstruction, which DvTemporal subclasses can't support,
388
+ # so +/-/multiply are implemented directly instead, going
389
+ # through magnitude (already sign-aware and nominal-month
390
+ # aware) and reconstructing via an ISO8601 string.
391
+ def +(other)
392
+ raise ArgumentError, 'type mismatch' unless is_strictly_comparable_to?(other)
393
+
394
+ self.class.from_seconds(magnitude + other.magnitude)
395
+ end
396
+
397
+ def -(other)
398
+ raise ArgumentError, 'type mismatch' unless is_strictly_comparable_to?(other)
399
+
400
+ self.class.from_seconds(magnitude - other.magnitude)
401
+ end
402
+
403
+ def multiply(factor)
404
+ raise ArgumentError, 'factor must be Numeric' unless factor.is_a?(Numeric)
405
+
406
+ self.class.from_seconds(magnitude * factor)
407
+ end
408
+ alias * multiply
409
+
410
+ def self.from_seconds(total_seconds)
411
+ sign = total_seconds.negative? ? '-' : ''
412
+ days, hours, minutes, seconds_part = duration_components(total_seconds.abs)
413
+ new(:value => "#{sign}P0Y0M0W#{days}DT#{hours}H#{minutes}M#{seconds_part}S")
414
+ end
415
+
416
+ def self.duration_components(total_seconds)
417
+ days, remainder = total_seconds.divmod(HOURS_IN_DAY * MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
418
+ hours, remainder = remainder.divmod(MINUTES_IN_HOUR * SECONDS_IN_MINUTE)
419
+ minutes, remainder = remainder.divmod(SECONDS_IN_MINUTE)
420
+ [days, hours, minutes, seconds_with_fraction(remainder)]
421
+ end
422
+ private_class_method :duration_components
423
+
424
+ def self.seconds_with_fraction(remainder)
425
+ seconds = remainder.to_i
426
+ fractional = (remainder - seconds).round(6)
427
+ seconds += 1 if fractional >= 1.0
428
+ fractional = 0.0 if fractional >= 1.0
429
+ fractional.zero? ? seconds.to_s : "#{seconds}#{fractional.to_s[1..]}"
430
+ end
431
+ private_class_method :seconds_with_fraction
257
432
  end
258
433
  end # of DateTime
259
434
  end # of Quantity