/// <summary> /// Reassigns the rates in <see cref="names2Rates"/> to the Item2 of the matched tuple in <see cref="reassignNames2Rates"/> /// preserving the sum total of <see cref="names2Rates"/> values. /// </summary> /// <param name="names2Rates"></param> /// <param name="reassignNames2Rates"></param> /// <param name="derivativeSlope"> /// Is passed into the <see cref="Etx.RandomDiminishingPortions"/> - see its annotation. Which entries /// receive the re-located rates is based on a diminishing probability. /// </param> /// <returns></returns> public static Dictionary <string, double> ReassignRates(Dictionary <string, double> names2Rates, List <Tuple <string, double> > reassignNames2Rates, double derivativeSlope = -1.0D) { if (names2Rates == null || !names2Rates.Any() || reassignNames2Rates == null || !reassignNames2Rates.Any()) { return(names2Rates); } //first check that the reassign isn't already perfectly ordered and sum to 1 var reassignHasEveryKey = names2Rates.All(kv => reassignNames2Rates.Any(rkv => String.Equals(rkv.Item1, kv.Key, StringComparison.OrdinalIgnoreCase))); var reassignIsSumOfOne = IsCloseEnoughToOne(Math.Round(reassignNames2Rates.Select(kv => kv.Item2).Sum(), DF_ROUND_DECIMAL_PLACES - 1)); if (reassignHasEveryKey && reassignIsSumOfOne) { return(reassignNames2Rates.ToDictionary(t => t.Item1, t => t.Item2)); } var relocateRates = new List <double>(); var idxNames = new List <string>(); var n2r = new Dictionary <string, double>(); bool IsMatch(Tuple <string, double> tuple, string s) => tuple.Item1 != null && string.Equals(tuple.Item1, s, StringComparison.OrdinalIgnoreCase); //make temp copies of all the differences in reassignment foreach (var k in names2Rates.Keys) { var currentRate = Math.Round(names2Rates[k], DF_ROUND_DECIMAL_PLACES); if (reassignNames2Rates.Any(x => IsMatch(x, k))) { foreach (var reassign in reassignNames2Rates.Where(x => IsMatch(x, k))) { var diffInRate = Math.Round(currentRate - reassign.Item2, DF_ROUND_DECIMAL_PLACES); relocateRates.Add(diffInRate); if (n2r.ContainsKey(k)) { n2r[k] = Math.Round(reassign.Item2, DF_ROUND_DECIMAL_PLACES); } else { n2r.Add(k, Math.Round(reassign.Item2, DF_ROUND_DECIMAL_PLACES)); } } } else { idxNames.Add(k); n2r.Add(k, currentRate); } } if (!relocateRates.Any() || !idxNames.Any()) { return(names2Rates); } //we don't wont to distribute the reassigned amount evenly var diminishing = Etx.RandomDiminishingPortions(idxNames.Count, derivativeSlope); var idxName2DiminishingRate = new Dictionary <string, double>(); for (var i = 0; i < idxNames.Count; i++) { idxName2DiminishingRate.Add(idxNames[i], diminishing[i]); } //split the relocateRates between positive and negative var negativeRelocatRates = relocateRates.Where(x => x < 0.0D); var positiveRelocateRates = relocateRates.Where(x => x > 0.0D); foreach (var relocate in positiveRelocateRates) { //pick one of the items not given a direct assignment with bias var randKey = Etx.RandomPickOne(idxName2DiminishingRate); n2r[randKey] += relocate; } var totalNegative = Math.Round(Math.Abs(negativeRelocatRates.Sum()), DF_ROUND_DECIMAL_PLACES); while (totalNegative > 0.0D) { var randKey = Etx.RandomPickOne(idxName2DiminishingRate); var decrement = Math.Round(totalNegative * idxName2DiminishingRate[randKey], DF_ROUND_DECIMAL_PLACES); if (Math.Abs(decrement) == 0.0D) { break; } if (n2r[randKey] - decrement < 0.0D) { //this should push the value to 0 decrement = Math.Round(n2r[randKey], DF_ROUND_DECIMAL_PLACES); } n2r[randKey] -= decrement; totalNegative = Math.Round(totalNegative - decrement, DF_ROUND_DECIMAL_PLACES); } n2r = n2r.ToDictionary(kv => kv.Key, kv => Math.Round(kv.Value, DF_ROUND_DECIMAL_PLACES - 1)); return(n2r); }
/// <summary> /// The capital method to get random portions for a list of names. /// </summary> /// <param name="itemOrGroupNames"> /// These are the names with which a random portion is supposed to be generated. /// </param> /// <returns> /// A set of item names to some percent where the sum of all the name's portion is 1 (i.e. 100%). /// </returns> /// <remarks> /// <![CDATA[ /// FAQ /// Q: What happens if no names are given? /// A: An ArgumentNullException is thrown. /// /// Q: What happens if you just invoke it with no options whatsoever (meaning, just instantiate it and call this)? /// A: Then every item-name gets a truly random value - the sum of which equals 1. /// /// Q: How does SumTotal work with GivenDirectly? /// A: The only time it matters is when SumTotal exceeds the GivenDirectly's cumulative total; furthermore, SumTotal has no use /// when the GivenDirectly is empty since we are getting random portions and not random values. /// /// Q: So what happens when there are GivenDirectly values and no SumTotal? /// A: Then its just doing the math and nothing is random. /// /// Q: What happens if SumTotal is less-than GivenDirectly's cumulative total. /// A: Then SumTotal is just reassigned to the cumulative total and it again just doing the math - nothing random. /// /// Q: What about when SumTotal exceeds cumulative total? /// A: Then the excess amount is what is used to generate random portions for the other item-names not present /// in GivenDirectly. /// /// Q: What if there are no items in GivenDirectly? /// A: It just resorts back to all item-names having a random portion. /// /// Q: What happens when the item-names don't match the names present in GivenDirectly? /// A: The output is always tied to the item-names - any GivenDirectly not found in the item-names is ignored. /// /// Q: Can a GivenDirectly entry be assigned an value of zero? /// A: Yes, and that is their main purpose to selectively remove randomness for certian item-names - recall /// that the function wants to assign some portion to every item-name, no matter how small. /// /// Q: What happens if I force every item to be zero using GivenDirectly? /// A: An exception is thrown - the function cannot satisfy portions whose sum is equal to both zero and one. /// /// Q: How do the PossibleZeroOuts play with explict values on GivenDirectly? /// A: The PossiableZeroOuts are only considered when they are not present in the GivenDirectly. /// /// Q: What if the SumTotal exceeds the GivenDirectly's sum but all the other item-names are present /// in the PossiblyZeroOut's and, it just so happens, that they all get selected to be zero-ed out? /// A: It leaves one to receive the excess - in effect forcing the dice role to be false for at least /// one of the PossiblyZeroOuts in this case no matter the odds. /// ]]> /// </remarks> public virtual List <Tuple <string, double> > GetNames2Portions(string[] itemOrGroupNames) { const StringComparison STR_OPT = StringComparison.OrdinalIgnoreCase; //make this required if (itemOrGroupNames == null || !itemOrGroupNames.Any()) { throw new ArgumentNullException(nameof(itemOrGroupNames)); } var givenDirectlyItems = GivenDirectly ?? new List <Tuple <VocaBase, double> >(); //immediately reduce this to only the items present in 'itemNames' givenDirectlyItems = givenDirectlyItems.Where(gd => itemOrGroupNames.Any(n => string.Equals(gd.Item1.Name, n, STR_OPT))).ToList(); //get the direct assign's total var givenDirectTotal = givenDirectlyItems.Where(x => x?.Item2 != null) .Select(x => Math.Round(Math.Abs(x.Item2), DF_ROUND_DECIMAL_PLACES)).Sum(); //get total given by the caller if any var sumTotalR = SumTotal.GetValueOrDefault(0); //get a random rate for all item names var randPortions = Etx.RandomDiminishingPortions(itemOrGroupNames.Length, _derivativeSlope); //put this random rate together with each item name var randMap = itemOrGroupNames .Zip(randPortions, (n, v) => new Tuple <string, double>(n, v)).ToList(); //convert it to a dictionary var randDict = new Dictionary <string, double>(); foreach (var t in randMap) { randDict.Add(t.Item1, t.Item2); } //filter zero out's down, likewise, to only what's actually in itemNames var possibleZeroOuts = Pzos2Prob.Keys.Distinct().ToList(); possibleZeroOuts = possibleZeroOuts .Where(p => itemOrGroupNames.Any(i => String.Equals(p, i, STR_OPT))).ToList(); //zero outs will get applied just like any other ReassignRates var actualZeroOuts = new List <Tuple <string, double> >(); //select actual zero outs from the possible zero outs if (possibleZeroOuts.Any()) { foreach (var pzo in possibleZeroOuts) { //this is the only random part in actual zero-outs var diceRoll = Pzos2Prob.ContainsKey(pzo) ? Pzos2Prob[pzo] : _defaultDice; //these predicates are filters var isAlreadyPresent = actualZeroOuts.Any(z => z.Item1 == pzo); var isInGivenDirectly = givenDirectlyItems.Any(x => string.Equals(x.Item1.Name, pzo, STR_OPT)); if (diceRoll(pzo) && !isAlreadyPresent && !isInGivenDirectly) { actualZeroOuts.Add(new Tuple <string, double>(pzo, 0.0D)); } } } //apply any GivenDirectly's of zero like PossibleZeroOuts foreach (var dr in givenDirectlyItems.Where(o => o.Item2 == 0)) { if (actualZeroOuts.All(z => z.Item1 != dr.Item1.Name)) { actualZeroOuts.Add(new Tuple <string, double>(dr.Item1.Name, 0.0D)); } } //zero out all the select names if (actualZeroOuts.Any()) { //make one last check that we aren't zero'ing out everything if (actualZeroOuts.Count == itemOrGroupNames.Length) { throw new WatDaFookIzDis("A sum total of 1 cannot be perserved when " + "all items have been directly assigned to 0."); } randDict = ReassignRates(randDict, actualZeroOuts, _derivativeSlope); } //there is nothing left to do so leave if (givenDirectTotal == 0.0D) { return(randDict.Select(kv => new Tuple <string, double>(kv.Key, kv.Value)).ToList()); } //we will need a denominator if the caller didn't give one use the sum what they did give var total = sumTotalR; //if caller gives a sumtotal in which all the GivenDirectly won't fit then up the total to make it fit if (total < givenDirectTotal) { total = givenDirectTotal; } //get a dict of group names to all 0.0D var calcDict = new Dictionary <string, double>(); //get the sum of each given directly item foreach (var d in givenDirectlyItems) { var dName = d.Item1.Name; if (string.IsNullOrWhiteSpace(dName) || d.Item2 == 0) { continue; } if (calcDict.ContainsKey(dName)) { calcDict[dName] += Math.Abs(d.Item2); } else { calcDict.Add(dName, Math.Abs(d.Item2)); } } var calcMap = new List <Tuple <string, double> >(); //get the item sum as a ratio of the total foreach (var k in itemOrGroupNames) { //we only want to reassign when value was explicitly given in options if (!calcDict.ContainsKey(k)) { continue; } var rate = Math.Round(calcDict[k] / total, DF_ROUND_DECIMAL_PLACES); calcMap.Add(new Tuple <string, double>(k, rate)); } //if the calc map doesn't leave any room for reassignment then we are done if (IsCloseEnoughToOne(Math.Round(calcMap.Select(t => t.Item2).Sum(), DF_ROUND_DECIMAL_PLACES))) { //still need to add in the 0.0 items since calcMap has only the directly assigned values foreach (var k in itemOrGroupNames) { if (calcMap.Any(t => string.Equals(t.Item1, k, STR_OPT))) { continue; } calcMap.Add(new Tuple <string, double>(k, 0.0D)); } return(calcMap); } //convert it to a dictionary return(ReassignRates(randDict, calcMap).Select(kv => new Tuple <string, double>(kv.Key, kv.Value)).ToList()); }