2

I've been working on a workforce scheduling problem using linear programming in Python with the PuLP library. I'm trying to assign employees to different brands, ensuring certain conditions are met. Here's a detailed explanation of the problem and the code I've implemented:

Problem Statement:

  1. Assign a fixed number of employees to different brands.
  2. Ensure that an employee works on only one brand per day.
  3. An employee can work a maximum of 9 hours and a minimum of 5 hours consecutively.
  4. Need to fulfill staffing requirements for each brand.

Code:

# Imports
import pulp
from pulp import LpMinimize, LpProblem, LpVariable, lpSum, LpMaximize
from pulp.apis import PULP_CBC_CMD
import pandas as pd


# Variable definition
# Define data
number_of_weeks = 1
days_per_week = 7
total_days = number_of_weeks * days_per_week
days = list(range(1, total_days + 1))

shop_open = 10  # 10 AM
shop_close = 20  # 8 PM
hours = list(range(shop_open, shop_close))
max_working_hours = 9
min_working_hours = 5

number_of_employee = 20

# 5-day or 4-day contract type (50% each for now)
five_days_count = int(number_of_employee * 0.5)

employees = list(range(1, number_of_employee + 1))
employee_type = ["5_days" if e <= five_days_count else "4_days" for e in employees]

categories = ["PHONE", "TV"]
brands = {
    "PHONE": ["PHONE1", "PHONE2"],
    "TV": ["TV1", "TV2", "TV3"]
}
list_of_brands = []
for key, values in brands.items():
    list_of_brands.extend(values)

staffing_requirements = {
    "PHONE": {"PHONE1": 1, "PHONE2": 1},
    "TV": {"TV1": 1, "TV2": 1, "TV3": 1}
}

flat_staffing_requirements = {brand: req for category in staffing_requirements for brand, req in staffing_requirements[category].items()}


# Initialize the problem
model = LpProblem(name="workforce-planning", sense=LpMinimize)

# Variables
# Work on a day for a brand on a specific hour
x = LpVariable.dicts("x", [(e, d, h, b) for e in employees for d in days for h in hours for b in list_of_brands], cat="Binary")
# Work on a day for a brand
y = LpVariable.dicts("y", [(e, d, b) for e in employees for d in days for b in list_of_brands], cat="Binary")
# Gap
g = LpVariable.dicts("g", [(e, d, h, b) for e in employees for d in days for h in hours for b in list_of_brands], cat="Binary")

# Objective: Minimize employees working per day
model += lpSum(x[e, d, h, b] for e in employees for d in days for h in hours for b in list_of_brands)

# An employee can only work on one brand of a category per day
for e in employees:
    for d in days:
            # For each employee and day, the sum of all brands they work for should be at most 1. 
            # This ensures that an employee works for only one brand a day.
            model += lpSum(y[e, d, b] for b in list_of_brands) <= 1

# Connect x to y
for e in employees:
    for d in days:
        for b in list_of_brands:
            # If y is 1, then the employee can work for that brand up to max_working_hours.
            # If y is 0, then they don't work for that brand.
            model += lpSum(x[e, d, h, b] for h in hours) <= max_working_hours * y[e, d, b]
            
            # If the employee works any hour for that brand, then y must be 1.
            # This ensures that if an employee is working, they are assigned to a brand.
            model += lpSum(x[e, d, h, b] for h in hours) >= y[e, d, b]


for e in employees:
    for d in days:
        for b in list_of_brands:
            for i, h in enumerate(hours):
                # 1. If a sequence begins at hour h, the following hours within max_working_hours   (or until the end of the day) 
                # must also be worked hours. This ensures consecutive hours.
                max_hrs = min(len(hours) - i, max_working_hours)
                for j in range(max_hrs):
                    if i+j < len(hours):
                        # This constraint enforces that if g is 1 (sequence starts at hour h), 
                        # the following hours are set to work hours.
                        model += x[e, d, hours[i+j], b] >= g[e, d, h, b]
                
                # 2. If an employee works at a certain hour h, then a work sequence must have started 
                # at that hour or some previous hour within the range of max_working_hours. This 
                # constraint ties together the concept that a worked hour is part of some sequence.
                # The sum checks for all possible starting hours of the sequence leading up to hour h.
                model += x[e, d, h, b] <= lpSum(g[e, d, hours[i-k], b] for k in range(min(i+1, max_working_hours)))

