def urja_to_gridium( self, urja_data: UrjanetData) -> GridiumBillingPeriodCollection: """Transform urjanet data into Gridium billing periods""" # Process the account objects in reverse order by statement date. The main motivation here is corrections; # we want to process the most recent billing date first, and ignore earlier data for those same dates. ordered_accounts = sorted(urja_data.accounts, key=lambda x: x.StatementDate, reverse=True) # First, we rough out the billing period dates, by iterating through the ordered accounts and pulling out # usage periods bill_history = DateIntervalTree() for account in ordered_accounts: usage_periods = self.get_account_billing_periods(account) for ival in sorted(usage_periods.intervals(), reverse=True): if bill_history.overlaps(ival.begin, ival.end): log.debug( "Skipping overlapping usage period: account_pk={}, start={}, end={}" .format(account.PK, ival.begin, ival.end)) else: log.debug("Adding usage period: %s - %s", ival.begin, ival.end) bill_history.add(ival.begin, ival.end, self.billing_period_class(account)) # fix periods where start/end are the same bill_history = self.shift_endpoints(bill_history) # Next, we go through the accounts again and insert relevant charge/usage information into the computed # billing periods for account in ordered_accounts: self.merge_statement_data(bill_history, account) # Convert the billing periods into the expected "gridium" format gridium_periods = [] for ival in sorted(bill_history.intervals()): period_data = ival.data gridium_periods.append( GridiumBillingPeriod( start=ival.begin, end=ival.end, statement=period_data.statement(), total_charge=period_data.get_total_charge(), peak_demand=period_data.get_peak_demand(), total_usage=period_data.get_total_usage(), source_urls=period_data.get_source_urls(), line_items=(period_data.utility_charges + period_data.third_party_charges), tariff=period_data.tariff(), service_id=period_data.get_service_id(), utility=period_data.get_utility(), utility_account_id=period_data.get_utility_account_id(), )) return GridiumBillingPeriodCollection(periods=gridium_periods)
def urja_to_gridium( self, urja_data: UrjanetData) -> GridiumBillingPeriodCollection: """Transform Urjanet data for water bills into Gridium billing periods""" filtered_accounts = self.filtered_accounts(urja_data) ordered_accounts = self.ordered_accounts(filtered_accounts) # For each account, create a billing period, taking care to detect overlaps (e.g. in the case that a # correction bill in issued) bill_history = DateIntervalTree() for account in ordered_accounts: period_start, period_end = self.get_account_period(account) if bill_history.overlaps(period_start, period_end): log.debug( "Skipping overlapping billing period: account_pk={}, start={}, end={}" .format(account.PK, period_start, period_end)) else: log.debug( "Adding billing period: account_pk={}, start={}, end={}". format(account.PK, period_start, period_end)) bill_history.add(period_start, period_end, self.billing_period(account)) # Adjust date endpoints to avoid 1-day overlaps bill_history = DateIntervalTree.shift_endpoints(bill_history) # Log the billing periods we determined log_generic_billing_periods(bill_history) # Compute the final set of gridium billing periods gridium_periods = [] for ival in sorted(bill_history.intervals()): period_data = ival.data gridium_periods.append( GridiumBillingPeriod( start=ival.begin, end=ival.end, statement=period_data.statement(), total_charge=period_data.get_total_charge(), peak_demand=None, # No peak demand for water total_usage=period_data.get_total_usage(), source_urls=period_data.get_source_urls(), line_items=list(period_data.iter_charges()), tariff=period_data.tariff(), )) return GridiumBillingPeriodCollection(periods=gridium_periods)
def log_generic_billing_periods(bill_history: DateIntervalTree) -> None: """Helper function for logging data in an interval tree holding bill data""" log.debug("Billing periods") for ival in sorted(bill_history.intervals()): period_data = ival.data log.debug( "\t{} to {} ({} days)".format( ival.begin, ival.end, (ival.end - ival.begin).days ) ) log.debug("\t\tUtility Charges:") for chg in period_data.iter_charges(): log.debug( "\t\t\tAmt=${0}\tName='{1}'\tPK={2}\t{3}\t{4}".format( chg.ChargeAmount, chg.ChargeActualName, chg.PK, chg.IntervalStart, chg.IntervalEnd, ) ) log.debug("\t\tTotal Charge: ${}".format(period_data.get_total_charge())) log.debug("\t\tUsages:") for usg in period_data.iter_unique_usages(): log.debug( "\t\t\tAmt={0}{1}\tComponent={2}\tPK={3}\t{4}\t{5}".format( usg.UsageAmount, usg.EnergyUnit, usg.RateComponent, usg.PK, usg.IntervalStart, usg.IntervalEnd, ) ) log.debug("\t\tTotal Usage: {}".format(period_data.get_total_usage())) log.debug("\t\tStatements:") log.debug( "\t\t\t{0}\tPK={1}".format( period_data.account.SourceLink, period_data.account.PK ) )
def update_date_range_from_charges(account: Account) -> Account: """Fix date range for bills that cross the winter/summer boundary. When a bill crosses the winter/summary boundary (9/1), charges are reported in two batches: the summer portion and the winter portion. The account and meter IntervalStart and IntervalEnd may encompass just one of these date ranges; fix if needed. Summer/winter example: meter oid 1707479190338 +-----------+---------------+-------------+----------+ | accountPK | IntervalStart | IntervalEnd | meterPK | +-----------+---------------+-------------+----------+ | 5494320 | 2015-09-01 | 2015-09-11 | 19729463 | | 5498442 | 2015-09-11 | 2015-10-09 | 19740313 | PDF (https://sources.o2.urjanet.net/sourcewithhttpbasicauth?id=1e55ab22-7795-d6a4 -a229-22000b849d83) has two two sections for charges: - 8/13/15 - 8/31/15 (summer) - 9/1/15 - 9/11/15 (winter) Meter record has IntervalStart = 9/1/15 and IntervalEnd = 9/1/15 The Charge records have IntervalStart and IntervalEnd for both date ranges. """ account_range = DateIntervalTree() log.debug( "account interval range: %s to %s", account.IntervalStart, account.IntervalEnd, ) if account.IntervalEnd > account.IntervalStart: account_range.add(account.IntervalStart, account.IntervalEnd) for meter in account.meters: meter_range = DateIntervalTree() log.debug("meter interval range: %s to %s", meter.IntervalStart, meter.IntervalEnd) if meter.IntervalEnd > meter.IntervalStart: meter_range.add(meter.IntervalStart, meter.IntervalEnd) charge_range = DateIntervalTree() for charge in meter.charges: # don't create single day periods if (charge.IntervalEnd - charge.IntervalStart).days <= 1: continue log.debug( "charge %s interval range: %s to %s", charge.PK, charge.IntervalStart, charge.IntervalEnd, ) charge_range.add(charge.IntervalStart, charge.IntervalEnd) if len(charge_range.intervals()) > 1: min_charge_dt = min( [r.begin for r in charge_range.intervals()]) max_charge_dt = max([r.end for r in charge_range.intervals()]) log.debug( "Updating meter date range from charges to %s - %s (was %s - %s)", min(meter.IntervalStart, min_charge_dt), max(account.IntervalEnd, max_charge_dt), meter.IntervalStart, meter.IntervalEnd, ) meter.IntervalStart = min(meter.IntervalStart, min_charge_dt) meter.IntervalEnd = max(meter.IntervalEnd, max_charge_dt) log.debug( "Updating account date range from charges to %s - %s (was %s - %s)", min(account.IntervalStart, min_charge_dt), max(account.IntervalEnd, max_charge_dt), account.IntervalStart, account.IntervalEnd, ) account.IntervalStart = min(account.IntervalStart, min_charge_dt) account.IntervalEnd = max(account.IntervalEnd, max_charge_dt) return account