File: ResourcesAI.py

package info (click to toggle)
freeorion 0.5.1.2-1
  • links: PTS, VCS
  • area: main
  • in suites: forky, sid
  • size: 194,920 kB
  • sloc: cpp: 186,821; python: 40,979; ansic: 1,164; xml: 721; makefile: 32; sh: 7
file content (907 lines) | stat: -rw-r--r-- 43,755 bytes parent folder | download | duplicates (2)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
"""
ResourcesAI.py provides generate_resources_orders which sets the focus for all populated planets in the empire.

The method is to start with a raw list of all the populated planets in the empire.
It considers in turn growth factors, production specials, defense requirements
and finally the targeted ratio of research/production. Each decision on a planet
transfers the planet from the raw list to the baked list, until all planets
have their future focus decided.
"""
from __future__ import annotations

import freeOrionAIInterface as fo
from collections.abc import Sequence
from functools import total_ordering
from itertools import chain
from logging import debug, info, warning
from operator import itemgetter
from typing import NamedTuple

import AIDependencies
import PlanetUtilsAI
import ProductionAI
from aistate_interface import get_aistate
from buildings import BuildingType
from colonization.calculate_planet_colonization_rating import empire_metabolisms
from common.fo_typing import PlanetId
from common.print_utils import Table, Text
from empire.growth_specials import get_growth_specials
from EnumsAI import FocusType, PriorityType, get_priority_resource_types
from freeorion_tools import get_named_real, get_species_industry, get_species_research, tech_is_complete
from freeorion_tools.bonus_calculation import adjust_direction
from freeorion_tools.statistics import stats
from freeorion_tools.timers import AITimer
from PolicyAI import PolicyManager, bureaucracy, liberty
from turn_state import (
    computronium_candidates,
    honeycomb_candidates,
    population_with_industry_focus,
)

resource_timer = AITimer("timer_bucket")

# Local Constants
INDUSTRY = FocusType.FOCUS_INDUSTRY
RESEARCH = FocusType.FOCUS_RESEARCH
GROWTH = FocusType.FOCUS_GROWTH
PROTECTION = FocusType.FOCUS_PROTECTION
INFLUENCE = FocusType.FOCUS_INFLUENCE
supported_foci = {INDUSTRY, RESEARCH, INFLUENCE, GROWTH, PROTECTION}
USELESS_RATING = -99999  # focus is either not available or stability is too bad


def _focus_name(focus: str) -> str:
    _known_names = {
        INDUSTRY: "Industry",
        RESEARCH: "Research",
        GROWTH: "Growth",
        PROTECTION: "Defense",
        INFLUENCE: "Influence",
    }
    return _known_names.get(focus, focus)


class Output(NamedTuple):
    industry: float
    research: float
    influence: float
    stability: float
    rating: float  # sum of all production values weighed by priorities for overall comparisons


class FocusRating(NamedTuple):
    rating: float
    focus: str


class PlanetFocusRating:
    """
    Best and second after the best focuses for the planet.
    """

    def __init__(self, focuses: Sequence[tuple[float, str]]):
        """
        Initialize object wih best and second best focuses.

        There is a number of focuses equal length of the `supported_foci`
        """
        assert len(focuses) == len(supported_foci)
        focuses = sorted((FocusRating(rating, focus) for rating, focus in focuses), reverse=True)
        self.best = focuses[0]
        self.second = focuses[1]


@total_ordering
class PlanetFocusInfo:
    """The current, possible and future foci and output of one planet."""

    def __init__(self, planet: fo.planet):
        self.planet = planet
        # Make sure current_focus is set, without it some calculation may go astray.
        self.current_focus = planet.focus if planet.focus else fo.getSpecies(planet.speciesName).preferredFocus
        self.current_output = Output(
            industry=planet.currentMeterValue(fo.meterType.industry),
            research=planet.currentMeterValue(fo.meterType.research),
            influence=planet.currentMeterValue(fo.meterType.influence),
            stability=planet.currentMeterValue(fo.meterType.happiness),
            rating=0.0,  # cannot calculate the rating here, do we even need it?
        )
        self.possible_output = {}
        self.future_focus = self.current_focus
        self.rated_foci: PlanetFocusRating = None  # initialized after the object creation
        self.options = {focus for focus in planet.availableFoci if focus in supported_foci}

    def __repr__(self):
        return f"PlanetFocusInfo({repr(self.planet)})"

    def __eq__(self, other: PlanetFocusInfo):
        return self.planet.id == other.planet.id

    def __lt__(self, other: PlanetFocusInfo):
        """
        Dummy sorting.
        In this module, we sort Tuples[float, PlanetFocusInfo]. To do so, PlanetFocusInfo must be sortable, but if the
        float is the same, we don't really care which comes first.
        """
        return self.planet.id < other.planet.id

    def set_rated_foci(self):
        self.rated_foci = PlanetFocusRating([(self.possible_output[focus].rating, focus) for focus in supported_foci])

    def rating_below_best(self, focus: str) -> float:
        return self.possible_output[focus].rating - self.rated_foci.best.rating

    def best_over_second(self) -> float:
        return self.rated_foci.best.rating - self.rated_foci.second.rating

    def difference(self, focus1: str, focus2: str) -> float:
        """How much is the output of focus1 better or worse than output of focus2."""
        return self.possible_output[focus1].rating - self.possible_output[focus2].rating

    def evaluate(self, focus: str) -> float:
        """
        Compare focus with best alternative.
        If focus produces best, return a positive rating, giving how much its output is better than second best.
        If another focus produces better, returns a negative rating, giving how much it is worse than best.
        Result is divided by sqrt(population) to prefer switching smaller planets.
        """
        sqrt_population = max(1.0, self.planet.currentMeterValue(fo.meterType.population)) ** 0.5
        if sqrt_population == 0:
            sqrt_population = 0.0001  # prevent Zero division

        if focus == self.rated_foci.best.focus:
            # TBD replace division by using immediate_lost...
            return self.best_over_second() / sqrt_population
        else:
            return self.rating_below_best(focus) / sqrt_population