# Staffing requirements for each hour
for d in days:
    for h in hours:
        for b in list_of_brands:
            model += lpSum(x[e, d, h, b] for e in employees) == flat_staffing_requirements[b]


# Solve the model
model.solve(PULP_CBC_CMD(msg=1))

# Create an empty DataFrame with the desired structure
columns = []
for d in days:
    columns.extend([f"Day_{d}_Start", f"Day_{d}_End", f"Day_{d}_Brand"])
df = pd.DataFrame(index=employees, columns=columns)

# Populate the DataFrame
for e in employees:
    for d in days:
        assigned_brand = None
        start_time = None  # Resetting for each employee and day combination
        end_time = None  # Resetting for each employee and day combination
        
        for h in hours:
            for b in list_of_brands:
                if x[e, d, h, b].varValue == 1:
                    assigned_brand = b
                    if start_time is None:  # First assignment for this day for this employee
                        start_time = h
                        end_time = h + 1
                    else:
                        start_time = min(start_time, h)
                        end_time = max(end_time, h + 1)  # h+1 because if they start at a particular hour, they work that entire hour


        # Use 'REST' if no assignment is detected
        df.loc[e, f"Day_{d}_Start"] = start_time if assigned_brand else "REST"
        df.loc[e, f"Day_{d}_End"] = end_time if assigned_brand else "REST"
        df.loc[e, f"Day_{d}_Brand"] = assigned_brand if assigned_brand else "REST"

df.head(30)

The provided code works and provides an optimal solution when I don't consider the 5 hours minimum working constraint. The execution time is 17s, which seems a bit long for the dataset size. Here's a sample output:

Day_1_Start Day_1_End Day_1_Brand
19 20 TV1
10 19 TV1
... ... ...

However, when I add the constraint to ensure a minimum of 5 hours of work, the problem becomes infeasible. Here are the changes I made to the original code :

# Connect x to y
for e in employees:
    for d in days:
        for b in list_of_brands:
            # If y is 1, then the employee can work for that brand up to max_working_hours.
            # If y is 0, then they don't work for that brand.
            model += lpSum(x[e, d, h, b] for h in hours) <= max_working_hours * y[e, d, b]
            model += lpSum(x[e, d, h, b] for h in hours) >= min_working_hours * y[e, d, b]
            
            # If the employee works any hour for that brand, then y must be 1.
            # This ensures that if an employee is working, they are assigned to a brand.
            model += lpSum(x[e, d, h, b] for h in hours) >= y[e, d, b]

for e in employees:
    for d in days:
        for b in list_of_brands:
            for i, h in enumerate(hours[:-4]):  # Exclude the last 4 hours to prevent a sequence that doesn't respect the 5-hour minimum
                # If a sequence starts at hour h, then the next 4 hours should also be worked hours to ensure 5 consecutive hours.
                for j in range(5):  # 5 hours including the current hour
                    model += x[e, d, hours[i+j], b] >= g[e, d, h, b]
                
                # 2. If an employee works at a certain hour h, then a work sequence must have started 
                # at that hour or some previous hour within the range of max_working_hours. This 
                # constraint ties together the concept that a worked hour is part of some sequence.
                # The sum checks for all possible starting hours of the sequence leading up to hour h.
                model += x[e, d, h, b] <= lpSum(g[e, d, hours[i-k], b] for k in range(min(i+1, max_working_hours)))

When adding the 5-hour minimum working constraint, the problem turns infeasible, and I'm unsure why. I'd appreciate any insights or corrections on the approach or code. Thank you!

10
  • To be clear, Assign a fixed number of employees to different brands. seems like from a fixed employee pool, assign a minimum number of employees to different brands. Commented Aug 8, 2023 at 14:11
  • Hello thanks for your answer. Can you develop a bit more please ? The number of employee is actually fixed by the input params. We have 20 employees divided in equal pool of 4 days workers and 5 days workers (not actually implemented yet). Commented Aug 8, 2023 at 14:26
  • That's what I figured; you just made it sound like brand assignment is fixed and that probably isn't the case. Commented Aug 8, 2023 at 14:28
  • Yes you're right, we want from the params dict to assign at minimum 1 employee in each brand (from the exemple, but we can make it vary) Commented Aug 8, 2023 at 14:32
  • 2
    A couple observations... Right now your objective is minimizing hours worked, regardless of source of hours, so by inspection, the optimal solution is 5 products * 7 days * 10hrs. Also, the "product" piece isn't really relevant as the products all have similar requirements. The problem I think reduces to "schedule 5 employees every hour" ... the product assignment can be arbitrary, it appears, no? Commented Aug 8, 2023 at 14:32

