""" shell_rule_test.py ================== Empirische toets van de Shell-regel: Shell 40 (alle kleuren actief) → 25 werkende HCP → manche Shell 30 (één uitgeschakelde zijkleur) → 19 werkende HCP → manche Shell 20 (twee uitgeschakelde zijkleuren) → 13 werkende HCP → manche Voor elke deal wordt zowel volgens 'streng' (singleton/renonce) als 'soepel' (doubleton/singleton/renonce) gecategoriseerd, zodat beide definities direct vergelijkbaar zijn. Werkende HCP = HCP in actieve kleuren + 4 per aas in uitgeschakelde kleuren. Vereist: endplay (pip install endplay) Output: aggregatietabellen per Shell-grootte, voor beide definities. """ from endplay.dealer import generate_deal from endplay.dds import calc_dd_table from endplay.types import Player, Denom import multiprocessing as mp from collections import Counter import random import statistics import time import sys # Robuuste HCP-functie (zoals in vorig script) try: from endplay.evaluate import hcp as _endplay_hcp def calc_hcp(hand): return _endplay_hcp(hand) except (ImportError, AttributeError): def calc_hcp(hand): s = str(hand) return s.count('A') * 4 + s.count('K') * 3 + s.count('Q') * 2 + s.count('J') * 1 def hcp_in_suit(hand, denom): """HCP-bezit in één specifieke kleur van één hand.""" s = str(hand[denom]) return s.count('A') * 4 + s.count('K') * 3 + s.count('Q') * 2 + s.count('J') * 1 def has_ace(hand, denom): return 'A' in str(hand[denom]) def calc_working_hcp(north, south, suits, uitgeschakeld_set): """Bereken werkende HCP gegeven welke kleuren uitgeschakeld zijn.""" working = 0 for d in suits: if d not in uitgeschakeld_set: # Actieve kleur: alle HCP tellen working += hcp_in_suit(north, d) + hcp_in_suit(south, d) else: # Uitgeschakelde kleur: alleen azen if has_ace(north, d): working += 4 if has_ace(south, d): working += 4 return working def analyze_one_deal(seed, min_ns_hcp=20): """ Retourneer (fitlengte, ns_hcp, troef_tricks, streng_shell, streng_working, soepel_shell, soepel_working) of None als deal het filter niet passeert. """ random.seed(seed) deal = generate_deal() north = deal.north south = deal.south ns_hcp_total = calc_hcp(north) + calc_hcp(south) if ns_hcp_total < min_ns_hcp: return None suits = [Denom.spades, Denom.hearts, Denom.diamonds, Denom.clubs] fits = {} for d in suits: total = len(north[d]) + len(south[d]) if total >= 8: fits[d] = total if not fits: return None max_len = max(fits.values()) longest = [d for d, l in fits.items() if l == max_len] trump_suit = random.choice(longest) side_suits = [d for d in suits if d != trump_suit] # STRENG: kleur uitgeschakeld als minstens één hand singleton of renonce streng_uitg = set() for d in side_suits: if len(north[d]) <= 1 or len(south[d]) <= 1: streng_uitg.add(d) # SOEPEL: kleur uitgeschakeld als minstens één hand doubleton, singleton of renonce soepel_uitg = set() for d in side_suits: if len(north[d]) <= 2 or len(south[d]) <= 2: soepel_uitg.add(d) streng_shell = 40 - 10 * len(streng_uitg) streng_working = calc_working_hcp(north, south, suits, streng_uitg) soepel_shell = 40 - 10 * len(soepel_uitg) soepel_working = calc_working_hcp(north, south, suits, soepel_uitg) # DD-tricks in troefkleur, gemiddeld over N en S als leider dd_table = calc_dd_table(deal) troef_n = dd_table[trump_suit, Player.north] troef_s = dd_table[trump_suit, Player.south] troef_tricks = (troef_n + troef_s) / 2.0 return (max_len, ns_hcp_total, troef_tricks, streng_shell, streng_working, soepel_shell, soepel_working) def worker_chunk(args): chunk_seeds, min_ns_hcp = args results = [] for seed in chunk_seeds: r = analyze_one_deal(seed, min_ns_hcp=min_ns_hcp) if r is not None: results.append(r) return results def aggregate_by_shell(results, shell_idx, working_idx, label, min_n=50): """Aggregeer per Shell-grootte; binnen elke groep per werkende-HCP-waarde.""" print(f"\n{'='*68}") print(f"AGGREGATIE: {label}") print(f"{'='*68}") by_shell = {} for r in results: shell = r[shell_idx] working = r[working_idx] tricks = r[2] by_shell.setdefault(shell, {}).setdefault(working, []).append(tricks) for shell in sorted(by_shell.keys(), reverse=True): total_in_shell = sum(len(v) for v in by_shell[shell].values()) print(f"\n--- Shell-grootte {shell} (n={total_in_shell}) ---") if total_in_shell < 100: print(" (te weinig data voor analyse)") continue print(f" {'Werk.HCP':>9} {'n':>6} {'gem.slagen':>12} {'sd':>5} marker") # Combineer naburige HCP-waarden als één bin te klein is rows = by_shell[shell] for hcp in sorted(rows.keys()): tricks_list = rows[hcp] n = len(tricks_list) if n < min_n: continue mean = statistics.mean(tricks_list) sd = statistics.stdev(tricks_list) if n > 1 else 0 # Markeer waar het gemiddelde rond 10 slagen ligt (manche-drempel) if 9.7 <= mean <= 10.3: marker = "<-- ca. 10 slagen" elif 11.7 <= mean <= 12.3: marker = "<-- ca. 12 slagen (slem)" else: marker = "" print(f" {hcp:>9} {n:>6} {mean:>12.2f} {sd:>5.2f} {marker}") def show_shell_distribution(results): print(f"\n{'='*68}") print("VERDELING VAN DEALS OVER SHELL-GROOTTES") print(f"{'='*68}") streng_dist = Counter(r[3] for r in results) soepel_dist = Counter(r[5] for r in results) n_total = len(results) print(f" {'Shell':>10} {'Streng':>10} ({'%':>5}) {'Soepel':>10} ({'%':>5})") for shell in [40, 30, 20, 10]: s = streng_dist.get(shell, 0) sp = soepel_dist.get(shell, 0) print(f" {shell:>10} {s:>10} ({100*s/n_total:>5.1f})" f" {sp:>10} ({100*sp/n_total:>5.1f})") def main(n_deals=50000, n_workers=8, min_ns_hcp=20): print(f"Genereer {n_deals} deals met {n_workers} workers...") print(f"Filter: NZ minstens {min_ns_hcp} HCP, minstens één 8+-fit\n") seeds = list(range(n_deals)) chunk_size = max(1, n_deals // (n_workers * 4)) chunks = [(seeds[i:i + chunk_size], min_ns_hcp) for i in range(0, len(seeds), chunk_size)] t0 = time.time() with mp.Pool(n_workers) as pool: all_results = pool.map(worker_chunk, chunks) results = [r for chunk_results in all_results for r in chunk_results] elapsed = time.time() - t0 print(f"Klaar in {elapsed:.1f}s. {len(results)} deals voldoen aan filter " f"({100*len(results)/n_deals:.1f}% van totaal).") show_shell_distribution(results) aggregate_by_shell(results, shell_idx=3, working_idx=4, label="STRENG (kleur uitgeschakeld bij singleton/renonce)") aggregate_by_shell(results, shell_idx=5, working_idx=6, label="SOEPEL (kleur uitgeschakeld bij doubleton/singleton/renonce)") print(f"\n{'='*68}") print("INTERPRETATIE") print(f"{'='*68}") print("De Shell-regel voorspelt:") print(" Shell 40: ~25 werkende HCP voor 10 slagen (manche)") print(" Shell 30: ~19 werkende HCP voor 10 slagen") print(" Shell 20: ~13 werkende HCP voor 10 slagen") print(" Shell 10: ~7 werkende HCP voor 10 slagen (extrapolatie)") print("\nKijk per Shell-grootte naar de regel met 'ca. 10 slagen'.") print("De werkende-HCP-waarde daar is de empirische manche-drempel.") if __name__ == '__main__': N_DEALS = 50000 N_WORKERS = 8 MIN_NS_HCP = 20 if len(sys.argv) > 1: N_DEALS = int(sys.argv[1]) if len(sys.argv) > 2: N_WORKERS = int(sys.argv[2]) if len(sys.argv) > 3: MIN_NS_HCP = int(sys.argv[3]) main(n_deals=N_DEALS, n_workers=N_WORKERS, min_ns_hcp=MIN_NS_HCP)