class PlanetFocusManager:
    """PlanetFocusManager tracks all of the empire's planets, what their current and future focus will be."""

    def __init__(self):
        universe = fo.getUniverse()

        resource_timer.start("getPlanets")
        self.planet_ids = [planet.id for planet in PlanetUtilsAI.get_empire_populated_planets()]

        resource_timer.start("Targets")
        self.planet_info: dict[PlanetId, PlanetFocusInfo] = {
            pid: PlanetFocusInfo(universe.getPlanet(pid)) for pid in self.planet_ids
        }
        self.baked_planet_info = {}
        aistate = get_aistate()
        self.priority_industry = aistate.get_priority(PriorityType.RESOURCE_PRODUCTION)
        self.priority_research = aistate.get_priority(PriorityType.RESOURCE_RESEARCH)
        self.priority_influence = aistate.get_priority(PriorityType.RESOURCE_INFLUENCE)
        self.calculate_planet_infos()

    def set_planetary_foci(self, reporter: Reporter):
        self.set_planet_growth_specials()
        self.set_planet_production_and_research_specials()
        reporter.capture_section_info("Specials")
        # TODO redo. Note that setting protection for stability is handled production_value and set_influence
        # self.set_planet_protection_foci()
        # reporter.capture_section_info("Protection")
        self.set_influence_focus()
        reporter.capture_section_info("Influence")
        self.early_capital_handling()
        self.set_other_foci()
        reporter.capture_section_info("Typical")
        reporter.print_table(self.priority_research / self.priority_industry)

    def bake_future_focus(self, pid, focus, force=False):
        """Set the focus and moves it from the raw list to the baked list of planets.
        pid -- pid
        focus -- future focus to use
        Return success or failure
        """
        pinfo = self.planet_info.get(pid)
        debug(f"baking {focus} on {pinfo.planet}")
        if not pinfo:
            return False
        if (focus == INDUSTRY or focus == RESEARCH) and not force:
            if self.check_avoid_change_due_to_bureaucracy(pinfo, focus):
                return False

        success = bool(
            pinfo.current_focus == focus
            or (focus in pinfo.planet.availableFoci and fo.issueChangeFocusOrder(pid, focus))
        )
        if success:
            if pinfo.current_focus != focus:
                PolicyManager.report_focus_change()
            pinfo.future_focus = focus
            self.baked_planet_info[pid] = self.planet_info.pop(pid)
        debug(f"success={success}")
        return success

    def check_avoid_change_due_to_bureaucracy(self, pinfo: PlanetFocusInfo, focus: str) -> bool:
        """Check whether we better not change a focus to avoid the influence penalty from bureaucracy."""
        if bureaucracy not in fo.getEmpire().adoptedPolicies or focus == pinfo.current_focus:
            return False  # No bureaucracy or no change
        last_turn = fo.currentTurn() - 1
        if pinfo.planet.lastTurnColonized == last_turn or pinfo.planet.lastTurnConquered == last_turn:
            return False  # no penalty for newly settled/conquered planets
        if pinfo.current_output.influence + 0.5 < pinfo.possible_output[pinfo.current_focus].influence:
            # avoid repeated focus changes: When influence production already is below target, do not
            # get it further down by switching again (I've seen one planet at -30...)
            debug(f"Avoid focus change on {pinfo.planet.name} due bureaucracy and influence not maxed.")
            return True
        return False

    def rate_output(self, pp: float, rp: float, ip: float, stability: float, focus: str) -> float:
        """Calculate rating of the given production output."""
        # For some reason even rebelling planets can produce influence. Also, we must avoid a death cycle: when
        # stability goes down due to influence debt, trying to counter instability by setting influence producing
        # planets to protection focus just makes it worse.
        if stability <= 0.0 and focus != INFLUENCE:
            return USELESS_RATING
        return (
            pp * self.priority_industry
            + rp * self.priority_research
            + ip * self.priority_influence
            # add a small amount for stability to make sure output of protection is better than growth
            + stability / 10
        )

    def possible_output_with_focus(self, planet: fo.planet, focus: str, stability: float | None = None) -> Output:
        """
        Estimate Output of planet, which should have been set to focus and meters should be current.
        Special case: if stability is giving, calculate output from current focus, but with adapted stability.
        """
        if planet.focus != focus and stability is None:  # basic output of growth is determined using protection
            return Output(0.0, 0.0, 0.0, 0.0, USELESS_RATING)

        industry_target = planet.currentMeterValue(fo.meterType.targetIndustry)
        research_target = planet.currentMeterValue(fo.meterType.targetResearch)
        influence_target = planet.currentMeterValue(fo.meterType.targetInfluence)
        current_stability = planet.currentMeterValue(fo.meterType.happiness)
        # note that stability 0.0 is a valid value
        target_stability = planet.currentMeterValue(fo.meterType.targetHappiness) if stability is None else stability
        if liberty in fo.getEmpire().adoptedPolicies and focus == RESEARCH:
            research_target += PlanetUtilsAI.adjust_liberty(planet, planet.currentMeterValue(fo.meterType.population))
        if tech_is_complete(AIDependencies.PRO_AUTO_1):
            min_stability = get_named_real("PRO_ADAPTIVE_AUTO_MIN_STABILITY")
            flat = get_named_real("PRO_ADAPTIVE_AUTO_TARGET_INDUSTRY_FLAT")
            industry_target += flat * adjust_direction(min_stability, current_stability, target_stability)
        if tech_is_complete(AIDependencies.LRN_ARTIF_MINDS_1):
            min_stability = get_named_real("LRN_NASCENT_AI_MIN_STABILITY")
            flat = get_named_real("LRN_NASCENT_AI_TARGET_RESEARCH_FLAT")
            research_target += flat * adjust_direction(min_stability, current_stability, target_stability)
        if target_stability < 0:
            industry_target = research_target = 0.0
        # There are a lot more adjustments, some also depend on supply connection. Hopefully one day we get
        # meter_update with adjusted stability...

        return Output(
            industry=industry_target,
            research=research_target,
            influence=influence_target,
            stability=target_stability,
            rating=self.rate_output(industry_target, research_target, influence_target, target_stability, focus),
        )

    def calculate_planet_infos(self):  # noqa complexity
        """
        Calculates for each possible focus an estimated target output of each planet and stores it in planet info.
        """

        def set_focus(pinfo, focus):
            if focus in pinfo.planet.availableFoci and pinfo.planet.focus != focus:
                fo.issueChangeFocusOrder(pinfo.planet.id, focus)

        def collect_estimates(planet_info, focus, callback, second_focus=None):
            for pinfo in planet_info.values():
                set_focus(pinfo, focus)
            universe.updateMeterEstimates(list(planet_info))
            for pinfo in planet_info.values():
                pinfo.possible_output[focus] = callback(pinfo.planet, focus)
            if second_focus:
                for pinfo in planet_info.values():
                    stability = PlanetUtilsAI.stability_with_focus(pinfo.planet, GROWTH)
                    pinfo.possible_output[GROWTH] = callback(pinfo.planet, PROTECTION, stability)

        universe = fo.getUniverse()
        collect_estimates(self.planet_info, INDUSTRY, self.possible_output_with_focus)
        collect_estimates(self.planet_info, RESEARCH, self.possible_output_with_focus)
        collect_estimates(self.planet_info, INFLUENCE, self.possible_output_with_focus)
        # PROTECTION and GROWTH only produce through focus-less bonuses, so they can do with one meter estimation
        collect_estimates(self.planet_info, PROTECTION, self.possible_output_with_focus, GROWTH)
        # revert to current focus and update meters again
        for pinfo in self.planet_info.values():
            set_focus(pinfo, pinfo.current_focus)
        universe.updateMeterEstimates(self.planet_ids)

        for pinfo in self.planet_info.values():
            pinfo.set_rated_foci()
        self.print_pinfo_table()

    def print_pinfo_table(self):
        def output_table_format(o: Output) -> str:
            if o.rating == USELESS_RATING:
                return "---"
            else:
                return f"{o.rating:.1f} {o.industry:.1f} {o.research:.1f} {o.influence:.1f} {o.stability:.1f}"

        debug("Values per focus: rating pp rp ip stability")
        pinfo_table = Table(
            Text("Planet"),
            Text("Best Focus"),
            Text("Industry"),
            Text("Research"),
            Text("Influence"),
            Text("Protection"),
            Text("Growth"),
            table_name="Potential Planetary Output Overview Turn %d" % fo.currentTurn(),
        )
        for pinfo in self.planet_info.values():
            pinfo_table.add_row(
                pinfo.planet,
                _focus_name(pinfo.rated_foci.best.focus),
                output_table_format(pinfo.possible_output[INDUSTRY]),
                output_table_format(pinfo.possible_output[RESEARCH]),
                output_table_format(pinfo.possible_output[INFLUENCE]),
                output_table_format(pinfo.possible_output[PROTECTION]),
                output_table_format(pinfo.possible_output[GROWTH]),
            )
        info(pinfo_table)

    def set_planet_growth_specials(self):  # noqa complexity
        """Consider growth focus for planets with useful growth specials. Remove planets from list of candidates."""
        if not get_aistate().character.may_use_growth_focus():
            return

        # TODO Consider actual resource output of the candidate locations rather than only population
        for special, locations in get_growth_specials().items():
            # Find which metabolism is boosted by this special
            metabolism = AIDependencies.metabolismBoosts.get(special)
            if not metabolism:
                warning("Entry in available growth special not mapped to a metabolism")
                continue

            # Find the total population bonus we could get by using growth focus
            potential_pop_increase = empire_metabolisms.get(metabolism, 0)
            if not potential_pop_increase:
                continue

            debug(
                f"Considering setting growth focus for {special} at locations {locations} for potential population bonus of {potential_pop_increase:.1f}"
            )

            # Find the best suited planet to use growth special on, i.e. the planet where
            # we will lose the least amount of resource generation when using growth focus.
            def _print_evaluation(evaluation):
                """Local helper function printing a formatted evaluation."""
                debug(f"  - {planet} {evaluation}")

            ranked_planets = []
            for pid in locations:
                pinfo = self.planet_info.get(pid)
                planet = fo.getUniverse().getPlanet(pid)
                if not pinfo or GROWTH not in pinfo.planet.availableFoci:
                    _print_evaluation("has no growth focus available.")
                    continue

                # the increased population on the planet using this growth focus
                # is mostly wasted, so ignore it for now.
                pop = planet.currentMeterValue(fo.meterType.population)
                pop_gain = potential_pop_increase - planet.habitableSize
                if pop > pop_gain:
                    _print_evaluation(f"would lose more pop ({pop:.1f}) than gain everywhere else ({pop_gain:.1f}).")
                    continue

                # If we have a computronium special here, then research focus will have higher priority.
                if AIDependencies.COMPUTRONIUM_SPECIAL in planet.specials and RESEARCH in planet.availableFoci:
                    _print_evaluation("has a usable %s" % AIDependencies.COMPUTRONIUM_SPECIAL)
                    continue

                _print_evaluation(
                    f"considered (pop {pop:.1f}, growth gain {pop_gain:.1f}, current focus {pinfo.current_focus})"
                )

                # add a bias to discourage switching out growth focus to avoid focus change penalties
                if pinfo.current_focus == GROWTH:
                    pop -= 4

                ranked_planets.append((pop, pid, planet))

            if not ranked_planets:
                debug("  --> No suitable location found.")
                continue

            # sort possible locations by population in ascending order and set population
            # bonus at the planet with lowest possible population loss.
            ranked_planets.sort()
            for pop, pid, planet in ranked_planets:
                if self.bake_future_focus(pid, GROWTH):
                    debug("  --> Using growth focus at %s" % planet)
                    break
            else:
                warning("  --> Failed to set growth focus at all candidate locations.")

    def set_planet_production_and_research_specials(self):
        """Set production and research specials.
        Sets production/research specials for known (COMPUTRONIUM, HONEYCOMB and CONC_CAMP)
        production/research specials.
        Remove planets from list of candidates using bake_future_focus.
        """
        # loss should be >= 0 with 0 meaning research is the best focus anyway
        for loss, pid in sorted(
            [(self.planet_info[pid].rating_below_best(RESEARCH), pid) for pid in computronium_candidates()]
        ):
            # Computronium moon has a rather high stability requirement, in many cases it may not be worth anything
            if self.evaluate_computronium(pid, loss):
                self.bake_future_focus(pid, RESEARCH, True)
                break

        for loss, pid in sorted(
            [(self.planet_info[pid].rating_below_best(INDUSTRY), pid) for pid in honeycomb_candidates()]
        ):
            # honeycomb has no stability requirement, it should almost always be useful
            # TODO check for supply
            pp_gain = population_with_industry_focus() * get_named_real("HONEYCOMB_TARGET_INDUSTRY_PERPOP")
            if loss < pp_gain * self.priority_industry:
                self.bake_future_focus(pid, INDUSTRY, True)
                break
        # TODO concentration camps, currently the AI does not build them...

    def evaluate_computronium(self, pid: PlanetId, loss: float) -> bool:
        """
        Determine whether switching the given planet with a computronium to research focus is worth the loss.
        """
        researchers = 0.0  # amount of people that would profit from the special
        min_stability = get_named_real("COMPUTRONIUM_MIN_STABILITY")
        per_pop = get_named_real("COMPUTRONIUM_TARGET_RESEARCH_PERPOP")
        # TODO: check for supply as well
        for _, pinfo in self.planet_info.items():
            if pinfo.current_focus == RESEARCH and pinfo.possible_output[RESEARCH].stability >= min_stability:
                researchers += pinfo.planet.currentMeterValue(fo.meterType.population)
        return researchers * per_pop * self.priority_research > loss

    def set_influence_focus(self):
        """Assign planets to influence production."""
        current_ip = fo.getEmpire().resourceAvailable(fo.resourceType.influence)
        focused_planets = sorted(
            [
                (pinfo.evaluate(INFLUENCE), pinfo)
                for pinfo in self.planet_info.values()
                if pinfo.current_focus == INFLUENCE
            ]
        )
        candidate_planets = sorted(
            [
                (pinfo.evaluate(INFLUENCE), pinfo)
                for pinfo in self.planet_info.values()
                if pinfo.current_focus != INFLUENCE and INFLUENCE in pinfo.options
            ]
        )
        target_ip_production = sum(
            pi.possible_output[pi.current_focus].influence
            for pi in chain(self.planet_info.values(), self.baked_planet_info.values())
            if pi.current_focus in pi.possible_output
        )
        ratio = (current_ip + 10 * target_ip_production) / max(1.5, self.priority_influence)  # in turn 1 prio is 0
        debug(
            f"Evaluating influence: IP: {current_ip}, target production: {target_ip_production}, prio:"
            f"{self.priority_influence} => ratio: {ratio}\n"
            f"focused_planets: {focused_planets}\n"
            f"candidates: {candidate_planets}"
        )
        if candidate_planets and (ratio < 1.0 or ratio < 25.0 and candidate_planets[-1][0] > ratio):
            debug("setting influence focus to last candidate")
            self.bake_future_focus(candidate_planets[-1][1].planet.id, INFLUENCE)
        if focused_planets:
            pinfo = focused_planets[0][1]
            below_max = pinfo.possible_output[pinfo.current_focus].influence - pinfo.current_output.influence
            if below_max < 0.5 and (ratio > 30.0 or ratio > 2.0 and focused_planets[0][0] < -100.0 / ratio):
                debug("removing influence focus of first focus planet")
                pinfo = focused_planets.pop(0)
            # all (others) remain influence focussed.
            for _, pinfo in focused_planets:
                self.bake_future_focus(pinfo.planet.id, INFLUENCE)

        ProductionAI.candidate_for_translator = None
        translator_locations = BuildingType.TRANSLATOR.built_or_queued_at()
        for _, pinfo in focused_planets:
            if pinfo.planet.id not in translator_locations:
                ProductionAI.candidate_for_translator = pinfo.planet.id
                break

    def early_capital_handling(self):
        """
        A small, handcrafted start optimisation for bad researchers.
        Even if research is needed most, they do better by quickly finishing the Automatic History Analyzer.
        """
        if fo.currentTurn() < 7:
            capital = fo.getUniverse().getPlanet(fo.getEmpire().capitalID)
            if capital:
                factor = get_species_industry(capital.speciesName) / get_species_research(capital.speciesName)
                pinfo = self.planet_info[capital.id]
                if factor >= 2.0 and pinfo and pinfo.current_focus == INDUSTRY:
                    # factor at start is 2.66 for Egassem and 2 for good industry / bad research.
                    # 1.5 currently does not exist and for normal industry / bad research (1.33) the hack is no good.
                    switch_turn = 7 if factor > 2 else 6
                    if fo.currentTurn() < switch_turn:
                        debug("Special handling: keeping capital at industry.")
                        self.bake_future_focus(capital.id, INDUSTRY)
                    # else: don't do anything special, standard handling will likely switch to RESEARCH

    def immediate_loss_on_switch_to(self, pinfo: PlanetFocusInfo, focus: str) -> float:
        """
        When switching to focus, how much would the current production go down?
        E.g. when switching from research to production, how much does research go down?
        Since it may take several turns until we reach the new targets, this can be used as an indicator how
        'expensive' the switch would be.
        """
        # This is not totally correct, since it won't go down to the minimum immediately. but it should be a good
        # enough estimate to compare how expensive a different switches would be.
        low_rating = self.rate_output(
            pp=min(pinfo.current_output.industry, pinfo.possible_output[focus].industry),
            rp=min(pinfo.current_output.research, pinfo.possible_output[focus].research),
            ip=min(pinfo.current_output.influence, pinfo.possible_output[focus].influence),
            stability=min(pinfo.current_output.stability, pinfo.possible_output[focus].stability),
            focus=focus,
        )
        current_rating = self.rate_output(
            pp=pinfo.current_output.industry,
            rp=pinfo.current_output.research,
            ip=pinfo.current_output.influence,
            stability=pinfo.current_output.stability,
            focus=pinfo.current_focus,
        )

        # note that this may be zero, e.g. for a newly conquered planet or one that currently has negative stability
        return current_rating - low_rating

    def change_candidate(
        self, best_so_far: PlanetFocusInfo | None, candidate: PlanetFocusInfo, from_focus: str, to_focus: str
    ) -> PlanetFocusInfo:
        """
        Returns either candidate or best_so_far (if not None), depending on which one should rather do the
        given focus switch.
        """
        if best_so_far is None:
            return candidate
        best_val = best_so_far.possible_output[to_focus].rating - best_so_far.possible_output[from_focus].rating
        cand_val = candidate.possible_output[to_focus].rating - candidate.possible_output[from_focus].rating
        if best_val > 0:
            # to_focus would be an improvement for best_so_far, chose candidate if it gains even more
            return candidate if cand_val > best_val else best_so_far
        elif cand_val > 0:
            # to_focus would be an improvement for candidate
            return candidate
        else:
            # Neither would be better, prefer planets with a smaller immediate and long term loss
            # Small offsets, since both values may be zero
            best_val = (best_val - 0.1) * (self.immediate_loss_on_switch_to(best_so_far, to_focus) ** 0.5 + 0.1)
            cand_val = (cand_val - 0.1) * (self.immediate_loss_on_switch_to(candidate, to_focus) ** 0.5 + 0.1)
            return candidate if cand_val > best_val else best_so_far

    def get_planned_pp_rp_per_prio(self) -> (float, float, float, float):
        """
        Calculate ratios of planned PP and RP and both values divided by their priority.
        It uses future_focus of all planets, setting this value without baking it can be used to check alternatives.
        """
        planned_pp_target = sum(
            pi.possible_output[pi.future_focus].industry
            for pi in chain(self.baked_planet_info.values(), self.planet_info.values())
            if pi.future_focus in pi.possible_output
        )
        planned_rp_target = sum(
            pi.possible_output[pi.future_focus].research
            for pi in chain(self.baked_planet_info.values(), self.planet_info.values())
            if pi.future_focus in pi.possible_output
        )
        pp_per_priority = planned_pp_target / self.priority_industry
        # When the AI finished all research, research prio becomes 0
        rp_per_priority = planned_rp_target / self.priority_research if self.priority_research else 0.0
        debug(
            f"pp: {planned_pp_target}/{self.priority_industry}={pp_per_priority}, "
            f"rp: {planned_rp_target}/{self.priority_research}={rp_per_priority}"
        )
        return planned_pp_target, planned_rp_target, pp_per_priority, rp_per_priority

    def set_other_foci(self):
        """
        Set remaining planet's foci.
        If best focus is protection, set it, else if only one of industry and research is possible, set it.
        All others are set to industry or research, aiming for a production ratio like the ratio of the priorities.
        If we have one or more candidates currently set to neither industry nor research, we assign these, otherwise
        we may switch one planet from industry to research or vice versa, or let two planets swap their foci.
        """
        debug("set_other_foci...")
        may_switch_to_research = None
        may_switch_to_industry = None
        set_to_neither = []
        num_industry = num_research = 0
        for pid, pinfo in dict(self.planet_info).items():
            # Note that if focus hasn't been baked as INFLUENCE yet, it shouldn't be.
            if pinfo.rated_foci.best.focus in (INDUSTRY, RESEARCH, INFLUENCE) and {INDUSTRY, RESEARCH} <= pinfo.options:
                if pinfo.current_focus == INDUSTRY:
                    num_industry = num_industry + 1
                    may_switch_to_research = self.change_candidate(may_switch_to_research, pinfo, INDUSTRY, RESEARCH)
                elif pinfo.current_focus == RESEARCH:
                    num_research = num_research + 1
                    may_switch_to_industry = self.change_candidate(may_switch_to_industry, pinfo, RESEARCH, INDUSTRY)
                else:
                    set_to_neither.append(pinfo)
            else:
                # Either protection due to stability, or planet does not support both foci
                self.bake_future_focus(pid, pinfo.rated_foci.best.focus)
        if set_to_neither:
            self.chose_industry_or_research(set_to_neither)
        else:
            # Never switch all planets to the lower priority focus, especially not the capital while it is the only one
            if self.priority_industry > self.priority_research and num_industry < 2:
                may_switch_to_research = None
            if self.priority_research > self.priority_industry and num_research < 2:
                may_switch_to_industry = None
            self.chose_switches(may_switch_to_research, may_switch_to_industry)

    def chose_industry_or_research(self, set_to_neither: list[PlanetFocusInfo]) -> None:
        """Assign planets in set_to_neither to industry or research focus."""
        set_to_neither.sort()
        debug(f"chose_industry_or_research set_to_neither={set_to_neither}")
        while set_to_neither:
            # TBD: can we also use balanced_output_value here? Most likely its only one planet anyway...
            _, _, pp_per_priority, rp_per_priority = self.get_planned_pp_rp_per_prio()
            # sorting is best research to best industry
            if pp_per_priority > rp_per_priority:
                self.bake_future_focus(set_to_neither[0].planet.id, RESEARCH)
                set_to_neither.pop(0)
            else:
                self.bake_future_focus(set_to_neither[-1].planet.id, INDUSTRY)
                set_to_neither.pop()

    def chose_switches(self, to_research: PlanetFocusInfo | None, to_industry: PlanetFocusInfo | None) -> None:
        """
        Decide which of the two candidates should switch between industry and research, if any.
        """
        debug(f"chose_switches, candidates: research={to_research or '-'}, industry={to_industry or '-'}")
        tr_gain = to_research.difference(RESEARCH, INDUSTRY) if to_research else USELESS_RATING
        ti_gain = to_industry.difference(INDUSTRY, RESEARCH) if to_industry else USELESS_RATING
        if tr_gain > 0 and ti_gain > 0:
            debug("both are better with the new focus")
            self.bake_future_focus(to_research.planet.id, RESEARCH)
            self.bake_future_focus(to_industry.planet.id, INDUSTRY)
        else:
            pp, rp, pp_per_priority, rp_per_priority = self.get_planned_pp_rp_per_prio()
            current_best = self.balanced_output_value(self.priority_industry, pp, self.priority_research, rp)
            debug(f"current: {current_best:.2f}")
            if to_research:
                current_best = self.check_switch_for_ratio(to_research, RESEARCH, current_best)
            # TBD: can we skip the second call, if the first was an improvement?
            if to_industry:
                self.check_switch_for_ratio(to_industry, INDUSTRY, current_best)

    def check_switch_for_ratio(self, candidate: PlanetFocusInfo, new_focus: str, old_best: float) -> float:
        """
        Check whether a switch would improve the balanced output.
        If so, do the switch and return the new best value, else return old_best.
        """
        candidate.future_focus = new_focus
        pp, rp, _, _ = self.get_planned_pp_rp_per_prio()
        new_value = self.balanced_output_value(self.priority_industry, pp, self.priority_research, rp)
        debug(f"With switch to {new_focus}: {new_value:.2f}")
        if new_value < old_best:
            candidate.future_focus = candidate.current_focus
        self.bake_future_focus(candidate.planet.id, candidate.future_focus)
        return max(old_best, new_value)

    @staticmethod
    def balanced_output_value(prio1: float, production1: float, prio2: float, production2: float) -> float:
        """Evaluate possible production values with a preference for a production ratio equal to the priority ratio."""
        return prio1 * production1 + prio2 * production2 + min(prio1 * production2, prio2 * production1)