2 Answers 2

3

This works pretty well. I didn't do much post-processing, so you should QA the results, but they pass the 'giggle test'.

I'm not a fan of mixing pandas into these problems (a) because I'm a pandas intermediate-level user, and I find the syntax distracting and (b) I think it is easier to use basic python structures to hack down the scope of the problem.

In big MILP with a lot of binary decision variables, it is usually quite helpful to hack down the domain(s) as much as possible. I used your basic structure, but carved out "illegal" assignments such as the wrong brand-item pairing with an employee. That takes the domain of that variable down by a factor of 5 (the count of the items). Similarly for shift starts. There are only a couple slots in the day where you can commence a 5 hour minimum shift, so I chopped to that.

Also, for variables, I collapsed it to 2 key variables that are linked with a continuity constraint. You just need to know when an employee starts a shift, and from there, you can calculate "coverage" for an item in any hour if they covered if they either (a) started shift or (b) covered it in previous hour. Very similar to yours, but with 1 less variable.

This solves in less that a minute for a larger instance: 25 employees / 2 weeks. Of note: if you are expanding on this for multiple weeks, right now, the 4/5 day limit is aggregated over the weeks and is not looked at on a weekly basis.

A couple of extensions on this are possible... You can add multiple items to the employee table if they are able to cover different brand items. I don't think it would be much work to make different working hours (as you suggest) for each brand item with some care to the possible start hours, etc.

Code:

import itertools

import pulp
from pulp import LpProblem, LpVariable, LpBinary, lpSum

n_weeks = 2
days_per_week = 7
total_days = n_weeks * days_per_week
shop_open = 10  # 10 AM
shop_close = 20  # 8 PM
min_working_hours = 5
max_working_hours = 9
n_employees = 25

### DATA

# 5-day or 4-day contract type (60% each for now)
five_days_count = n_employees * 0.6

brands = {
    "PHONE": ["PHONE1", "PHONE2"],
    "TV": ["TV1", "TV2", "TV3"]
}
brand_items = tuple(itertools.chain(*brands.values()))

# employee table  e# : ( 4/5 day , [brand_item, ...] )
employees = {f'e{i}': (5*n_weeks if i < five_days_count else 4*n_weeks, [brand_items[i % len(brand_items)]]) for i in range(n_employees)}
# print(employees)

staffing_requirements = {
    "PHONE1": 1,
    "PHONE2": 1,
    "TV1": 1,
    "TV2": 1,
    "TV3": 1}

# the hours on which it is possible to commence a 5-hr shift
poss_starts = [hr for hr in range(shop_open, shop_close - min_working_hours + 1)]

prob = LpProblem('shift_sked')

### SETS / INDEXES

hours = list(range(shop_open, shop_close))  # convenience for readability...
days = list(range(total_days))
#                                                     Domain trim-down:  vvvv                 vvvv
EDHB_employees = {(e, d, h, b) for e in employees for d in days for h in poss_starts for b in employees[e][1]}
EDHB_brand_items = {(e, d, h, b) for e in employees for d in days for h in hours for b in employees[e][1]}

### VARS

start_shift = LpVariable.dicts('start', indices=EDHB_employees, cat=LpBinary) # e starts shift on day d, hour h, for brand item b
covers = LpVariable.dicts('covers', indices=EDHB_brand_items, cat=LpBinary)  # e covers brand item b on day d, hour h
max_shifts = LpVariable('max_shifts')  # the max shifts by any employee

### OBJ:  Minimize shift starts
prob += lpSum(start_shift)

# alternate objectives for experimenting...
# prob += lpSum(covers)  # should produce days*hours*requirements
# prob += max_shifts  # the max number of shifts by any employee  NOTE:  This is tougher solve, longer...

### CONSTRAINTS

