The Situation
I have created some code in the form of modules that each represent a medical questionnaire (I'm calling them Catalog
s). 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
Catalog
s are included in anEntry
class and can't step on each other - Some
Catalog
s incorporate things like "Current Weight" for calculations, breaking the 1-5 or 1-10 paradigm and not fitting very nicely into simplesum
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’s 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