""" Collected formulas and helpers extracted from module algorithms. This file consolidates the core mathematical/financial formulas used across: - `Eac.py` (NPV, PMT, discounted projections, EAC calculation) - `insert_actual_data.py` (aggregation formulas, man-hour conversion) - `Prediksi.py` (future value / fv wrappers) ### Prediction Logic Summary | Category | Logic Type | Formula Basis | | :--- | :--- | :--- | | **CM Labor** | **Reliability-Based** | `Failures x 3.0 x 1.0 x ManPowerRate` | | **CM Other** | **Reliability-Based** | `Failures x CostPerFailure (from Production SQL)` | | **PM / OH / PDM** | **Last Scenario** | `Value from Last Actual Year` (Carry Forward) | | **Total Risk Cost** | **Aggregated** | `Sum of above + Asset Criticality Multiplier` | Keep these functions pure and well-documented to make debugging and comparisons easier. """ from typing import Iterable, List, Sequence import math import numpy as np try: import numpy_financial as npf except Exception: # numpy_financial may not be installed in all envs npf = None def npv_from_cash_flows(cash_flows: Sequence[float], rate: float, offset: int = 0) -> float: """Compute discounted sum (NPV) of `cash_flows` at `rate` with optional time offset. Formula used in `Eac.py`: NPV = Σ [Ct / (1 + r)^(offset + t)] where t is 1-based index of flows in `cash_flows`. Args: cash_flows: sequence of cash flows (Ct) for consecutive periods. rate: periodic discount/inflation rate (e.g., 0.05 for 5%). offset: integer number of periods to offset (e.g., last_seq from actuals). Returns: Discounted present value as float. """ if not cash_flows: return 0.0 total = 0.0 for i, value in enumerate(cash_flows): exp = offset + i + 1 total += float(value) / ((1.0 + rate) ** exp) return float(total) def pmt_formula(rate: float, nper: int, pv: float) -> float: """Compute level payment PMT for given rate, periods and present value. Uses the standard annuity payment formula (same as numpy_financial.pmt but returned value uses same sign-convention as mathematical formula): PMT = PV * [r (1+r)^n] / [(1+r)^n - 1] Special-case when rate == 0 (equal division): PMT = PV / nper Note: many code places use sign flips (e.g. `-npf.pmt(...)`) because of numpy_financial's cashflow sign policy. Use whichever sign convention your caller expects. Args: rate: periodic rate (decimal) nper: number of periods (int) pv: present value to amortize Returns: payment per period (float) """ if nper == 0: raise ValueError("nper must be > 0") if abs(rate) < 1e-12: return float(pv) / float(nper) r = float(rate) n = int(nper) numerator = pv * r * ((1 + r) ** n) denominator = ((1 + r) ** n) - 1 return float(numerator / denominator) def pmt(rate: float, nper: int, pv: float) -> float: """Wrapper to compute PMT using `numpy_financial` when available, else fall back to formula. Returns the value that `numpy_financial.pmt` would return (i.e. typically negative for positive PV by that library's sign convention). The caller can negate the result to get a positive payment if desired (as seen in `Eac.py` where they do `-npf.pmt(...)`). """ if npf is not None: return float(npf.pmt(rate, nper, pv)) # numpy_financial returns payments with sign convention; our fallback # returns payment with the same absolute magnitude but as negative (to # match npf behavior for positive PV) so callers that negate will get the # same sign semantics. res = pmt_formula(rate, nper, pv) return -res def fv(rate: float, nper: int, pmt: float, pv: float) -> float: """Future value wrapper to `numpy_financial.fv` when available. Used in `Prediksi.py` as `npf.fv(rate, nper + i, pmt, pv)` to compute forecasting values. """ if npf is not None: return float(npf.fv(rate, nper, pmt, pv)) # fallback: compute iteratively r = float(rate) n = int(nper) fv_val = -pv # apply periods for _ in range(n): fv_val = fv_val * (1 + r) + (-pmt) return float(fv_val) def discounted_projection_sum(values: Sequence[float], inflation_rate: float, last_seq: int = 0) -> float: """Discount a sequence of projected `values` using `inflation_rate` with an initial offset `last_seq`. This mirrors the calculation in `Eac.py` where projected flows are discounted starting at `last_seq + 1`. """ return npv_from_cash_flows(values, inflation_rate, offset=last_seq) def rc_labor_cost(interval: float, labor_time: float, labor_human: float, man_hour_rate: float) -> float: """Compute labor cost for corrective/pm/oh rows. Formula from `Prediksi.py` / `insert_actual_data.py`: rc_xxx_labor_cost = (interval * labor_time * labor_human * man_hour_rate) """ return float(interval) * float(labor_time) * float(labor_human) * float(man_hour_rate) def rc_lost_cost(raw_loss_output_mw: float, raw_loss_output_price: float, cm_interval: float) -> float: """Compute lost production cost as in `Prediksi.py`. Formula used: rc_lost_cost = (raw_loss_output_MW * raw_loss_output_price * raw_cm_interval) * 1000 The multiplication by 1000 appears in source and is preserved here. """ return float(raw_loss_output_mw) * float(raw_loss_output_price) * float(cm_interval) * 1000.0 def rc_total_cost(**components) -> float: """Sum all provided components into a total RC cost. The `Eac.py` / `Prediksi.py` code builds a `rc_total_cost` as the sum of many parts; this helper simply sums values passed as keyword args (coalescing None to 0.0) to make testing/comparison easier. """ total = 0.0 for k, v in components.items(): try: total += 0.0 if v is None else float(v) except Exception: # ignore non-numeric components continue return float(total) def seconds_to_hours(seconds: float, ndigits: int = 2) -> float: """Convert seconds to hours and round to `ndigits` decimals. This mirrors usage like: ROUND(SUM(EXTRACT(EPOCH FROM (actfinish - actstart)) / 3600), 2) in SQL queries seen in `insert_actual_data.py`. """ hours = float(seconds) / 3600.0 return float(round(hours, ndigits)) def man_count_from_labtrans(count: int) -> int: """Return man_count as per SQL CASE: if count == 0 -> 3 else count. Implemented to match the SQL "CASE WHEN COUNT(b.laborcode) = 0 THEN 3 ELSE COUNT(b.laborcode)". """ try: c = int(count) except Exception: c = 0 return 3 if c == 0 else c __all__ = [ "npv_from_cash_flows", "pmt_formula", "pmt", "fv", "discounted_projection_sum", "rc_labor_cost", "rc_lost_cost", "rc_total_cost", "seconds_to_hours", "man_count_from_labtrans", ]