# 1 & 2.  Limit shift starts by 4/5 day limit, and can only start 1 shift/day
for e in employees:
    prob += sum(start_shift[e, d, h, b] for d in days for h in poss_starts for b in employees[e][1]) <= employees[e][0]
    for d in days:
        prob += sum(start_shift[e, d, h, b] for h in poss_starts for b in employees[e][1]) <= 1

# 3. Link coverage to shift starting
for (e, d, h, b) in EDHB_brand_items:
    if b not in employees[e][1]:  # this employee cannot 'cover' this item
        prob += covers[e, d, h, b] <= 0
    elif h in poss_starts:  # a start or continued coverage can work...
        prev_hour = covers[e, d, h-1, b] if h > shop_open else None
        prob += covers[e, d, h, b] <= start_shift[e, d, h, b] + prev_hour
    else:  # only previous hour coverage can work (start not possible)
        prob += covers[e, d, h, b] <= covers[e, d, h-1, b]

# 4.  min/max coverage
for e in employees:
    for d in days:
        starts_shift = sum(start_shift[e, d, h, b] for h in poss_starts for b in employees[e][1])
        prob += sum(covers[e, d, h, b] for h in hours for b in employees[e][1]) <= max_working_hours * starts_shift
        prob += sum(covers[e, d, h, b] for h in hours for b in employees[e][1]) >= min_working_hours * starts_shift

# 5.  brand-item coverage minimums
for (d, h, b) in itertools.product(days, hours, brand_items):
    prob += sum(covers[e, d, h, b] for e in employees if b in employees[e][1]) >= staffing_requirements[b]

# 6.  Capture max shifts
for e in employees:
    prob += max_shifts >= sum(start_shift[e, d, h, b] for d in days for h in poss_starts for b in employees[e][1])

# print(prob)

cbc_path = '/opt/homebrew/opt/cbc/bin/cbc'
solver = pulp.COIN_CMD(path=cbc_path)
res = prob.solve(solver)

# highs_path = '/opt/homebrew/bin/highs'
# solver_2 = pulp.HiGHS_CMD(path=highs_path)
# res = prob.solve(solver_2)

Partial Output:

Result - Optimal solution found

Objective value:                140.00000000
Enumerated nodes:               1064
Total iterations:               36758
Time (CPU seconds):             56.91
Time (Wallclock seconds):       61.49

Option for printingOptions changed from normal to all
Total time (CPU seconds):       56.93   (Wallclock seconds):       61.51
Sign up to request clarification or add additional context in comments.

7 Comments

Thank you for your detailed response! It's greatly appreciated. For the following considerations Would the problem still be feasible within reasonable CPU time? - (1) Different brands have varying opening and closing times, or even closed days. - (2) For a 4-day contract, ensuring at least 2 consecutive days off in the same week. - (3) When planning over 4 weeks or a month, guaranteeing at least days 6 and 7 off in one week for the 5-day and 4-day contracts. - (4) Determining the optimal number of employees by starting with an optimal value and decrementing it in a loop until it fail.
What do you think if we frame the problem differently? For instance: For the 4-day contract, the weekly requirement is: 3 days at 9 hours each day + 1 day at 8 hours For the 5-day contract, the weekly requirement is: 5 days at 7 hours each day. Considering all other constraints remain the same, do you believe this would simplify the solution? Thanks a lot for your help !
I think that's about 9 more questions! lol. All seems doable. A few more variables are likely needed. Go small, keep the time window as small as possible. For example, do you need to solve for multiple weeks? etc. With a small tweak, you can use the current model to calculate the minimal employees needed. Just introduce a new variable employee_used[e] with a big-M constraint to capture if he/she has any shift starts and minimize that variable
Also...forgot to mention. If it starts to bog down, putting even a tiny relative gap into the solver options can make a huge difference sometimes and may only give up some tiny optimality guarantee. The code above does 4 weeks / 25 employees in about 30 seconds with relGap = 0.02 added
To understand your code correctly you choosed to trim down the space of employee assigned to a particular brand meaning that one employee (ex: e1) will be only able to work on TV1 right ?
|
2

The every-hour constraint requires a fairly severe proliferation of variables. (This question is still running on my laptop; we'll see when it finishes - I'll leave it for the day.)

To see quicker results where the hours do not respect start-and-end contiguity constraints, comment out the hourlo/hourhi and it finishes in ~10 s.

