# Copyright (C) 2011 AMEE UK Ltd. - http://www.amee.com
# Released as Open Source Software under the BSD 3-Clause license. See LICENSE.txt for details.
# :title: Class: AMEE::DataAbstraction::OngoingCalculation
module AMEE
module DataAbstraction
# Instances of the OngoingCalculation class represent actual
# calculations made via the AMEE platform.
#
# The class inherits from the Calculation class and is therefore
# primarly characterised by the label, name, and path
# attributes, as well as an associated instance of the TermsList
# class which represents each of the values (input, outputs, metdata) involved
# in the calculation.
#
# Instances of OngoingCalcualtion are typically instantiated from an
# instance of PrototypeCalculation using the #begin_calculation
# method, e.g.
#
# my_prototype.begin_calculation #=>
#
# In this case, the new instance inherits all of the attributes and terms
# defined on the PrototypeCalculation template.
#
# In contrast to instances of PrototypeCalculation, instances of the
# OngoingCalculation class will typically have their term values
# explicitly set according to the specific calculation scenario being
# represented. Other term attributes, e.g. units, may also be modified on a
# calculation-by-calculation basis. These values and other attributes form
# (at least some of) the data which is passed to the AMEE platform in order
# to make caluclations.
#
class OngoingCalculation < Calculation
public
# String representing the AMEE platform profile UID assocaited with self
attr_accessor :profile_uid
# String representing the AMEE platform profile item UID assocaited with self
attr_accessor :profile_item_uid
# Hash of invalidity messages. Keys are represent the labels of terms
# assocaited with self. Values are string error message reports associated
# with the keyed term.
#
attr_accessor :invalidity_messages
# Construct an Ongoing Calculation. Should be called only via
# PrototypeCalculation#begin_calculation. Not intended for external
# use.
#
def initialize
super
dirty!
reset_invalidity_messages
end
# Returns true if the value of a term associated with self has been changed
# since the calculation was last synchronized with the AMEE platform.
# Otherwise, return false.
#
def dirty?
@dirty
end
# Declare that the calculation is dirty, i.e. that changes to term values
# have been made since self was last synchronized with the AMEE platform,
# in which case a synchonization with with AMEE must occur for self to be
# valid.
#
def dirty!
@dirty=true
end
# Declare that the calculation is not dirty, and need not be sent to AMEE
# for results to be valid.
#
def clean!
inputs.each{|i| i.clean!}
@dirty=false
end
# Returns true if all compulsory terms are set, i.e. ahve non-nil values.
# This inidicates that self is ready to be sent to the AMEE platform for
# outputs to be calculated
#
def satisfied?
inputs.compulsory.unset.empty?
end
# Mass assignment of (one or more) term attributes (value, unit, per_unit)
# based on data defined in choice. choice should be
# a hash with keys representing the labels of terms which are to be updated.
# Hash values can represent either the the value to be assigned explicitly,
# or, alternatively, a hash representing any or all of the term value, unit
# and per_unit attributes (keyed as :value, :unit and :per_unit).
#
# Unit attributes can be represented by any form which is accepted by the
# Quantify::Unit#for method (either an instance of
# Quantify::Unit::Base (or subclass) or a symbolized or string
# representation of the a unit symbol, name or label).
#
# Nil values are ignored. Term attributes can be intentionally blanked by
# passing a blank string as the respective hash value.
#
# Examples of options hash which modify only term values:
#
# options = { :type => 'van' }
#
# options = { :type => 'van',
# :distance => 100 }
#
# options = { :type => 'van',
# :distance => "" }
#
# Examples of options hash which modify other term attributes:
#
# options = { :type => 'van',
# :distance => { :value => 100 }}
#
# options = { :type => 'van',
# :distance => { :value => 100,
# :unit => :mi }}
#
# options = { :type => 'van',
# :distance => { :value => 100,
# :unit => 'feet' }}
#
# my_distance_unit =
# my_time_unit =
#
# options = { :type => 'van',
# :distance => { :value => 100,
# :unit => my_unit,
# :per_unit => my_time_unit }}
#
# my_calculation.choose_without_validation!(options)
#
# Do not attempt to check that the values specified are acceptable.
#
def choose_without_validation!(choice)
# Make sure choice keys are symbols since they are mapped to term labels
# Uses extension methods for Hash defined in /core_extensions
choice = choice.recursive_symbolize_keys
new_profile_uid= choice.delete(:profile_uid)
self.profile_uid=new_profile_uid if new_profile_uid
new_profile_item_uid= choice.delete(:profile_item_uid)
self.profile_item_uid=new_profile_item_uid if new_profile_item_uid
choice.each do |k,v|
next unless self[k]
if v.is_a? Hash
# if has_key? clause included so that single attributes can
# be updated without nullifying others if their values are not
# explicitly passed. Intentional blanking of values is enabled by
# passing nil or "".
#
self[k].value v[:value] if v.has_key?(:value)
self[k].unit v[:unit] if v.has_key?(:unit)
self[k].per_unit v[:per_unit] if v.has_key?(:per_unit)
else
self[k].value v
end
end
end
# Similar to #choose_without_validation! but performs validation
# on the modified input terms and raises a ChoiceValidation
# exception if any of the values supplied is invalid
#
def choose!(choice)
choose_without_validation!(choice)
validate!
raise AMEE::DataAbstraction::Exceptions::ChoiceValidation.new(invalidity_messages) unless
invalidity_messages.empty?
end
# Similar to #choose! but returns false if any term
# attributes are invalid, rather than raising an exception. Returns
# true if validation is successful.
#
def choose(choice)
begin
choose!(choice)
return true
rescue AMEE::DataAbstraction::Exceptions::ChoiceValidation
return false
end
end
# Synchonizes the current term values and attributes with the AMEE platform
# if self is dirty?, and subsequently calls clean!
# on self
#
def calculate!
return unless dirty?
syncronize_with_amee
clean!
end
# Check that the values set for all terms are acceptable, and raise a
# ChoiceValidation exception if not. Error messages are available
# via the self.invalidity_messages hash.
#
def validate!
return unless dirty?
reset_invalidity_messages
inputs.each do |d|
d.validate! unless d.unset?
end
autodrill
end
# Declare that the term labelled by label has an unnaceptable value
# and load the message into the invalidity_messages hash.
#
def invalid(label,message)
@invalidity_messages[label]=message
end
# Set the values of any invalid terms to nil. Can be called following any
# of the #choose... methods so that invalid terms resulting from
# modification of a previously valid calculation can be cleared. This is
# particularly useful in cases where drill down choices have been changed
# thus invalidating the choices for subsequent drills.
#
def clear_invalid_terms!
terms.select do |term|
invalidity_messages.keys.include?(term.label)
end.each do |term|
term.value nil
end
reset_invalidity_messages
end
def clear_outputs
outputs.each {|output| output.value nil }
end
def ==(other_calc)
!terms.inject(false) do |boolean,term|
boolean || term != other_calc[term.label]
end && label == other_calc.label
end
private
# Empty the hash of error messages for term choices.
def reset_invalidity_messages
@invalidity_messages={}
end
# Obtain from the AMEE platform the results of a calculation, and set these
# to the output terms of self.
#
def load_outputs
outputs.each do |output|
res=nil
if output.path.to_s=='default'
res= profile_item.amounts.find{|x| x[:default] == true}
else
res= profile_item.amounts.find{|x| x[:type] == output.path}
end
if res
output.value res[:value]
output.unit res[:unit]
output.per_unit res[:per_unit]
else
# If no platform result, then no outputs should be set. Added so that
# nullifying a compulsory PIV wipes output value, otherwise previous
# output values persist after PIVs have been removed.
#
output.value nil
end
end
end
# Load any metadata stored in the AMEE profile which can be used to set
# metadata for self. Not implemented in AMEE yet.
#
def load_metadata
end
# If a profile item exists for self, load the corresponding values
# and units for any unset Profile terms (profile item values) from
# the AMEE platform
#
def load_profile_item_values
return unless profile_item
profiles.unset.each do |term|
ameeval=profile_item.values.find { |value| value[:path] == term.path }
term.value ameeval[:value]
term.unit ameeval[:unit]
term.per_unit ameeval[:per_unit]
end
end
# Load drill values from the AMEE platform. If the remote drills selections
# are different than locally set values, raise a Syncronization
# exception.
#
# If an exception is raised, typical practice would be to delete the profile
# item associated with self and create a new one with the current
# selection of drill down choices (see, for example, the
# #synchronize_with_amee method
#
def load_drills
return unless profile_item
drills.each do |term|
next unless term.value.nil? || term.dirty?
ameeval=data_item.value(term.path)
raise Exceptions::Syncronization if term.set? && ameeval!=term.value
term.value ameeval
end
end
# Dispatch the calculation to AMEE. If necessary, delete an out of date
# AMEE profile item and create a new one. Fetch any values which are stored
# in the AMEE platform and not stored locally. Send any values stored locally
# and not stored in the AMEE platform. Fetch calculation results from the
# AMEE platform and update outputs assocaited with self
#
def syncronize_with_amee
new_memoize_pass
find_profile
load_profile_item_values
begin
load_drills
rescue Exceptions::Syncronization
delete_profile_item
clear_outputs
end
load_metadata
# We could create an unsatisfied PI, and just check drilled? here
if satisfied?
if profile_item
set_profile_item_values
else
create_profile_item
end
load_outputs
end
rescue AMEE::UnknownError
# Tidy up, only if we created a "Bad" profile item.
# Need to check this condition
delete_profile_item
raise DidNotCreateProfileItem
end
# Returns a String representation of drill down choices appropriate for
# submitting to the AMEE platform. An optional hash argument can be provided,
# with the key :before in order to specify the drill down choice
# to which the representation is required, e.g.
#
# my_calc.drill_options #=> "type=van&fuel=petrol&size=2.0+litres"
#
# my_calc.drill_options(:before => :size) #=> "type=van&fuel=petrol"
#
def drill_options(options={})
to=options.delete(:before)
drills_to_use=to ? drills.before(to).set : drills.set
drills_to_use.map{|x| "#{CGI.escape(x.path)}=#{CGI.escape(x.value)}"}.join("&")
end
# Returns a Hash representation of Profile term attributes
# appropriate for submitting to the AMEE platform via the AMEE rubygem.
#
def profile_options
result={}
profiles.set.each do |piv|
result[piv.path]=piv.value
result["#{piv.path}Unit"]=piv.unit.label unless piv.unit.nil?
result["#{piv.path}PerUnit"]=piv.per_unit.label unless piv.per_unit.nil?
end
if contents[:start_date] && !contents[:start_date].value.blank?
result[:start_date] = contents[:start_date].value
end
if contents[:end_date] && !contents[:end_date].value.blank?
result[:end_date] = contents[:end_date].value
end
return result
end
# Returns a Hash of options for profile item GET requests to the AMEE
# platform.
#
# This is where is where return unit convertion requests can be handled
# if/when theseare implemented.
#
def get_options
# Specify unit options here based on the contents
# getopts={}
# getopts[:returnUnit] = params[:unit] if params[:unit]
# getopts[:returnPerUnit] = params[:perUnit] if params[:perUnit]
return {}
end
# Return the AMEE::Profile::Profile object under which the AMEE profile
# item associated with self belongs and and update
# self.profile_uid to make the appropriate reference.
#
def find_profile
unless self.profile_uid
prof ||= AMEE::Profile::ProfileList.new(connection).first
prof ||= AMEE::Profile::Profile.create(connection)
self.profile_uid=prof.uid
end
end
# Generate a unique name for the profile item assocaited with self.
# This is required in order to make similarly drilled profile items within
# the same profile distinguishable.
#
# This is random at present but could be improved to generate more meaningful
# name by interrogating metadata according to specifications of an
# organisational model.
#
def amee_name
UUIDTools::UUID.timestamp_create
end
# Create a profile item in the AMEE platform to be associated with
# self. Raises AlreadyHaveProfileItem exception if a
# profile item value is already associated with self
#
def create_profile_item
raise Exceptions::AlreadyHaveProfileItem unless profile_item_uid.blank?
@profile_item = AMEE::Profile::Item.create(profile_category,amee_drill.data_item_uid,
profile_options.merge(:get_item => true, :name => amee_name))
self.profile_item_uid = @profile_item.uid
end
# Methods which should be memoized once per interaction with AMEE to minimise
# API calls. These require wiping at every pass, because otherwise, they might
# change, e.g. if metadata changes change the profile
#
MemoizedProfileInformation=[:profile_item,:data_item,:profile_category]
# Clear the memoized values.
def new_memoize_pass
MemoizedProfileInformation.each do |prop|
instance_variable_set("@#{prop.to_s}",nil)
end
end
# Return the AMEE::Profile::Item object associated with self. If
# not set, instantiates via the AMEE platform and assigns to self
#
def profile_item
@profile_item||=AMEE::Profile::Item.get(connection, profile_item_path, get_options) unless profile_item_uid.blank?
end
# Update the associated profile item in the AMEE platform with the current
# Profile term values and attributes
#
def set_profile_item_values
@profile_item = AMEE::Profile::Item.update(connection,profile_item_path,
profile_options.merge(:get_item=>true))
end
# Delete the profile item which is associated with self from the
# AMEE platform and nullify the local references (i.e.
# @profile_item and #profile_item_uid)
#
def delete_profile_item
AMEE::Profile::Item.delete(connection,profile_item_path)
self.profile_item_uid=nil
@profile_item=nil
end
# Returns a string representing the AMEE platform path to the profile
# category associated with self
#
def profile_category_path
"/profiles/#{profile_uid}#{path}"
end
# Returns a string representing the AMEE platform path to the profile
# item associated with self
#
def profile_item_path
"#{profile_category_path}/#{profile_item_uid}"
end
# Returns a string representing the AMEE platform path to the data item
# associated with self
#
def data_item_path
"/data#{path}/#{data_item_uid}"
end
# Returns a string representing the AMEE platform UID for the data item
# associated with self
#
def data_item_uid
profile_item.data_item_uid
end
# Return the AMEE::Data::Item object associated with self. If
# not set, instantiates via the AMEE platform and assigns to self
#
def data_item
@data_item||=AMEE::Data::Item.get(connection, data_item_path, get_options)
end
# Return the AMEE::Profile::Category object associated with self. If
# not set, instantiates via the AMEE platform and assigns to self
#
def profile_category
@profile_category||=AMEE::Profile::Category.get(connection, profile_category_path)
end
public
# Automatically set the value of a drill term if there is only one choice
def autodrill
picks=amee_drill.selections
picks.each do |path,value|
# If drill term does not exist, initialize a dummy instance.
#
# This is useful in those cases where some drills selections are unecessary
# (i.e. not all choices require selection for data items to be uniquely
# identified) and removes the need to explicitly specify the blank drills
# in configuration. This doesn't matter if calculations are auto configured.
#
if drill = drill_by_path(path)
drill.value value
else
drills << Drill.new {path path; value value}
end
end
end
# Instantiate an AMEE::Data::DrillDown object representing the
# drill down sequence defined by the drill terms associated with
# self. As with #drill_options, An optional hash argument
# can be provided, with the key :before in order to specify the
# drill down choice to which the representation is required, e.g.
#
# my_calc.amee_drill(:before => :size)
#
def amee_drill(options={})
AMEE::Data::DrillDown.get(connection,"/data#{path}/drill?#{drill_options(options)}")
end
end
end
end