#!/usr/bin/env python3
""" @brief  Study of the emergence of exagerated signals in the presence of gossip
"""

#============================================================================#
# EVOLIFE  http://evolife.telecom-paris.fr             Jean-Louis Dessalles  #
# Telecom Paris  2024-04-30                                www.dessalles.fr  #
# -------------------------------------------------------------------------- #
# License:  Creative Commons BY-NC-SA                                        #
#============================================================================#
# Documentation: https://evolife.telecom-paris.fr/Classes                    #
#============================================================================#



import sys
import random
from math import log2

sys.path.append('../../..')

import Evolife.Tools.Tools as ET
import Evolife.Social.Alliances as EA
import Evolife.Social.SocialSimulation as SSim

Gbl = None	# will hold global parameters

# When the 'PolicingProbability' feature is interpreted as binary (>< 50) and it isn't costly enough,
# random fluctuations may create policing artificially - So True seems preferrable.
GRADUALPOLICINGPROBABILITY	= True	# if True, the actual policing probability is a non-linear function of the feature
									# otherwise, it is a mere binary threshold above or below 0.5
SIGNALCOSTSHIFT = 30	# Above this percentage of max signal, individuals shift to "what is left" evaluation
SIGNALLINGTHRESHOLD = 16	# to distinguish signalers from non-signalers (for display)

	
MONITOREDSIGNALCLUSTERS = 2	
if MONITOREDSIGNALCLUSTERS > 0:
	from sklearn.cluster import KMeans
	import numpy as np

class Observer(SSim.Social_Observer):
	def __init__(self, Parameters=None):
		if Gbl.Parameter('DrawCostline', Default=1):
			self.MaxCostCoeff = -Individual.SignalCostDecrease(Gbl['BottomCompetence'])
			self.MaxCostCoeff = int(self.MaxCostCoeff + 1 - 0.000001)
		super().__init__(Parameters)
		paint = lambda x,Col: f'<b><font color="{Col}">{x}</font></b>'
		L = '<br><u>Field</u>:<br>'
		L += 'Individuals are displayed by the signal they emit as a function of their quality.<br>'
		L += f'{paint("Red dots","red")} are negative gossipers. {paint("Brown dots","brown")} are gossipees.<br>'
		L += f'{paint("Orange dots","orange")} are positive gossipers. Green dots are praised individuals.<br>'
		L += f'{paint("Blue shades","#6666FF")} get {paint("darker","#1111AA")} with gossiping probability.<br>'
		if Gbl.Parameter('DrawCostline', Default=1) and self.MaxCostCoeff > 0:
			CDColor = 'blue' if self.MaxCostCoeff > 1 else 'green'
			L += f'<br>{paint(f"Cost line in {CDColor}",CDColor)} = cost coefficient (on y-axis) depending on quality' + \
				f'<b> / {self.MaxCostCoeff} (actual middle is {-Individual.SignalCostDecrease(50):.2f})</b>' \
						if self.MaxCostCoeff > 1 else ''
			L += f'<br/>{paint("Cost line in red","red")} = normalized cost coefficient (on x-axis) depending on <b>signal</b><br/>'
		L += '<br><u>Trajectories</u>:<br>'
		# L += "x-axis: signal -- y-axis: number of outrages directed at the individual<br>"
		L += "Policing probability of individuals ranked by quality<br>"
		self.recordInfo('WindowLegends', L)
		self.recordInfo('Background', 'lightblue')
		self.recordInfo('Title', 'Praise')

	def Plot_Curve(self, F, Color, Vertical=False):
		"""	Used by Field_grid to plot cost curves
		"""
		if Vertical:
			Dots = [(F(x), x, Color, 2) \
				for x in range(Gbl['BottomCompetence'], 101) ]
		else:
			Dots = [(x, F(x), Color, 2) \
				for x in range(Gbl['BottomCompetence'], 101) ]
		# print([int(D[1] * self.MaxCostCoeff) for D in Dots])
		# ====== drawing a continuous line from one dot to the next
		return [x+y for (x,y) in zip(Dots[:-1], Dots[1:])]
		
	def Field_grid(self):
		"""	initial draw: Maximal cost line 
		"""
		G = [(100, 100, 1, 0)]	# G = list of grid coordinates
		# ====== drawing cost line
		if Gbl.Parameter('DrawCostline', Default=1) and self.MaxCostCoeff > 0:
			Color = 'blue' if self.MaxCostCoeff > 1 else 'green'
			# G = [(0, 0, 'blue', 1, 100, 100, 'blue', 1), (100, 100, 1, 0)]
			G += self.Plot_Curve(lambda x: -100 * Individual.SignalCostDecrease(x) / self.MaxCostCoeff, Color)

			G += self.Plot_Curve(lambda x: 100 * Individual.SignalCostIncrease(x) / Individual.SignalCostIncrease(100), 'red', Vertical=True)

			
		# ====== drawing signal levels
		for sl in Gbl['Levels']:	G += [(0, sl, 'blue', 1, 100, sl, 'blue', 1)]
		return G
		
	