class Reporter:
    """Reporter contains some file scope functions to report"""

    def __init__(self, focus_manager):
        self.focus_manager = focus_manager
        self.sections = []
        self.captured_ids = set()

    def capture_section_info(self, title):
        """Grab ids of all the newly baked planets."""
        new_captured_ids = set(self.focus_manager.baked_planet_info)
        new_ids = new_captured_ids - self.captured_ids
        if new_ids:
            self.captured_ids = new_captured_ids
            self.sections.append((title, list(new_ids)))

    table_format = "%34s | %17s | %17s  | %13s | %13s  | %17s |"

    @staticmethod
    def print_resource_ai_header():
        debug("\n============================")
        debug("Collecting info to assess Planet Focus Changes\n")

    @staticmethod
    def print_table_header():
        debug("===================================")
        debug(
            Reporter.table_format,
            "Planet",
            "current RP/PP",
            "old target RP/PP",
            "current Focus",
            "newFocus",
            "new target RP/PP",
        )

    def print_table_footer(self, priority_ratio):
        current_industry_target = 0
        current_research_target = 0
        new_industry_target = 0
        new_research_target = 0
        all_industry_industry_target = 0
        all_industry_research_target = 0
        all_research_industry_target = 0
        all_research_research_target = 0
        total_changed = 0
        for pinfo in self.focus_manager.baked_planet_info.values():
            if pinfo.current_focus != pinfo.future_focus:
                total_changed += 1

            if pinfo.current_focus in pinfo.possible_output:
                old_pp, old_rp, *_ = pinfo.possible_output[pinfo.current_focus]
                current_industry_target += old_pp
                current_research_target += old_rp

            if pinfo.future_focus in pinfo.possible_output:
                future_pp, future_rp, *_ = pinfo.possible_output[pinfo.future_focus]
                new_industry_target += future_pp
                new_research_target += future_rp

            industry_pp, industry_rp, *_ = (
                pinfo.possible_output[INDUSTRY] if INDUSTRY in pinfo.possible_output else (future_pp, future_rp, 0, 0)
            )
            all_industry_industry_target += industry_pp
            all_industry_research_target += industry_rp

            research_pp, research_rp, *_ = (
                pinfo.possible_output[RESEARCH] if RESEARCH in pinfo.possible_output else (future_pp, future_rp, 0, 0)
            )
            all_research_industry_target += research_pp
            all_research_research_target += research_rp

        debug("-----------------------------------")
        debug(
            "Planet Focus Assignments to achieve target RP/PP ratio of %.2f"
            " from current target ratio of %.2f ( %.1f / %.1f )",
            priority_ratio,
            current_research_target / (current_industry_target + 0.0001),
            current_research_target,
            current_industry_target,
        )
        debug(
            "Max Industry assignments would result in target RP/PP ratio of %.2f ( %.1f / %.1f )",
            all_industry_research_target / (all_industry_industry_target + 0.0001),
            all_industry_research_target,
            all_industry_industry_target,
        )
        debug(
            "Max Research assignments would result in target RP/PP ratio of %.2f ( %.1f / %.1f )",
            all_research_research_target / (all_research_industry_target + 0.0001),
            all_research_research_target,
            all_research_industry_target,
        )
        debug("-----------------------------------")
        debug(
            "Final Ratio Target (turn %4d) RP/PP : %.2f ( %.1f / %.1f ) after %d Focus changes",
            fo.currentTurn(),
            new_research_target / (new_industry_target + 0.0001),
            new_research_target,
            new_industry_target,
            total_changed,
        )

    def print_table(self, priority_ratio):
        """Prints a table of all of the captured sections of assignments."""
        self.print_table_header()

        for title, id_set in self.sections:
            debug(
                Reporter.table_format,
                ("---------- " + title + " ------------------------------")[:33],
                "",
                "",
                "",
                "",
                "",
            )
            id_set.sort()  # pay sort cost only when printing
            for pid in id_set:
                pinfo = self.focus_manager.baked_planet_info[pid]
                old_focus = pinfo.current_focus
                new_focus = pinfo.future_focus
                current_pp, curren_rp, *_ = pinfo.current_output
                ot_pp, ot_rp, *_ = pinfo.possible_output.get(old_focus, (0, 0, 0, 0))
                nt_pp, nt_rp, *_ = pinfo.possible_output[new_focus]
                debug(
                    Reporter.table_format,
                    "pID (%3d) %22s" % (pid, pinfo.planet.name[-22:]),
                    f"c: {curren_rp:5.1f} / {current_pp:5.1f}",
                    f"cT: {ot_rp:5.1f} / {ot_pp:5.1f}",
                    "cF: %8s" % _focus_name(old_focus),
                    "nF: %8s" % _focus_name(new_focus),
                    f"cT: {nt_rp:5.1f} / {nt_pp:5.1f}",
                )
        self.print_table_footer(priority_ratio)

    @staticmethod
    def dump_output():
        empire = fo.getEmpire()
        pp, rp = empire.productionPoints, empire.resourceProduction(fo.resourceType.research)
        stats.output(fo.currentTurn(), rp, pp)

    @staticmethod
    def print_resources_priority():
        """Calculate top resource priority."""
        universe = fo.getUniverse()
        empire = fo.getEmpire()
        empire_planet_ids = PlanetUtilsAI.get_owned_planets_by_empire()
        debug("Resource Priorities:")
        resource_priorities = {}
        aistate = get_aistate()
        for priority_type in get_priority_resource_types():
            resource_priorities[priority_type] = aistate.get_priority(priority_type)

        sorted_priorities = sorted(resource_priorities.items(), key=itemgetter(1), reverse=True)
        top_priority = -1
        for evaluation_priority, evaluation_score in sorted_priorities:
            if top_priority < 0:
                top_priority = evaluation_priority
            debug("  %s: %.2f", evaluation_priority, evaluation_score)

        # what is the focus of available resource centers?
        debug("")
        warnings = {}
        foci_table = Table(
            Text("Planet"),
            Text("Size"),
            Text("Type"),
            Text("Focus"),
            Text("Species"),
            Text("Pop"),
            table_name="Planetary Foci Overview Turn %d" % fo.currentTurn(),
        )
        for pid in empire_planet_ids:
            planet = universe.getPlanet(pid)
            population = planet.currentMeterValue(fo.meterType.population)
            max_population = planet.currentMeterValue(fo.meterType.targetPopulation)
            if max_population < 1 and population > 0:
                warnings[planet.name] = (population, max_population)
            foci_table.add_row(
                planet,
                planet.size,
                planet.type,
                "_".join(str(planet.focus).split("_")[1:])[:8],
                planet.speciesName,
                f"{population:.1f}/{max_population:.1f}",
            )
        info(foci_table)
        debug(
            "Empire Totals:\nPopulation: %5d \nProduction: %5d\nResearch: %5d\n",
            empire.population(),
            empire.productionPoints,
            empire.resourceProduction(fo.resourceType.research),
        )
        for name, (cp, mp) in warnings.items():
            warning("Population Warning! -- %s has unsustainable current pop %d -- target %d", name, cp, mp)


def generate_resources_orders():
    """generate resources focus orders"""

    Reporter.print_resource_ai_header()
    resource_timer.start("Focus Infos")
    focus_manager = PlanetFocusManager()
    reporter = Reporter(focus_manager)
    focus_manager.set_planetary_foci(reporter)
    resource_timer.stop_print_and_clear()

    Reporter.dump_output()
    Reporter.print_resources_priority()