"""Synthetic time, space and spectrum accounting; Python >= 3.10. Schedule rows: (start hour, end hour, interval-average PPFD in umol/m2/s). Fixed 24-hour coverage, in supplied order. No crop-response model. """ import argparse import json import math from pathlib import Path import unittest def number(value): if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value) or value < 0: raise ValueError('Expected a finite nonnegative number') return float(value) def schedule(rows): if not rows: raise ValueError('Missing day') end_previous = dose = light = peak = 0.0 for row in rows: if len(row) != 3: raise ValueError('Expected start, end, PPFD') start, end, ppfd = map(number, row) if start != end_previous or not start < end <= 24: raise ValueError('Intervals must be ordered, contiguous, and inside 0..24 h') hours = end - start dose += ppfd * hours * 3600 / 1e6 light += hours if ppfd > 0 else 0 peak = max(peak, ppfd) end_previous = end if end_previous != 24: raise ValueError('A complete 24-hour day is required') return {'dli_mol_m2_day': dose, 'light_hours': light, 'dark_hours': 24.0-light, 'peak_ppfd': peak} def spatial(ppfd, areas, hours): if not ppfd or len(ppfd) != len(areas): raise ValueError('Nonempty paired values and areas required') values = [number(v) for v in ppfd] weights = [number(v) for v in areas] hours = number(hours) if min(weights) <= 0 or hours > 24: raise ValueError('Positive cell areas and 0..24 lighting hours required') mean = sum(v*a for v, a in zip(values, weights)) / sum(weights) return {'mean_ppfd': mean, 'min_ppfd': min(values), 'min_over_mean': min(values)/mean if mean else None, 'cell_dli': [v*hours*3600/1e6 for v in values]} def spectrum(bands): if len(bands) != 3: raise ValueError('Expected three nonoverlapping 400..700 nm bands') values = list(map(number, bands)) total = sum(values) return {'total_ppfd': total, 'fraction_400_500': values[0]/total if total else None} def verify(actual, expected): if isinstance(actual, dict): if not isinstance(expected, dict) or actual.keys() != expected.keys(): raise ValueError('Result keys differ') for key in actual: verify(actual[key], expected[key]) elif isinstance(actual, list): if not isinstance(expected, list) or len(actual) != len(expected): raise ValueError('Result lengths differ') for a, e in zip(actual, expected): verify(a, e) elif isinstance(actual, (float, int)): if isinstance(expected, bool) or not isinstance(expected, (float, int)) or not math.isfinite(expected) or not math.isfinite(actual) or not math.isclose(actual, expected, rel_tol=1e-10, abs_tol=1e-10): raise ValueError('Numerical result differs') elif actual != expected: raise ValueError('Result differs') class Checks(unittest.TestCase): def test_equal_dli(self): a = schedule([(0, 16, 200), (16, 24, 0)]) b = schedule([(0, 8, 400), (8, 24, 0)]) self.assertAlmostEqual(a['dli_mol_m2_day'], 11.52) self.assertAlmostEqual(a['dli_mol_m2_day'], b['dli_mol_m2_day']) self.assertEqual(a['dark_hours'], 8) self.assertEqual(b['dark_hours'], 16) def test_interval_subdivision(self): self.assertEqual(schedule([(0, 24, 200)]), schedule([(0, 6, 200), (6, 24, 200)])) def test_dark_day(self): r = schedule([(0, 24, 0)]) self.assertEqual(r['dli_mol_m2_day'], 0) self.assertEqual(r['dark_hours'], 24) def test_known_cell_doses(self): r = spatial([100, 200, 200, 300], [1]*4, 16) self.assertAlmostEqual(r['mean_ppfd'], 200) self.assertAlmostEqual(r['min_over_mean'], 0.5) for a, e in zip(r['cell_dli'], [5.76, 11.52, 11.52, 17.28]): self.assertAlmostEqual(a, e) def test_area_weighting(self): self.assertAlmostEqual(spatial([100, 300], [1, 3], 16)['mean_ppfd'], 250) def test_zero_ratios(self): self.assertIsNone(spatial([0, 0], [1, 1], 16)['min_over_mean']) self.assertIsNone(spectrum([0, 0, 0])['fraction_400_500']) def test_spectral_sum_is_not_composition(self): a, b = spectrum([40, 60, 100]), spectrum([20, 40, 140]) self.assertEqual(a['total_ppfd'], b['total_ppfd']) self.assertEqual(a['fraction_400_500'], 0.2) self.assertEqual(b['fraction_400_500'], 0.1) def test_missing_overlap_order(self): for rows in ([], [(0, 23, 200)], [(1, 24, 200)], [(0, 8, 200), (9, 24, 0)], [(0, 8, 200), (7, 24, 0)], [(8, 24, 0), (0, 8, 200)]): with self.assertRaises(ValueError): schedule(rows) def test_invalid_values(self): for bad in (-1, float('nan'), float('inf'), True, '200'): with self.assertRaises(ValueError): schedule([(0, 24, bad)]) with self.assertRaises(ValueError): spatial([bad], [1], 16) for areas in ([0], [], [-1]): with self.assertRaises(ValueError): spatial([200], areas, 16) def test_corrupted_expectations(self): for e in ({}, {'x': float('nan')}, {'x': 2}, {'x': True}): with self.assertRaises(ValueError): verify({'x': 1.0}, e) def examples(): return { 'scope': 'synthetic light accounting; no crop or energy prediction', 'A': schedule([(0, 6, 0), (6, 22, 200), (22, 24, 0)]), 'B': schedule([(0, 8, 0), (8, 16, 400), (16, 24, 0)]), 'U': spatial([200, 200, 200, 200], [1, 1, 1, 1], 16), 'V': spatial([100, 200, 200, 300], [1, 1, 1, 1], 16), 'S1': spectrum([40, 60, 100]), 'S2': spectrum([20, 40, 140]), } if __name__ == '__main__': parser = argparse.ArgumentParser(description=__doc__) group = parser.add_mutually_exclusive_group() group.add_argument('--self-test', action='store_true') group.add_argument('--verify', type=Path) args = parser.parse_args() if args.self_test: result = unittest.TextTestRunner(verbosity=2).run(unittest.defaultTestLoader.loadTestsFromTestCase(Checks)) raise SystemExit(not result.wasSuccessful()) if args.verify: verify(examples(), json.loads(args.verify.read_text())) print('All expected results verified') else: print(json.dumps(examples(), indent=2, allow_nan=False))