class Individual(SSim.Social_Individual):
	"""	class Individual: defines what an individual consists of 
	"""
	def __init__(self, IdNb, maxQuality=100, features={}, parameters=None):
		self.color = None	# for display
		self.dotSize = 1	# for display
		self.top = False	# indicates that self signals at the top level
		self.BestSignal = 0	# best signal value in memory (for display)
		# self.GossipOpportunities = 0       # players' opportunities to gossip others may be capped
		
		# ====== memory of other individuals that might be admired:
		self.reputable = EA.Liker(Gbl['SocialMemory']) 
		self.praises = 0	# counts praises directed toward self (for display)
		
		# ====== memory of other individuals that might be despised:
		# self.disreputable = EA.Friend(Gbl['MaxFollowers']) 
		self.disreputable = EA.Liker(Gbl['SocialMemory']) 
		self.outrages = 0	# counts outrages directed toward self (for display)
		
		# ====== memory of other individuals that appear as pairs:
		# self.pairs = EA.Liker(Gbl['SocialMemory']) 
		
		# ====== Possibility of storing individuals whose signal has been seen or has not been seen
		self.ObservationMemory = set()
		if Gbl['SignalAmbiguity']:
			self.opaques = []
		
		# ====== Social_Individual includes friendship relations:
		SSim.Social_Individual.__init__(self, IdNb, features=features, maxQuality=maxQuality, parameters=Gbl)	# calls Reset()
		
		# ====== recalibrate quality
		BQ = Gbl['BottomCompetence']
		Q = (100 * IdNb) // maxQuality
		self.Quality = (100 - BQ) * Q // 100 + BQ
		
	def reinit(self, Newborn=False):	
		"""	called at the beginning of each year 
		"""
		if Newborn or Gbl['EraseNetwork']:	
			self.cleanMemory()
		self.Points = 0
		self.signal = None
		self.signalCost = None
		self.color = None
		self.dotSize = 1
		# ====== Non-linearity on policing probability to avoid parasitic policing
		# self.PolicingProbability = (self.feature('PolicingProbability')/100) ** 4
		self.PolicingProbability = ET.logistic(self.feature('PolicingProbability'), Steepness=1.1)
		self.activePolicing = self.Policing()
		self.MonitoringProbability = ET.logistic(self.feature('MonitoringProbability'))
		self.activeMonitoring = self.Monitoring()
		self.praises = 0	# counts praises directed toward self (for display)
		self.outrages = 0	# counts outrages directed toward self (for display)
		self.top = False

	def cleanMemory(self):
		self.forgetAll()
		self.reputable.detach()
		self.disreputable.detach()
		# self.pairs.detach()
		self.top = False
		self.praises = 0	# counts praises directed toward self (for display)
		self.ObservationMemory = set()
		if Gbl['SignalAmbiguity']:	self.opaques = []
	
	def Colour(self, Col=None):
		if Col is None and self.color is None: 
			self.color = max(34, 41 - int(self.PolicingProbability * 7.0))
		elif Col is not None:
			# print(f'Color of {self} set from {self.color} to', end=' ')
			self.color = Col
			# print(self.color)
		return self.color
		
	def update(self, infancy=True):
		"""	Updating individual's data for display 
		"""
		self.Colour() # creates color if needed
		# if infancy and not self.adult():	self.Colour('white')	# children are white
		self.BestSignal = self.bestFeatureRecord('Signal')
		if self.BestSignal is None:	self.BestSignal = 0
		# self.location = (self.Quality, self.Signal(SignalValue=self.BestSignal), Colour, -1)	# -1 == negative size --> relative size instead of pixel
		self.location = (self.Quality, self.Signal(), self.Colour(), -self.dotSize)	# -1 == negative size --> relative size instead of pixel

	def Signal(self, SignalValue=None):
		"""	returns the signal displayed based on a quantized version of the feature 
		"""
		def quantization(signal):
			"""	returns a quantized value of signal 
		"""
			Levels = Gbl['Levels']
			Precision = Gbl['SignalPrecision']
			assert(type(Precision) == int and Precision > 0)
			if Levels == []:
				# ====== Making signals discrete to avoid absurd precision
				Levels = list(range(0,100, Precision))
			for level in Levels[::-1]:
				if signal >= level:	return level
			return signal if signal < 0 else 0			# signal may be negative in certain implementations
	
		if SignalValue is not None:
			return quantization(SignalValue)
		if self.signal is None:		# computing signal only once 
			# ====== actual investment in displays
			self.signal = quantization(self.feature('Signal'))
		return self.signal
	
	def Signaller(self):
		return self.Signal() > SIGNALLINGTHRESHOLD
	
	@staticmethod
	def SignalCostIncrease(Signal):
		"""	Computes signal cost depending on signal intensity and signaler's quality
		"""
		# ====== cost increases with signal intensity
		increase = Gbl['CostIncrease']
		assert increase in ['linear', 'flat', 'logarithmic']
		assert Signal <= 100
		if 		increase == 'linear':	SCI = Signal
		elif	increase == 'flat':		SCI = 1
		elif	increase == 'logarithmic':	
			alpha = -SIGNALCOSTSHIFT / log2(1-SIGNALCOSTSHIFT/100.1)
			SCI = max(Signal, -alpha * log2(1 - Signal/100.1))
			# if Signal < SIGNALCOSTSHIFT:	SCI = Signal
			# else:	SCI = -alpha * log2(1 - Signal/100.1)
		# print(int(SCI), end=' ', flush=True)
		return SCI
	
	@staticmethod
	def SignalCostDecrease(Quality):
		# ====== cost coefficient decreases with quality
		# print(Gbl['SignallingCost'] * ET.decrease(Quality, 100, Gbl['CostDecrease']))	# max =~ 0.02
		return Gbl['SignallingCost'] * ET.decrease(Quality, 100, Gbl['CostDecrease'])	# max =~ 0.02

	def SignalCost(self):
		if self.signalCost is None:
			CD = self.SignalCostDecrease(self.Quality)
			CI = self.SignalCostIncrease(self.Signal())
			# self.signalCost = int(CD * CI)
			self.signalCost = CD * CI
			# print(f"{self.Quality},{self.Signal()},{self.signalCost}")
		return self.signalCost
	
	
	def Policing(self):
		"""	Determines whether self is ready to exert policing (denouncing others + inflict damage)
		"""
		if Gbl['EnablePolicing']:
			if GRADUALPOLICINGPROBABILITY:
				return (random.random() < self.PolicingProbability)
			else:
				return (self.feature('PolicingProbability') >= 50)
		return False
		
	def Monitoring(self):
		"""	Determines whether self is actively monitoring other's signals
		"""
		if Gbl['EnableMonitoring']:
			return (random.random() < self.MonitoringProbability)
		return True
		
	
	def Evaluate(self, Other, OSignal=None):
		"""	self sees Other's signal and remembers it. 
			Note: OSignal may differ from Other's real signal
		"""
		assert self != Other
		SSignal = self.Signal()
		if OSignal is None:	# OSignal comes from direct observation
			OSignal = Other.Signal()
			self.ObservationMemory.add(Other)
		else:	# hearsay
			if Other in self.ObservationMemory:
				# Other is already known through their true signal
				return
			assert Gbl['Visibility'] < 100
		if (Gbl['ContemptImpact'] != 0) and (SSignal > OSignal):	# individual remembered as despicable
			self.disreputable.follow(Other, -OSignal, increasing=True)	# note the negative sign
		elif SSignal < OSignal:	# that person is admirable
			# print('.', end='', flush=True)
			self.reputable.follow(Other, OSignal, increasing=True)
		else:	# keeping the possibility of treating equality separately
			# self.pairs.follow(Other, OSignal, increasing=True)
			pass
		if Gbl['SignalAmbiguity'] and (Other in self.opaques):
			self.opaques.remove(Other)
		return

	def Observe(self, Partner):	
		"""	First round of observation. Individuals witness other's performance if visible
			and make a negative or positive opinion. They may also decide to affiliate.
		"""
		if self.activeMonitoring and (random.random() <= Gbl['Visibility'] / 100.0):
			# ====== Partner is visible to self
			self.Evaluate(Partner)
		elif Gbl['SignalAmbiguity'] and (Partner not in self.opaques):
			self.opaques.append(Partner)

	def Gossip(self, Partner):
		"""	Second round: triadic interactions. 
			Self attracts Partner's attention by talking to Partner about a third party 
		"""
		if self.activePolicing:
			# ====== self tries to find a third-party to comment upon in order to become visible to Partner
			SSignal = self.Signal()
			PSignal = Partner.Signal()

			# ====== negative GOSSIP
			# ====== self talks about worse third parties to Partner
			Other = self.disreputable.best(randomTie=True)	# = worst, actually, as negative scores stored 
			
			if (Other is None) and Gbl['SignalAmbiguity'] and (self.opaques != []):
				# ====== Policing may be directed at opaque individuals
				# This option doesn't work properly. 
				# If individuals are "opaque", Partner shouldn't be able to read their signal
				Other = random.choice(self.opaques)
				self.opaques.remove(Other)
				
			if (Other is not None) and (Other != Partner) and Gbl['ContemptImpact'] != 0:
				# ====== the point of offering someone else to criticism is to show oneself
				# OSignal = -self.disreputable.performance(Other)	# stored signal, maybe not real signal
				self.Colour('red')
				OSignal = Other.Signal()	# real signal
				assert Gbl['SignalAmbiguity'] or (OSignal < SSignal)
				# By the way: we may have OSignal >= SSignal if SignalAmbiguity
				Other.outrages += 1	# (for display)
				# if not Partner.reputable.follows(Other) and not Partner.disreputable.follows(Other):
					# print('x', end='')
				Partner.Evaluate(Other, OSignal)
				# ====== self has revealed its own signal to Partner by despising Other
				# ====== Partner just knows that SSignal > OSignal (no hypocrisy)
				# if not Partner.reputable.follows(self): print('X', end='')
				if random.random() <= (Gbl['VisibilityGossip'] / 100.0):
					Partner.Evaluate(self, SSignal)
				else:
					Partner.Evaluate(self, OSignal + 1)
					
				# ======- cost of policing at each policing act each year (here) vs. each year
				# self.Points += Gbl['PolicingCost']
				
					
			# ====== positive GOSSIP
			# ====== self talks about best third parties to Partner
			Other = self.reputable.best()
			
			if (Other is not None) and (Other != Partner) and Gbl['AdmirationImpact'] != 0:
				self.Colour('orange')
				OSignal = Other.Signal()	# real signal. Note: makes Other more visible
				assert OSignal > SSignal
				Other.praises += 1	# (for display)
				Partner.Evaluate(Other, OSignal)
				# ====== Partner just knows that SSignal < OSignal
				# Partner.Evaluate(self, OSignal - 1)
				# ====== Partner just knows that SSignal > 0
				# Partner.Evaluate(self, 1)
				# ====== Partner can see SSignal
				if random.random() <= (Gbl['VisibilityGossip'] / 100.0):
					Partner.Evaluate(self, SSignal)
	
	def Interact(self, Partner):
		"""	Third round: self tries to establish friendship with Partner based on Partner's remembered signal:
			- true signal if Partner has been directly observerd
			- target's signal + 1 if Partner has gossiped about target
			- 0 otherwise
		"""
		self.Affiliate(Partner, self.ApparentSignal(Partner))	# self considers affiliating with Partner 
	
	def ApparentSignal(self, Partner):
		"""	Known Signal if Partner is known to self, otherwise 0
		"""
		if Partner in self.ObservationMemory:	
			# print('+', end='')
			return Partner.Signal()	# direct observation
		assert Gbl['Visibility'] < 100
		if Partner in self.reputable:	
			# print('+', end='')
			return self.reputable.performance(Partner)
		if Partner in self.disreputable:	
			# print('-', end='')
			return -self.disreputable.performance(Partner)
		# if Partner in self.pairs:	
			# # print('=', end='')
			# return self.pairs.performance(Partner)
		# print('o', end='')
		return 0
	
	def Affiliate(self, Partner, Performance=0):	
		"""	Asymmetrical friendship (social links)
		"""
		# ====== self selects Partner to affiliate with.
		# ====== Since 'MaxFriends' is limited, self selects Partners based on their Performance.
		return self.F_follow(0, Partner, Performance, increasing=True)
		
		
	def assessment(self):
		"""	effect of social encounters 
		"""
		# ====== Asymmetric friendship payoff
		for F in self.followees():
			# ====== individuals benefit from being followed (social links)
			F.Points += Gbl['FollowerImpact']
			if not Gbl['EnablePolicing']:
				# ====== If no praise, praising reward is converted into social reward for comparison
				F.Points += (Gbl['AdmirationImpact'] /  Gbl['MaxFriends'])
			# ====== Follower's payoff may depend on followee's quality
			# self.Points += Gbl['FollowingImpact'] * ET.increase(F.Quality/100.0, Gbl['QualityImpact'])	# linear function of quality
			self.Points += Gbl['FollowingImpact'] * ET.logistic(F.Quality)	# non-linear function of friend's quality
			
		# ====== paying the cost of signalling
		self.Points += self.SignalCost()	# signalling cost (given as negative), paid only once

		# ====== cost of policing each year (here) vs. at each policing act
		# if self.activePolicing:
			# self.Points += Gbl['PolicingCost']
			# self.Points += (Gbl['PolicingCost'] * self.feature('PolicingProbability') / 100.0)
			# self.Points += Gbl['PolicingCost'] * self.PolicingProbability
		# ------ paying the feature, not actual policing
		self.Points += (Gbl['PolicingCost'] * self.feature('PolicingProbability') / 100.0)
		
		# ====== cost of monitoring each year (here) vs. at each monitoring act
		# self.Points += (Gbl['MonitoringCost'] * self.feature('MonitoringProbability') / 100.0)
		if Gbl['EnableMonitoring'] and self.activeMonitoring:
			self.Points += Gbl['MonitoringCost'] * self.MonitoringProbability

		# ====== effect of policing
		if self.activePolicing:
			# for G in self.reputable:	G.Points += Gbl['AdmirationImpact']
			# for W in self.disreputable:	
				# W.Points += Gbl['ContemptImpact']
				# W.outrages += 1
			best = self.reputable.best()
			worst = self.disreputable.best()
			if best is not None and Gbl['AdmirationImpact'] != 0:	
				assert best != self
				best.Colour('green3')
				best.dotSize = 2
				best.Points += Gbl['AdmirationImpact']
				best.top = True
				# assert best.top, 'Error: best is not top'
			if worst is not None and Gbl['ContemptImpact'] != 0:	
				assert worst != self
				worst.Colour('brown')
				worst.Points += Gbl['ContemptImpact']
		# print(self.Points)
		
	def topSignaller(self):
		"""	Identifies top signaler (for display)
		"""
		return self.top and self.adult()
		
	
	def __str__(self):
		return f"{self.ID}[{self.Signal():0.1f} {int(self.Points)}]"
		
