4
\$\begingroup\$

The Situation

I have created some code in the form of modules that each represent a medical questionnaire (I'm calling them Catalogs). Each different questionnaire has its own module as they may differ slightly in their content and associated calculations, but are essentially made up of simple questions that have boolean/numeric possible responses. Here is an example.

These Catalog modules are included in an Entry class that collects responses matching the question names. Each questionnaire is transformed into a DEFINITION which is used in the Entry to do things like:

  • Validate inputs
  • Check completeness
  • Calculate scoring

There are 2 examples for reference at the bottom that illustrate the problem of duplication... much of the code is similar but not exactly the same.

The Problem

There is a lot of duplication here, but I'm not sure about the best strategy to remove it. There are a few things that make it difficult for this particular problem and make me lean towards accepting some duplication as opposed to a system that is too strict to work. The system needs to remain flexible enough to accommodate currently unknown medical questionnaires of a similar nature so I need to be careful (the reason I've gone with a Module system so far)

Here are some examples:

  • Each Catalog can have slightly different scoring requirements and custom grouping of questions that represent one "score"
  • Potentially many Catalogs are included in an Entry class and can't step on each other
  • Some Catalogs incorporate things like "Current Weight" for calculations, breaking the 1-5 or 1-10 paradigm and not fitting very nicely into simple sum reductions.
  • One Catalog requires a week of previous entries in order to be valid, a sort of weird custom validation.

The Question:

What strategies might be employed here to reduce duplication overall? I'm not looking for tweaks cut out a few lines from these specific examples. Implementation cost is a consideration.

Possibilities:

  • Put some of this into a database (sounds pretty good, but I think the cost of implementation could be high)
  • I fear there could be room for improvement in my metaprogramming here, perhaps there are better ways to accomplish this through some dynamic method creation or other voodoo.

Gist and inline below:

module HbiCatalog
 extend ActiveSupport::Concern
 DEFINITION = [
 ### General Well-Being
 [{
 # 0 Very Well
 # 1 Slightly below par
 # 2 Poor
 # 3 Very poor
 # 4 Terrible
 name: :general_wellbeing,
 kind: :select,
 inputs: [
 {value: 0, label: "very_well", meta_label: "", helper: nil},
 {value: 1, label: "slightly_below_par", meta_label: "", helper: nil},
 {value: 2, label: "poor", meta_label: "", helper: nil},
 {value: 3, label: "very_poor", meta_label: "", helper: nil},
 {value: 4, label: "terrible", meta_label: "", helper: nil},
 ]
 }],
 ### Abdominal Pain
 [{
 # 0 None
 # 1 Mild
 # 2 Moderate
 # 3 Severe
 name: :ab_pain,
 kind: :select,
 inputs: [
 {value: 0, label: "none", meta_label: "", helper: nil},
 {value: 1, label: "mild", meta_label: "", helper: nil},
 {value: 2, label: "moderate", meta_label: "", helper: nil},
 {value: 3, label: "severe", meta_label: "", helper: nil},
 ]
 }],
 ### Number of Liquid/Soft Stools for the Day
 [{
 name: :stools,
 kind: :number,
 inputs: [
 {value: 0, label: nil, meta_label: nil, helper: "stools_daily"}
 ]
 }],
 ### Abdominal Mass
 [{
 # 0 None
 # 1 Dubious
 # 2 Definite
 # 3 Definite and Tender
 name: :ab_mass,
 kind: :select,
 inputs: [
 {value: 0, label: "none", meta_label: "", helper: nil},
 {value: 1, label: "dubious", meta_label: "", helper: nil},
 {value: 2, label: "definite", meta_label: "", helper: nil},
 {value: 3, label: "definite_and_tender", meta_label: "", helper: nil},
 ]
 }],
 ### Complications (1 point each)
 # Arthralgia
 # Uveitis
 # Erythema nodosum
 # Aphthous ulcers
 # Pyoderma gangrenosum
 # Anal fissure
 # New fistula
 # Abscess
 [
 {
 name: :complication_arthralgia,
 kind: :checkbox
 },
 {
 name: :complication_uveitis,
 kind: :checkbox
 },
 {
 name: :complication_erythema_nodosum,
 kind: :checkbox
 },
 {
 name: :complication_aphthous_ulcers,
 kind: :checkbox
 },
 {
 name: :complication_anal_fissure,
 kind: :checkbox
 },
 {
 name: :complication_fistula,
 kind: :checkbox
 },
 {
 name: :complication_abscess,
 kind: :checkbox
 }
 ]
 ]
 SCORE_COMPONENTS = %i( general_wellbeing ab_pain stools ab_mass complications )
 QUESTIONS = DEFINITION.map{|questions| questions.map{|question| question[:name] }}.flatten
 COMPLICATIONS = DEFINITION[4].map{|question| question[:name] }.flatten
 included do |base_class|
 validate :hbi_response_ranges
 def hbi_response_ranges
 ranges = [
 [:general_wellbeing, [*0..4]],
 [:ab_pain, [*0..3]],
 [:stools, [*0..50]],
 [:ab_mass, [*0..3]],
 ]
 ranges.each do |range|
 response = hbi_responses.detect{|r| r.name.to_sym == range[0]}
 if response and not range[1].include?(response.value)
 # TODO add catalog namespace here
 self.errors.messages[:responses] ||= {}
 self.errors.messages[:responses][range[0]] = "Not within allowed values"
 end
 end
 end
 validate :hbi_response_booleans
 def hbi_response_booleans
 HbiCatalog::COMPLICATIONS.each do |name|
 response = hbi_responses.detect{|r| r.name.to_sym == name}
 if response and not [0,1].include? response.value.to_i
 self.errors.messages[:responses] ||= {}
 self.errors.messages[:responses][name.to_sym] = "Must be true or false"
 end
 end
 end
 end
 def hbi_responses
 responses.select{|r| r.catalog == "hbi"}
 end
 # def valid_hbi_entry?
 # return false unless last_6_entries.count == 6
 # !last_6_entries.map{|e| e.filled_hbi_entry?}.include?(false)
 # end
 def filled_hbi_entry?
 (QUESTIONS - hbi_responses.reduce([]) {|accu, response| (accu << response.name.to_sym) if response.name}) == []
 end
 def complete_hbi_entry?
 filled_hbi_entry?
 end
 # def setup_hbi_scoring
 # end
 def hbi_complications_score
 COMPLICATIONS.reduce(0) do |sum, question_name|
 sum + (self.send("hbi_#{question_name}").to_i)
 end.to_f
 end