import pulp
import pandas as pd


n_weeks = 1
days_per_week = 7
total_days = n_weeks * days_per_week
shop_open = 10  # 10 AM
shop_close = 20  # 8 PM
min_working_hours = 5
max_working_hours = 9
n_employees = 20

# 5-day or 4-day contract type (50% each for now)
n_five_days = n_employees // 2


def make_vars() -> tuple[
    pd.DataFrame,  # employee contract info
    pd.DataFrame,  # shift information by employee and day
    pd.DataFrame,  # brand support by employee, day and brand
    pd.DataFrame,  # brand category and staffing requirements
    pd.DataFrame,  # staffing by employee, day, hour, and brand
]:
    employees = pd.DataFrame(
        data={'work_days': (4,)*(n_employees - n_five_days) + (5,)*n_five_days},
        index=pd.RangeIndex(name='employee', start=0, stop=n_employees),
    )

    brands = pd.DataFrame(
        data={
            'category': ('PHONE', 'PHONE', 'TV', 'TV', 'TV'),
            'min_staff': (     1,       1,    1,    1,    1),
        },
        index=pd.Index(name='brand', data=('PHONE1', 'PHONE2', 'TV1', 'TV2', 'TV3')),
    )

    day_idx = pd.RangeIndex(name='day', start=0, stop=total_days)
    hour_idx = pd.RangeIndex(name='hour', start=shop_open, stop=shop_close)
    brand_hour_idx = pd.MultiIndex.from_product((
        employees.index, day_idx, brands.index, hour_idx,
    ))
    brand_hour_names = (
         'e' + brand_hour_idx.get_level_values('employee').astype(str) +
        '_d' + brand_hour_idx.get_level_values('day').astype(str) +
        '_h' + brand_hour_idx.get_level_values('hour').astype(str) +
         '_' + brand_hour_idx.get_level_values('brand').astype(str)
    ).to_series(index=brand_hour_idx)
    brand_hours = brand_hour_names.apply(pulp.LpVariable, cat=pulp.LpBinary)

    emp_brand_idx = pd.MultiIndex.from_product((employees.index, day_idx, brands.index))
    emp_brand_names = (
        'e'  + emp_brand_idx.get_level_values('employee').astype(str) +
        '_d' + emp_brand_idx.get_level_values('day').astype(str) +
        '_'  + emp_brand_idx.get_level_values('brand').astype(str)
    ).to_series(name='employee_works_brand', index=emp_brand_idx)
    employee_brands = emp_brand_names.apply(pulp.LpVariable, cat=pulp.LpBinary)

    emp_day_idx = pd.MultiIndex.from_product((employees.index, day_idx))
    emp_day_suffix = (
        '_e'  + emp_day_idx.get_level_values('employee').astype(str) +
        '_d' + emp_day_idx.get_level_values('day').astype(str)
    ).to_series(index=emp_day_idx)
    employee_days = pd.DataFrame({
        'start': ('start' + emp_day_suffix).apply(
            pulp.LpVariable, cat=pulp.LpContinuous,
            lowBound=shop_open, upBound=shop_close - max_working_hours,
        ),
    })
    # All affine expressions, but can be treated as variables
    employee_days['n_hours'] = brand_hours.groupby(['employee', 'day']).sum()
    employee_days['stop'] = employee_days.start + employee_days.n_hours
    employee_days['working'] = employee_brands.groupby(['employee', 'day']).sum()

    return employees, employee_days, employee_brands, brands, brand_hours