class Population(SSim.Social_Population):
	"""	defines the population of agents 
	"""
	def __init__(self, parameters, NbAgents, Observer):
		"""	creates a population of agents 
		"""
		# ====== Learnable features
		Features = dict()
		Features['Signal'] = ('blue',)	# propensity to signal one's quality
		Features['PolicingProbability'] = ('brown',)	# ability to gossip
		Features['MonitoringProbability'] = ('green', 1)	# ability to monitor signals
		# Features['Visibility'] = ('white',)	# makes signal visible
		SSim.Social_Population.__init__(self, Gbl, NbAgents, Observer, 
			IndividualClass=Individual, features=Features)

	def interactions(self, group):
		"""	interactions occur within a group 
		"""
		# ====== first round: observation (of individuals that happen to be visible)
		for Player in group:
			Player.cleanMemory()	# forget previous observations (which are obsolete due to learning)
		for Player, Partner in self.systematic_encounters(group, shuffle=False):
			Player.Observe(Partner)
		
		# ====== second round: gossip
		# print(len(group))
		# for Player in group:
			# print(len(Partner.disreputable) + len(Partner.reputable) + len(Partner.pairs), end=' ')
		# print()
		for Player, Partner in self.systematic_encounters(group, shuffle=True):
			Player.Gossip(Partner)
		
		# ====== third round: socializing: calls Interact 
		NbInteractions = Gbl['NbInteractions']
		if type(NbInteractions) == int:
			systematic = True
		else:
			systematic = False
			# ====== NbInteractions is interpreted as the fraction of the population seen by self
			NbInteractions = int(NbInteractions * self.PopSize)
		super().interactions(group, shuffle=True, systematic=systematic, NbInteractions=NbInteractions)
		# print('\n----------------------------------------------')

		
		# for Player, Partner in list(self.encounters(group, systematic=True, shuffle=False)):
			# ====== giving a chance for isolated individuals to affiliate to someone
			# Player.Affiliate(Partner)
		# print([indiv.nbFollowers() for indiv in group if indiv.nbFollowers() != Gbl['MaxFollowers']])
		
	def display(self):
		super().display()	# displays curves, field and social links - calls agent.update
		# ====== 'Trajectories' window
		if self.Obs.Visible():	# Statistics for display
			self.Obs.record((100,100, 0, 0), Window='Trajectories')	# sets the frame
			for A in self:
				# Colour = A.Colour()
				# TrPosition = (A.Signal()+10*random.random(), A.outrages, Colour, 7)
				# TrPosition = (A.Signal()+10*random.random(), 98 * A.PolicingProbability, Colour, -1)
				# TrPosition = (A.Signal() + 6*random.random(), A.feature('PolicingProbability'), Colour, -1)
				# TrPosition = (1000+A.Points, A.Signal(), Colour, 12)
				TrPosition = (A.Quality, A.feature('PolicingProbability'), 'orange', -1)
				self.Obs.record((A.ID, TrPosition), Window='Trajectories')

	def display_curves(self):

		if MONITOREDSIGNALCLUSTERS == 0:
			self.Obs.curve('SignalLevel', self.SignalAvg(), Color='blue', Thickness=2, Legend='Signal')

		if Gbl['EnablePolicing']:
			# self.Obs.curve('PolicingProbability', self.PolicingAvg(), Color='orange', Thickness=3, Legend='Policing Probability')
			self.Obs.curve('PolicingProbabilitySignallers', self.PolicingAvg(SignallersOnly=True), Color='orange', Thickness=3, Legend="Signallers' Policing Probability")

		if Gbl['EnableMonitoring']:
			self.display_feature('MonitoringProbability')
		
		
		if MONITOREDSIGNALCLUSTERS > 0:
			Levels = self.Levels(MONITOREDSIGNALCLUSTERS)
			# for nrolevel, (Level, nbIndividuals) in enumerate(Levels):
				# self.Obs.curve(f'SignalLevel{nrolevel+1}', Level, \
					# Color=f'blue{1+nrolevel*2}', Thickness=2, Legend=f'Signal level {nrolevel+1}')
			self.Obs.curve('SignalLevel1', Levels[0][0], \
				Color=f'blue0', Thickness=3, Legend=f'Signal level 1')
			if len(Levels) > 1 and Levels[1][1] > len(self)/10:	
				self.Obs.curve('SignalLevel2', Levels[1][0], \
					Color=f'blue6', Thickness=3, Legend=f'Signal level 2')
				# self.Obs.curve('SignalLevel2', (Levels[1][0] + Levels[2][0])/2, \
					# Color=f'blue3', Thickness=2, Legend=f'Signal level 2')
			
			# displaying cut-off competence between non-signallers and signallers
				self.Obs.curve('Cutoff', Levels[0][1] *100/len(self), Color='red', Thickness=1, Legend='Cut-off quality')
			# TentativeCutoff = len([I for I in self if I.Signal()==0]) * 100 / len(self)
			# self.Obs.curve('Cutoff', len([I for I in self if I.Quality < TentativeCutoff and I.Signal()==0]) \
				# *100/len(self), Color='red', Thickness=1, Legend='Cut-off quality')
			
			# ====== finding top signalers
			TopSignallers = [A.BestSignal for A in self if A.topSignaller()]
			self.Obs.curve('TopSignallers', 10 * len(TopSignallers) / self.NbGroup, Color='lightgreen', Thickness=1, Legend='10 x # top signalers')
			if TopSignallers:
				self.Obs.curve('SignalLevel3', sum(TopSignallers)/len(TopSignallers), \
					Color=f'green', Thickness=3, Legend=f'Signal level 3')
					
			'''
			if len(Levels) == 2:	return
			if Levels[-1][0] > Levels[-2][0] * 1.7:
				self.Obs.curve('SignalLevel3', Levels[-1][0], \
					Color=f'blue9', Thickness=2, Legend=f'Signal level 3')
				self.Obs.curve('TopSignallers', min(100, 10*Levels[-1][1]) if Gbl['BatchMode'] == 0 else 10*Levels[-1][1],
						Color='brown', Thickness=1, Legend='10 x # top signalers')
			'''
		else:
			NumberOfTopSignallers = 5
			for ii, (_,Signal) in enumerate(self.TopSignallers(NumberOfTopSignallers)):
				self.Obs.curve(f'TopSignaller{ii+1}', Signal, Color=f'green{1+ii*2}', Thickness=1, Legend=f'Top signaller {ii+1}')
			
		# TopSignallers, Legend = self.TopSignal()
		# self.Obs.curve('TopSignallers', TopSignallers, Color='green', Thickness=3, Legend=Legend)
			

	def SignalAvg(self):
		"""	average signal in the population
		"""
		Avg = 0
		for I in self:	Avg += I.Signal()
		if self.Pop:	Avg /= len(self.Pop)
		return Avg

	def TopSignallers(self, NumberOfTopSignallers):
		"""	Identification of top signallers - Used instead of clustering
		"""
		# ====== using club as a limited list
		topSignallers = EA.club(sizeMax=NumberOfTopSignallers)
		for I in self:	
			if I.Age < Gbl['MemorySpan']:	continue
			# topSignallers.select(I, I.Signal())	# only enters if signal is large enough
			topSignallers.select(I, I.BestSignal)	# only enters if signal is large enough
		topSignallers.limit(NumberOfTopSignallers)	# sorts the list
		assert len(topSignallers) > 0
		return topSignallers

	def TopSignal(self):
		"""	Computes average signal of top signallers
		"""
		Percentile = 5
		# ====== using club as a limited list
		TopSignallers = self.TopSignallers(self.PopSize * Percentile / 100.0)
		return TopSignallers.average(), f'Avg signal of {Percentile}% top signalers'
		"""
		TSProp = 0	# number of top signalers

		if Gbl['AdmirationImpact'] != 0 and Gbl['Levels'] == []:	# continuous praise
			TSSignal = 0	# signal of top signalers
			for I in self:	
				if I.top:
					TSProp += 1
					TSSignal += I.Signal()	# avg signal of top signalers
			Legend = 'Avg signal of top signalers'
			return TSSignal / TSProp if TSProp else 0, Legend
		for I in self:	
			TSProp += (1 if I.Signal() >= I.Signal(90) else 0)	# number of top signalers
		TopSignallersMultFactor = 10 if Gbl['AdmirationImpact'] != 0 else 1
		Legend = 'Proportion of top signalers' + f" x {TopSignallersMultFactor}" * (TopSignallersMultFactor != 1)
		return TSProp / len(self.Pop) if self.Pop else 0, Legend
		"""
		
	def Levels(self, NbClusters):
		"""	Analyses signal values to find clusters
		"""
		# ====== performing clustering on signals
		Signals = np.array([A.Signal() for A in self]).reshape(-1,1)
		kmeans = KMeans(n_clusters=NbClusters, algorithm='elkan').fit(Signals)
		# ====== Get the cluster sizes
		unique_labels, counts = np.unique(kmeans.labels_, return_counts=True)
		cluster_sizes = dict(zip(unique_labels, counts))
		cluster_info = {}
		for label, center in zip(unique_labels, kmeans.cluster_centers_):
			cluster_info[label] = (int(center[0]), cluster_sizes[label])
		# print(sorted(cluster_info.values()))
		return (sorted(cluster_info.values()))
	
	def PolicingAvg(self, SignallersOnly=False):
		"""	average policing probability in the population
		"""
		if SignallersOnly:
			PProb = [100 * I.PolicingProbability for I in self if I.Signaller()]
		else:
			PProb = [100 * I.PolicingProbability for I in self]
		if PProb:	return sum(PProb)/len(PProb)
		return 0

	def Dump(self, Slot):
		""" Saving investment in signalling for each adult agent
			and then distance to best friend for each adult agent having a best friend
		"""
		if Slot == 'DistanceToBestFriend':
			D = [(agent.Quality, "%d" % abs(agent.best_friend().Quality - agent.Quality)) for agent in self if agent.adult() and (agent.best_friend() is not None)]
			D += [(agent.Quality, " ") for agent in self if agent.best_friend() == None or not agent.adult()]
		else:
			D = [(agent.Quality, "%2.03f" % agent.feature(Slot)) for agent in self]
		return [Slot] + [d[1] for d in sorted(D, key=lambda x: x[0])]
		# return D
		