end

module Rapid3Catalog
 extend ActiveSupport::Concern
 DEFINITION = [
 ### Over the last week were you able to.. ###
 ### Dress yourself, including tying shoelaces and doing buttons?
 [{
 name: :dress_yourself,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Get in and out of bed?
 [{
 name: :get_in_out_of_bed,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Lift a full glass of water to your mouth?
 [{
 name: :lift_full_glass,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Walk outdoors on flat ground?
 [{
 name: :walk_outdoors,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Wash and dry your entire body?
 [{
 name: :wash_and_dry_yourself,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Bend down to pick up clothing from the floor?
 [{
 name: :bend_down,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Turn regular faucets on and off?
 [{
 name: :turn_faucet,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Get in and out of a car, bus, train, or airplane?
 [{
 name: :enter_exit_vehicles,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Walk two miles or three kilometers, if you wish?
 [{
 name: :walk_two_miles,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### Participate in recreational activities and sports as you would like, if you wish?
 [{
 name: :play_sports,
 kind: :select,
 inputs: [
 {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 {value: 3, label: "unable", meta_label: "", helper: nil},
 ]
 }],
 ### !!! RAPID3 FORM SAYS: questions K-M have been found to be informative, but are not scored formally
 ### !!! So they are commented out
 # ### Get a good nightâ€TMs sleep?
 # [{
 # name: :sleep_well,
 # section: 10,
 # kind: :select,
 # inputs: [
 # {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 # {value: 1.1, label: "some_difficulty", meta_label: "", helper: nil},
 # {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 # {value: 3, label: "unable", meta_label: "", helper: nil},
 # ]
 # }],
 #
 # ### Deal with feelings of anxiety or being nervous?
 # [{
 # name: :deal_with_anxiety,
 # section: 11,
 # kind: :select,
 # inputs: [
 # {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 # {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 # {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 # {value: 3, label: "unable", meta_label: "", helper: nil},
 # ]
 # }],
 #
 # ### Deal with feelings of depression or feeling blue?
 # [{
 # name: :deal_with_depression,
 # section: 12,
 # kind: :select,
 # inputs: [
 # {value: 0, label: "no_difficulty", meta_label: "", helper: nil},
 # {value: 1, label: "some_difficulty", meta_label: "", helper: nil},
 # {value: 2, label: "much_difficulty", meta_label: "", helper: nil},
 # {value: 3, label: "unable", meta_label: "", helper: nil},
 # ]
 # }],
 ### How much pain have you had because of your condition OVER THE PAST WEEK?
 [{
 name: :pain_tolerance,
 kind: :range,
 step: 0.5,
 inputs: [
 {value: 0.0, label: "no_pain", meta_label: "", helper: nil},
 {value: 10.0, label: "maximum_pain", meta_label: "", helper: nil},
 ]
 }],
 ### Considering all the ways in which illness and health conditions may affect you at this time, please indicate below how you are doing:
 [{
 name: :global_estimate,
 kind: :range,
 step: 0.5,
 inputs: [
 {value: 0.0, label: "very_well", meta_label: "", helper: nil},
 {value: 10.0, label: "very_poorly", meta_label: "", helper: nil},
 ]
 }],
 ]
 SCORING_INDEX = [0.3, 0.7, 1.0, 1.3, 1.7, 2.0, 2.3, 2.7, 3.0, 3.3, 3.7, 4.0, 4.3, 4.7, 5.0, 5.3, 5.7, 6.0, 6.3, 6.7, 7.0, 7.3, 7.7, 8.0, 8.3, 8.7, 9.0, 9.3, 9.7, 10]
 SCORE_COMPONENTS = %i( functional_status pain_tolerance global_estimate )
 QUESTIONS = DEFINITION.map{|questions| questions.map{|question| question[:name] }}.flatten
 FUNCTIONAL_QUESTIONS = (QUESTIONS - [:pain_tolerance, :global_estimate])
 included do |base_class|
 validate :response_ranges
 def response_ranges
 ranges = [
 [:pain_tolerance, (0..10).step(0.5).to_a],
 [:global_estimate, (0..10).step(0.5).to_a],
 ]
 FUNCTIONAL_QUESTIONS.each{|q| ranges << [q, [*0..3] ]}
 ranges.each do |range|
 response = rapid3_responses.select{|r| r.name.to_sym == range[0]}.first
 if response and not range[1].include?(response.value)
 # self.errors.add "responses.#{range[0]}", "Not within allowed values"
 self.errors.messages[:responses] ||= {}
 self.errors.messages[:responses][range[0]] = "Not within allowed values"
 end
 end
 end
 end
 def rapid3_responses
 responses.select{|r| r.catalog == "rapid3"}
 end
 def filled_rapid3_entry?
 (QUESTIONS - responses.reduce([]) {|accu, response| (accu << response.name.to_sym) if response.name}) == []
 end
 def complete_rapid3_entry?
 filled_rapid3_entry?
 end
 # def setup_rapid3_scoring
 # end
 # def finalize_rapid3_scoring(score)
 # RAPID3_SCORING_INDEX[score.round-1] # "weight" the final score
 # end
 def rapid3_functional_status_score
 score = FUNCTIONAL_QUESTIONS.reduce(0) do |sum, question_name|
 sum + self.send("rapid3_#{question_name}")
 end
 SCORING_INDEX[score-1]
 end
 def rapid3_pain_tolerance_score
 self.send(:rapid3_pain_tolerance)
 end
 def rapid3_global_estimate_score
 self.send(:rapid3_global_estimate)
 end
end
Jamal
35.2k13 gold badges134 silver badges238 bronze badges
asked Jan 18, 2015 at 13:18
\$\endgroup\$
0

1 Answer 1

2
\$\begingroup\$

You mention in your description that Entry will do three different things:

  • Validate inputs
  • Check completeness
  • Calculate scoring

This violates the single responsibility principle. Entry does too much! I suggest a system where Entry is just an accumulation of answers with a general API to retrieve questions and their answers. Ideally it would use some meta-programming to make the code simpler and self-documenting.

class HBI
 include Questionnaire
 select(:general_wellbeing) do |s|
 s.option(:no_difficulty, 0, :optional => :stuff)
 s.option(:slightly_below_par, 1, :optional => :stuff)
 # etc
 end
end
hbi = HBI.new
hbi.answer(:general_wellbeing, 1, :optional => :stuff)
hbi.general_wellbeing # => 1 or for example an Answer object

Validating can largely be avoided, because in Entry you define which values are acceptable as answers. Simply raise an error in Entry#answer when receiving a non-sensical value (such as 10). You still need to check for completeness, though.

A validator object can validate an Entry. You could use some more meta-programming here to avoid repetition or use a library like scrivener.

class HBIValidator
 def initialize(entry)
 @entry = entry
 end
 def validate
 assert_presence(:well_being)
 # etc
 end
end
validator = HBIValidator.new(hbi)
validator.valid? # => true or false

A scoring object can then score an Entry. Again you could use some meta-programming here to avoid repetition.

Thinking about validation more, it actually is hardly necessary. Entry defines exactly which the exact

scorer = HBIScorer.new(hbi)
scorer.score # => 10 or for example a Score object.

A good term to google for if you want some help with the meta-programming is DSL (Domain Specific Language).

answered Jan 18, 2015 at 14:49
\$\endgroup\$
5
  • \$\begingroup\$ Thanks @britishtea, I hear you on these points. However, most of the scoring logic actually is moved out of the catalog already (in a CatalogScore module), only a few placeholder methods are left for the idiosyncrasies of specific catalog. Also, this doesn't really help me cut down on duplication, as I would just be moving things around for each catalog module. \$\endgroup\$ Commented Jan 18, 2015 at 15:18
  • \$\begingroup\$ Also, you're right a DSL might be a good fit here... but the question remains, what exactly would that DSL do? And how does it help me cut down on duplication while still dealing with weirdness of each particular catalog? \$\endgroup\$ Commented Jan 18, 2015 at 15:30
  • \$\begingroup\$ The DSL will take away all the boilerplate code that you currently have. I haven't inspected the example code very closely, but from what I can tell is there is a lot of duplicate boilerplate code mixed with the logic that is actually different. I will update the answer to illustrate this better. \$\endgroup\$ Commented Jan 18, 2015 at 16:47
  • \$\begingroup\$ Also, a simpler example or a clearer example would greatly help to answer your question better. \$\endgroup\$ Commented Jan 18, 2015 at 17:08
  • \$\begingroup\$ It's single responsibility principle, not "problem" ;-) \$\endgroup\$ Commented Jan 18, 2015 at 18:49

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.