def add_constraints(
    model: pulp.LpProblem,
    employees: pd.DataFrame,
    employee_days: pd.DataFrame,
    employee_brands: pd.DataFrame,
    brands: pd.DataFrame,
    brand_hours: pd.DataFrame,
) -> None:
    # Employees work a fixed number of days
    for employee, total in employee_days.working.groupby('employee').sum().items():
        model.addConstraint(
            name=f'contract_e{employee}',
            constraint=total == employees.loc[employee, 'work_days'],
        )

    for (employee, day), day_row in employee_days.iterrows():
        suffix = f'_e{employee}_d{day}'

        # Employees support up to one brand per day
        model.addConstraint(
            name='brandexcl' + suffix,
            constraint=day_row.working <= 1,
        )

        # If an employee works a day, they must work between min_working_hours and max_working_hours
        model.addConstraint(
            name='min_hours' + suffix,
            constraint=day_row.n_hours >= min_working_hours*day_row.working,
        )
        model.addConstraint(
            name='max_hours' + suffix,
            constraint=day_row.n_hours <= max_working_hours*day_row.working,
        )

    # Every brand has at least 'min_staff'
    for (day, hour, brand), total in brand_hours.groupby(['day', 'hour', 'brand']).sum().items():
        model.addConstraint(
            name=f'staffed_d{day}_h{hour}_{brand}',
            constraint=total >= brands.loc[brand, 'min_staff'],
        )

    # For each employee, day, and brand, correlate the hourly staffed-status with the assigned brand
    for (employee, day, brand), total in brand_hours.groupby(['employee', 'day', 'brand']).sum().items():
        brand_assigned = employee_brands.loc[(employee, day, brand)]
        model.addConstraint(
            name=f'hbrand_e{employee}_d{day}_{brand}',
            constraint=total <= brand_assigned*max_working_hours,
        )

    # For each employee, day and hour, constrain the hour's worked-status by shift start and end
    for (employee, day, hour), is_staffed in brand_hours.groupby(['employee', 'day', 'hour']).sum().items():
        day_row = employee_days.loc[(employee, day)]
        suffix = f'_e{employee}_d{day}_h{hour}'
        model.addConstraint(
            name='hourlo' + suffix,
            constraint=is_staffed <= 1 + (hour - day_row.start + 0.5)/(shop_close - shop_open),
        )
        model.addConstraint(
            name='hourhi' + suffix,
            constraint=is_staffed <= 1 - (hour - day_row.stop + 0.5)/(shop_close - shop_open),
        )


def dump(
    employee_brands: pd.DataFrame,
    employee_days: pd.DataFrame,
    brand_hours: pd.DataFrame,
) -> None:
    pd.set_option('display.max_rows', 1_000)
    pd.set_option('display.max_columns', 1_000)
    pd.set_option('display.width', 1_000)

    print('Employee days:')
    employee_days.start = employee_days.start.apply(pulp.LpVariable.value)
    employee_days[['n_hours', 'stop', 'working']] = (
        employee_days[['n_hours', 'stop', 'working']]
        .applymap(pulp.LpAffineExpression.value)
    )
    print(employee_days.unstack('day'), end='\n\n')

    print('Employee brands:')
    employee_brands = employee_brands.apply(pulp.LpVariable.value).astype(int)
    print(employee_brands.unstack('employee'), end='\n\n')

    print('Brand hour staffing:')
    brand_hours = brand_hours.apply(pulp.LpVariable.value).astype(int)
    print(
        brand_hours
        .groupby(['day', 'hour', 'brand'])
        .sum()
        .unstack('hour'),
        end='\n\n'
    )

    print('First day:')
    first_day = brand_hours.loc[(slice(None), 0, slice(None), slice(None))]
    print(first_day.unstack(level='employee'), end='\n\n')



def main() -> None:
    employees, employee_days, employee_brands, brands, brand_hours = make_vars()

    # Minimize wage budget
    model = pulp.LpProblem(name='workforce-planning', sense=pulp.LpMinimize)
    model.objective = employee_days.n_hours.sum()

    add_constraints(model, employees, employee_days, employee_brands, brands, brand_hours)

    print(model)
    model.solve()
    assert model.status == pulp.LpStatusOptimal

    dump(employee_brands, employee_days, brand_hours)


if __name__ == '__main__':
    main()

As it stands, this is not a very good implementation due to being impractically large:

At line 2 NAME          MODEL
At line 3 ROWS
At line 4295 COLUMNS
At line 137996 RHS
At line 142287 BOUNDS
At line 150268 ENDATA
Problem MODEL has 4290 rows, 7840 columns and 111300 elements

CBC has so far only been able to reduce it modestly:

Cbc0038I Full problem 4290 rows 7840 columns, reduced to 1781 rows 1160 columns

1 Comment

Thank you so much for your assistance and the provided code! As demonstrated by @AirSquid it appears vital to narrow down the parameter space. Keeping the current hours structure leads to extended resolution time. Importantly, the hours must be consecutive; any solution without this sequential hour constraint wouldn't be valid.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.