if __name__ == "__main__":
	Gbl = SSim.Global('___Patriot.evo')
	B = Gbl['LevelBase']	# not evenly spaced levels if B is nonzero (or mere tuple of levels)
	Gbl['Levels'] = []
	if type(Gbl['SignalLevels']) == int:
		if B > 0:	
			Gbl['Levels'] = [(B**level-1) * 100/B**Gbl['SignalLevels'] 
					for level in range(1, Gbl['SignalLevels']+1)]
		elif B < 0:
			Gbl['Levels'] = sorted([90 - abs(B)**level * 90/B**Gbl['SignalLevels'] 
					for level in range(1, Gbl['SignalLevels']+1)])
		else:	Gbl['Levels'] = [level * 90/Gbl['SignalLevels'] 
					for level in range(1, Gbl['SignalLevels']+1)]
	else:	# levels directly provided as tuple
		Gbl['Levels'] = tuple(Gbl['SignalLevels'])
		# Gbl['SignalLevels'] = len(Gbl['Levels'])
	if Gbl['BatchMode'] == 0:	
		print(__doc__)
		print('Levels:', list(map(int, Gbl['Levels'])))
	# Start()
	SSim.Start(Params=Gbl, PopClass=Population, ObsClass=Observer, DumpFeatures=['Signal', 'PolicingProbability'], Windows='FCNT')


__author__ = 'Dessalles'
